0.28.1 — vendor @joan/procedural-glyph-engine for portable SPA builds (fixes deploy)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

The procedural-glyph-engine dep pointed at a non-portable file:/private/tmp/orby-pkg
path, breaking npm ci in Docker and every main deploy since v0.20.0 (the build
cache masked it until it busted ~Aug 5). Vendor Orby v5.0.0 into web/vendor/,
switch the dep to file:../vendor, and use npm install in the web Dockerfile
(file: deps need install, not ci). Cherry-picked from 3cd4cf9.
This commit is contained in:
2026-08-05 17:27:04 +02:00
parent fa79c1ea25
commit 8ff382a50d
41 changed files with 27242 additions and 326 deletions

224
web/vendor/LICENSE vendored Normal file
View File

@@ -0,0 +1,224 @@
"Commons Clause" License Condition v1.0
The Software is provided to you by the Licensor under the License, as defined
below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights under the
License will not include, and the License does not grant to you, the right to
Sell the Software.
For purposes of the foregoing, "Sell" means practicing any or all of the rights
granted to you under the License to provide to third parties, for a fee or other
consideration (including without limitation fees for hosting or consulting/
support services related to the Software), a product or service whose value
derives, entirely or substantially, from the functionality of the Software. Any
license notice or attribution required by the License must also include this
Commons Clause License Condition notice.
Software: Orby
License: Apache License 2.0
Licensor: Joan Sterjo
-------------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

7
web/vendor/NOTICE vendored Normal file
View File

@@ -0,0 +1,7 @@
Orby
Copyright 2026 Joan Sterjo
This product includes software developed by Joan Sterjo.
The Apache License 2.0 grant is subject to the Commons Clause License
Condition v1.0. See LICENSE for the complete terms.

943
web/vendor/README.md vendored Normal file
View File

@@ -0,0 +1,943 @@
<p align="center">
<img
src="./docs/assets/readme-hero.svg"
alt="Orby — A procedural glyph engine by Joan Sterjo, shown as a deterministic pixel field"
width="100%"
/>
</p>
<h1 align="center">Orby</h1>
<p align="center">
<strong>A procedural glyph engine by Joan Sterjo.</strong><br />
Design one visual identity, invoke semantic states from product code, and keep
every transition responsive, reproducible, and alive.
</p>
<p align="center">
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/"><strong>Open the live Studio</strong></a>
·
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/#download">Download v5.0.0</a>
·
<a href="./docs/QUICKSTART.md">Quick-start guide</a>
·
<a href="./examples/README.md">Runnable examples</a>
</p>
<p align="center">
<img alt="Version 5.0.0" src="https://img.shields.io/badge/version-5.0.0-555555?style=flat-square&amp;labelColor=111111" />
<img alt="Canvas 2D and native ESM" src="https://img.shields.io/badge/runtime-Canvas_2D_%C2%B7_ESM-555555?style=flat-square&amp;labelColor=111111" />
<img alt="Zero runtime dependencies" src="https://img.shields.io/badge/dependencies-0-555555?style=flat-square&amp;labelColor=111111" />
<img alt="TypeScript declarations included" src="https://img.shields.io/badge/types-TypeScript-555555?style=flat-square&amp;labelColor=111111" />
<img alt="Source-available license: Apache 2.0 with Commons Clause" src="https://img.shields.io/badge/license-Apache_2.0_%2B_Commons_Clause-555555?style=flat-square&amp;labelColor=111111" />
</p>
Orby turns product intent—ready, listening, thinking, using a tool, progressing,
completed, failed—into a coherent live glyph. Each frame combines an analytic
silhouette, a seeded field, a persistent pixel gate, and a spatial transition.
The result is motion with identity, not a generic loading ornament.
Orby is distributed as `@joan/procedural-glyph-engine`. Its primary runtime
class is `JoanGlyphEngine`, its browser-native element is `<joan-glyph>`, and
portable Studio recipes use the `.joan.json` format.
The runtime has no third-party dependencies. It ships as native ES modules with
TypeScript declarations, a browser-native web component, deterministic exports,
and a complete offline Studio.
> [!IMPORTANT]
> Orby is source-available under the [Apache License 2.0 subject to the
> Commons Clause License Condition v1.0](./LICENSE). You may use, copy, modify,
> and redistribute it, including inside a larger value-added product. You may
> not sell Orby or a product or service whose value derives entirely or
> substantially from Orby's functionality.
## Choose your path
| I want to… | Start here |
| --- | --- |
| Explore states and tune a recipe | [Open the live Studio](https://joansterjo-celonis.github.io/Procedural-glyph-engine/#playground) |
| Download everything for offline use | [Get the complete integration kit](https://joansterjo-celonis.github.io/Procedural-glyph-engine/#download) |
| Integrate the runtime into a product | [Follow the focused quick-start guide](./docs/QUICKSTART.md) |
| Try plain Canvas, web component, or timed sequences | [Run the included examples](./examples/README.md) |
| Understand the engine deeply | [Architecture](#architecture) · [state language](#the-25-semantic-sprites) · [API](#api-shape) |
| Operate it responsibly | [Accessibility](#accessibility-and-reduced-motion) · [performance](#performance-guidance) · [development](#development-test-and-build) |
<details>
<summary><strong>Complete contents</strong></summary>
- [Quick start](#quick-start)
- [Why Orby](#why-orby)
- [What Orby includes](#what-orby-includes)
- [Self-service download kit](#self-service-download-kit)
- [Architecture](#architecture)
- [The 25 semantic sprites](#the-25-semantic-sprites)
- [Runtime API](#runtime-api)
- [Web component](#web-component)
- [Custom glyphs](#custom-glyphs)
- [Signals, audio, and progress](#signals-audio-and-progress)
- [Export](#export)
- [Timed state sequences](#timed-and-pre-recorded-state-sequences)
- [StateDirector](#statedirector)
- [Studio preset library](#studio-preset-library)
- [Accessibility and reduced motion](#accessibility-and-reduced-motion)
- [Development, test, and build](#development-test-and-build)
- [Performance guidance](#performance-guidance)
- [License](#license)
</details>
## Quick start
Install the extracted integration kit from a consuming project:
```sh
npm install ./joan-procedural-glyph-engine-5.0.0
```
Mount one long-lived engine instance, then drive it with real product state:
```html
<canvas id="ai-glyph" width="160" height="160"></canvas>
<script type="module">
import { createGlyph } from "@joan/procedural-glyph-engine";
const glyph = createGlyph("#ai-glyph", {
sprite: "ai.idle",
seed: "conversation-42",
gridSize: 68,
});
await glyph.transitionTo("ai.thinking");
await glyph.transitionTo("status.success", {
transition: "path-draw",
});
</script>
```
For direct, unbundled browser use, replace the package import with
`./src/joan-engine.js`. For a declarative integration, use the included
[`<joan-glyph>` web component](#web-component). For pre-recorded or timed flows,
use [`playStateSequence()`](#timed-and-pre-recorded-state-sequences).
## Why Orby
| Semantic by default | Deterministic by design | Portable by construction |
| --- | --- | --- |
| Twenty-five states cover the real lifecycle of AI work, from ambient readiness to completion and recovery. | A stable seed preserves visual identity across frames, products, previews, and exports. | Use Canvas 2D, native ESM, a web component, serialized configurations, or a single offline HTML Studio. |
| 25 semantic states | 19 seeded fields | 12 transitions | 12 switch systems | 9 pixel geometries | 68×68 default grid |
| ---: | ---: | ---: | ---: | ---: | ---: |
| Presence → recovery | Quiet → chaotic | Morph → ignite | Dither → trace | Disc → composites | Adaptive quality |
## What Orby includes
- 25 immutable semantic sprite recipes covering presence, field-only ambient
motion, AI activity, status, transfer, and handoff states.
- 19 seeded scalar fields: `fbm`, `ridged`, `domain-warp`, `curl`, `flow`,
`worley`, `voronoi`, `plasma`, `interference`, `vortex`, `metaballs`,
`caustics`, `strata`, `radar`, `constellation`, `liquid`, `electric`,
`ripple`, and `kaleidoscope`.
- 12 pixel-switch strategies: `ordered-dither`, `temporal-blue-noise`,
`threshold-hysteresis`, `sdf-wavefront`, `contour-trace`, `curl-advect`,
`neighbor-propagation`, `radial-cascade`, `path-draw`, `axis-flip`,
`seeded-dissolve`, and `field-morph`.
- 12 transition strategies: `field-morph`, `seeded-dissolve`,
`radial-cascade`, `angular-sweep`, `scanline`, `contour-trace`, `path-draw`,
`axis-flip`, `cluster-dissolve`, `neighbor-ignite`, `glitch-bands`, and
`instant`.
- Nine Canvas 2D pixel shapes: `disc`, `square`, `diamond`, `capsule`, `line`,
`ring`, `cross`, `square-cross`, and `square-cross-ring`. The composite
shapes choose one primitive per cell from its sampled signal lightness.
- Analytic glyph masks with progress, audio-energy, pointer, press, signal, and
reduced-motion inputs.
- Seeded ordered dithering, dwell time, hysteresis, afterglow, and spring
response so pixels switch deliberately instead of flickering at a threshold.
- Runtime registration of sampler functions and bitmap glyphs, plus browser
image-file import.
- PNG, JSON-safe configuration, static SVG, and deterministic frame-sampled
animated SVG export without mutating the live engine.
- A semantic timing helper for escalating long-running reasoning states.
- Serializable named state sequences plus one-off timed playback with
cancellation, pause, resume, and stop controls.
## Self-service download kit
The website's **Download** section publishes a versioned ZIP assembled from the
same runtime source used by the live Studio. The complete integration kit
contains:
- the complete native ESM runtime in `src/`;
- all TypeScript declarations in `types/`;
- this full API and integration reference;
- a focused quick start in `docs/QUICKSTART.md`;
- runnable canvas, web-component, and sequence examples;
- the self-contained `joan-engine-v5.standalone.html` Studio;
- package metadata and a runtime manifest;
- the complete `LICENSE` and `NOTICE`; and
- `SHA256SUMS.txt` covering every packaged payload file.
Beside the ZIP, the website publishes its `.sha256` file and a machine-readable
release manifest. After extracting the kit, install that local folder from a
consuming project—the package is not currently registry-published:
```sh
npm install ./joan-procedural-glyph-engine-5.0.0
```
Alternatively, serve the extracted folder and import `./src/joan-engine.js`
directly, or open the standalone Studio without a build step. The archive grants
the same source-available permissions—and carries the same no-sale condition—as
the repository. See [`LICENSE`](./LICENSE) for the complete terms.
## Architecture
The renderer is intentionally layered. Each layer can be used independently or
composed by `JoanGlyphEngine`.
| Module | Responsibility |
| --- | --- |
| `src/fields.js` | Seed hashing, gradient/value/cellular noise, Bayer dithering, and the canonical scalar-field registry. Every named field samples to `0..1`. |
| `src/glyphs.js` | Resolution-aware analytic glyph coverage functions. Coordinates are normalized to `-1..1`; coverage is `0..1`. |
| `src/sprites.js` | Deep-frozen semantic recipes: glyph, field stack, palette, switching, timing, interaction, labels, and reduced-motion representation. |
| `src/joan-engine.js` | Canvas lifecycle, state transitions, interaction impulses, pixel gating, spring dynamics, drawing, exports, and events. |
| `src/web-component.js` | The `<joan-glyph>` custom element and its attribute-to-engine adapter. |
| `src/state-director.js` | Optional, explicit orchestration for thinking, deep-thinking, still-working, completion, failure, and reset. |
| `src/state-sequence.js` | Reusable, serializable timelines for timed state choreography, playback control, and cancellation. |
| `src/studio.js` | Interactive demo/studio wiring. It is not required by the runtime. |
The frame pipeline keeps meaning, identity, motion, and output as explicit
layers:
```mermaid
flowchart LR
A[Semantic state] --> B[Analytic glyph or field orb]
A --> C[Seeded procedural field stack]
D[Signals and interaction] --> B
D --> C
B --> F[Spatial transition]
C --> F
F --> E[Dither · hysteresis · dwell pixel gate]
E --> G[Spring and afterglow]
G --> H[Canvas · PNG · SVG]
```
Seeds affect the field permutation and per-cell decisions. The same seed,
sprite, coordinates, time, and options produce the same field samples. Animation
time and live interaction still intentionally change a rendered frame.
## The 25 semantic sprites
These IDs are the stable built-in catalog. Short aliases such as `thinking`,
`success`, `error`, `upload`, and `handoff` are accepted, but product code should
prefer the canonical IDs.
<details>
<summary><strong>View all 25 canonical state IDs</strong></summary>
| Canonical ID | Default label | Intended meaning |
| --- | --- | --- |
| `ai.idle` | Ready | Ready and available |
| `ai.ambient-idle` | Ambient ready | Calm presence expressed only through a field |
| `ai.ambient-thinking` | Ambient thinking | Reasoning expressed only through a field |
| `ai.ambient-thinking-symmetric` | Ambient thinking — symmetric | Clean, symmetrical reasoning expressed only through a field |
| `ai.ambient-speaking` | Ambient speaking | Voice output expressed only through a field |
| `ai.listening` | Listening | Capturing voice or input |
| `ai.thinking` | Thinking | Reasoning |
| `ai.thinking-deep` | Reasoning deeply | Deliberate extended reasoning |
| `ai.still-working` | Still working | Work is taking longer than expected |
| `ai.loading` | Loading | Indeterminate startup or wait |
| `ai.progress` | In progress | Determinate completion progress |
| `ai.generating` | Generating | Producing content |
| `ai.searching` | Searching | Searching or retrieving information |
| `ai.tool-use` | Using a tool | Executing a tool or action |
| `ai.speaking` | Speaking | Producing voice output |
| `ai.awaiting-input` | Your input is needed | User action is required |
| `status.success` | Completed | Completed successfully |
| `status.warning` | Warning | Attention is needed for a nonfatal issue |
| `status.error` | Error | Operation failed |
| `status.paused` | Paused | Work is suspended and can resume |
| `status.cancelled` | Cancelled | Operation was stopped |
| `status.offline` | Offline | Disconnected or unavailable |
| `transfer.active` | Transferring | Uploading, downloading, or synchronizing data |
| `workflow.handoff` | Handing off | Passing work to another agent or person |
| `status.celebration` | Milestone completed | A milestone or high-value success |
</details>
Use `listSprites()` to obtain the frozen ordered catalog and `getSprite(id)` to
resolve either a canonical ID or alias.
## Runtime API
### Package entry points
Every JavaScript entry point is native ESM and carries TypeScript declarations.
| Import | Provides |
| --- | --- |
| `@joan/procedural-glyph-engine` | `JoanGlyphEngine`, `createGlyph()`, core catalogs, and rendering utilities |
| `@joan/procedural-glyph-engine/config` | Strict option inspection, validation, and frozen recipe helpers |
| `@joan/procedural-glyph-engine/fields` | Seeded field samplers, noise helpers, and field registration |
| `@joan/procedural-glyph-engine/glyphs` | Analytic glyph masks and glyph registration helpers |
| `@joan/procedural-glyph-engine/sprites` | Immutable semantic sprite catalog and aliases |
| `@joan/procedural-glyph-engine/state-director` | Escalation timing for long-running task presentation |
| `@joan/procedural-glyph-engine/state-sequence` | Serializable timelines and controlled timed playback |
| `@joan/procedural-glyph-engine/web-component` | `<joan-glyph>` element, definition helper, and automatic browser registration |
| `@joan/procedural-glyph-engine/web-component/register` | Explicit side-effect registration for `<joan-glyph>` |
| `@joan/procedural-glyph-engine/styles.css` | Default web-component presentation styles |
### Engine construction
Create one long-lived engine instance per surface. A more fully authored setup
can override the active recipe while preserving the same semantic API:
```js
import { createGlyph } from "@joan/procedural-glyph-engine";
const glyph = createGlyph("#ai-glyph", {
sprite: "ai.idle",
seed: "conversation-42",
gridSize: 68,
pixelShape: "disc",
pixelSwitch: "threshold-hysteresis",
transition: "field-morph",
orbBoundary: "gestalt",
orbBackgroundColor: "#14212b",
orbBackgroundMode: "pixelated",
speed: 1,
density: 1,
});
await glyph.transitionTo("ai.thinking", {
transition: "neighbor-ignite",
duration: 0.5,
preservePhase: true,
});
// Release observers, listeners, and the animation frame when unmounting.
glyph.destroy();
```
`setSprite()` is synchronous and chainable. `transitionTo()` resolves when the
visual transition completes and accepts an `AbortSignal`; use it when product
flow must wait for presentation. For a direct, unbundled demo import, replace
the package import with `./src/joan-engine.js`.
`createGlyph(canvasOrSelector, options)` is the preferred factory.
`createProceduralGlyph({ canvas, ...options })` and
`mountProceduralGlyph(target, options)` provide equivalent construction forms.
The constructor form is equivalent:
```js
import JoanGlyphEngine from "@joan/procedural-glyph-engine";
const glyph = new JoanGlyphEngine(canvas, {
sprite: "ai.loading",
autoplay: true,
});
```
### Lifecycle and configuration
Useful lifecycle and configuration methods include `play()`, `pause()`,
`toggle()`, `activate()`, `resume()`, `renderOnce()`, `setSeed()`,
`setResolution()`, `setField()`, `setPixelShape()`, `setPixelSwitch()`,
`setNonErrorPalette()`, `setOptions()`, `transitionTo()`,
`whenTransitionComplete()`, `configure()`, `inspect()`, `exportConfig()`,
`toDataURL()`, `toBlob()`,
`toSVG()`, `toAnimatedSVG()`, `downloadPNG()`, `downloadSVG()`,
`downloadAnimatedSVG()`, and `destroy()`.
Built-in sprites use layered field stacks. `setField("radar")` deliberately
replaces that stack with one field; call `useRecipeFields()` to restore the
sprite's authored composition. `exportConfig()` records this distinction as
`fieldMode: "recipe" | "override"`, so configurations round-trip faithfully.
Recipe-owned pixel geometry, switching, and transitions serialize as `null`;
explicit global overrides serialize as their string or structured object. This
keeps reconstructed engines on each future state's authored motion recipe.
### Orb boundary and background
Set `orbBoundary: "gestalt"` to replace the continuous circular rim with an
implied edge built from separated pixel clusters. The default is
`orbBoundary: "defined"`, which preserves the authored hard outline. The
Gestalt treatment is available for `ai.idle`, `ai.ambient-idle`,
`ai.ambient-thinking`, and `ai.ambient-speaking`; other sprites retain their
authored silhouette. The option remains configured when moving between states,
so one engine can carry the same boundary preference through a product flow.
Custom circular recipes can opt in with
`composition: { gestaltBoundary: true, gestaltOpenness: 0.5 }`.
```js
glyph.setOptions({ orbBoundary: "gestalt" });
glyph.setOptions({ orbBoundary: "defined" }); // restore the continuous rim
```
`orbBackgroundColor` supplies the color for an independent fill behind the
pixels of the same four supported presence orbs. Choose its treatment with
`orbBackgroundMode`: `"none"` disables the layer, `"solid"` draws a smooth
circle, and `"pixelated"` builds the circle from grid-aligned row runs. The
pixelated mode follows the selected engine resolution, so its edge belongs to
the same grid as the foreground glyph. The fill carries through other states
without painting there, remains visible when the canvas background is disabled,
and is included in static and animated SVG exports.
An initial `orbBackgroundColor` without an explicit mode selects `"solid"`.
Once `"pixelated"` is selected, later color-only patches
preserve that treatment so changing the swatch does not reset the shape.
Clearing the color with `null` or `"transparent"` disables the layer. Set the
mode explicitly when you want to retain a color while temporarily hiding it.
```js
glyph.setOptions({
orbBackgroundColor: "#14212b",
orbBackgroundMode: "pixelated",
});
glyph.setOptions({ orbBackgroundMode: "none" }); // retain the chosen color
glyph.setOptions({ orbBackgroundMode: "solid" });
glyph.setOptions({ orbBackgroundColor: null }); // disable and clear the color
```
Named options fail fast with a nearby-name suggestion instead of silently
falling back. Use the config subpath to validate before constructing an engine,
or to define and freeze a custom recipe:
```js
import { getSprite } from "@joan/procedural-glyph-engine";
import {
defineRecipe,
inspectEngineOptions,
validateEngineOptions,
} from "@joan/procedural-glyph-engine/config";
const options = validateEngineOptions({
sprite: "thinking", // canonicalized to ai.thinking
field: "domain_warp", // canonicalized to domain-warp
gridSize: 68,
quality: "auto",
});
const inspection = inspectEngineOptions(untrustedOptions);
if (!inspection.ok) console.table(inspection.issues);
const branded = defineRecipe({
...getSprite("ai.generating"),
id: "product.generating",
glyph: "product.mark",
});
```
`validateEngineOptions()` is strict and non-mutating. `inspectEngineOptions()`
returns `{ ok, value, issues }`, safely
coerces ordinary HTML-style values, and omits invalid named options.
### API shape
| Operation | Return | Use it for |
| --- | --- | --- |
| `createGlyph(target, options)` | engine | The preferred canvas-or-selector invocation |
| `setSprite(sprite, options)` | engine | Immediate, chainable state commands |
| `transitionTo(sprite, options)` | `Promise<TransitionDetail>` | Waiting for the visual handoff or cancelling it with `signal` |
| `configure(patch)` | engine | Strict, atomic runtime configuration with one consolidated event/render |
| `setOptions(patch)` | engine | Runtime configuration with safe coercion |
| `signal(type, payload)` | engine | Short-lived product events and semantic inputs |
| `on(type, listener)` | unsubscribe function | Typed event subscription with one-call cleanup |
| `inspect()` | JSON-safe snapshot | State, transition, signals, palette, quality, and performance debugging |
| `renderOnce(time)` | stats | Deterministic paused previews and test fixtures |
| `exportConfig()` | JSON-safe object | Reconstructing the authored runtime configuration |
| `destroy()` | `undefined` | Releasing frames, observers, and listeners |
The package declarations expose literal unions for built-in sprites, fields,
pixel shapes, switches, transitions, quality settings, event payloads, recipes,
exports, the custom element, `StateDirector`, and `StateSequencePlayer`.
### Color control
Use `nonErrorPalette` to override any combination of the `background`, `off`,
`ink`, `accent`, and `glow` channels for every state except `status.error`.
Unspecified channels continue to come from each states authored palette.
Use `variants` when shared light or dark surfaces need to preserve the authored
semantic color family. Variant keys match palette names such as `info`,
`success`, `warning`, and `celebration`; explicit top-level channels still win.
```js
const glyph = new JoanGlyphEngine(canvas, {
sprite: "ai.thinking",
nonErrorPalette: {
background: "#e5e6e2",
off: "#c7cbc8",
ink: "#17191e",
variants: {
info: { accent: "#4788aa", glow: "#6aa6c4" },
success: { accent: "#397c57", glow: "#62a27b" },
warning: { accent: "#9a6817", glow: "#bd8b3c" },
},
},
});
glyph.setNonErrorPalette({ ink: "#ffffff", accent: "#53d6c7" });
glyph.setNonErrorPalette({ glow: "transparent" }); // disable the halo
glyph.setNonErrorPalette(null); // restore authored state colors
```
The error state keeps its danger palette so a product-level color choice cannot
erase its failure semantics. The `palette` option provides an explicit global
override—including for errors—when that behavior is needed.
Hex and `rgb()` color values provide consistent Canvas and SVG output.
In the studio, turn **Glow** off to select no glow color. The previous chosen
color is preserved and returns when Glow is enabled again.
## Web component
Importing the web-component subpath registers `<joan-glyph>` in a browser and
is safe to evaluate during SSR:
```js
import "@joan/procedural-glyph-engine/web-component";
```
An explicit side-effect entry is also available for application bootstrap:
```js
import "@joan/procedural-glyph-engine/web-component/register";
```
```html
<joan-glyph
sprite="ai.generating"
seed="answer-108"
resolution="68"
speed="0.9"
density="1"
field="electric"
pixel-shape="diamond"
pixel-switch="curl-advect"
orb-boundary="gestalt"
orb-background-color="#14212b"
orb-background-mode="pixelated"
non-error-background="#10131a"
non-error-off="#293140"
non-error-ink="#f7f8fb"
non-error-accent="#8f7cff"
non-error-glow="#6554e8"
></joan-glyph>
```
Size the host with CSS; its default size is `68px × 68px`.
```css
joan-glyph {
inline-size: 3rem;
block-size: 3rem;
}
```
The five `non-error-*` color attributes are optional and may be added, changed,
or removed at runtime. The boolean `paused` attribute disables autoplay.
`noninteractive` disables pointer interaction and `transparent` disables the
painted background; both react when added or removed. Removing `field`,
`pixel-shape`, or `pixel-switch` restores the recipe-authored behavior. The
optional `orb-boundary` attribute accepts `gestalt`; removing it (or using an
unsupported value) restores the default `defined` boundary. The optional
`orb-background-color` attribute adds the orb-only fill; removing it clears the
fill without changing the canvas background. Use `orb-background-mode="none"`,
`"solid"`, or `"pixelated"` to choose the treatment. Supplying only
`orb-background-color` selects `solid`; removing the mode attribute returns to
that color-driven behavior. The
element forwards the engine's state, signal, palette, playback, configuration,
and inspection methods and exposes the underlying instance as `element.engine`.
## Custom glyphs
A sampler receives normalized `x`, `y`, animation time, and the live engine
context. Return coverage from `0` (off) to `1` (fully covered). Keep the hot
sampler pure and allocation-free.
```js
import {
createProceduralGlyph,
getSprite,
} from "@joan/procedural-glyph-engine";
const glyph = createProceduralGlyph({ canvas, autoplay: true });
glyph.registerGlyph("product.spark", (x, y, time, context) => {
const radius = Math.hypot(x, y);
const spokes = Math.cos(Math.atan2(y, x) * 8 + time * 0.6);
const pulse = 0.04 * Math.sin(time * 1.4 + context.energy * Math.PI);
return radius < 0.42 + spokes * 0.08 + pulse ? 1 : 0;
});
const base = getSprite("ai.generating");
glyph.setSprite({
...base,
id: "product.spark",
label: "Generating with Product",
glyph: "product.spark",
semantic: {
...base.semantic,
category: "custom",
meaning: "generating branded output",
},
labels: {
...base.labels,
default: "Generating with Product",
aria: "Generating branded output",
},
});
```
A two-dimensional numeric array is accepted as a bitmap and its dimensions are
inferred:
```js
glyph.registerGlyph("product.pixel-heart", [
[0, 1, 0, 1, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[0, 1, 1, 1, 0],
[0, 0, 1, 0, 0],
]);
```
In the browser, `loadGlyphFile(file, options)` rasterizes an image file and
activates it as a custom glyph:
```js
await glyph.loadGlyphFile(fileInput.files[0], {
id: "product.uploaded-mark",
label: "Product mark",
baseSprite: "ai.generating",
resolution: 48,
});
```
## Signals, audio, and progress
Signals add short-lived spatial energy without changing semantic state. Signal
names are intentionally open-ended, so the host can mirror its own event model.
```js
glyph.signal("token", { energy: 0.55 });
glyph.signal("search.hit", { x: 0.35, y: -0.2, energy: 0.9, life: 0.8 });
glyph.signal("tool.call", { energy: 1 });
glyph.signal("audio.level", { value: microphoneLevel });
glyph.signal("transfer.direction", { direction: "up" });
glyph.signal("handoff.accepted", { accepted: true });
glyph.signal("network.retry", { energy: 0.8 });
glyph.signal("resume", { value: 1 }); // restores the state held before status.paused
```
`audio.level` updates the smoothed listening/speaking input. `progress` is
special-cased as determinate state:
```js
glyph.setSprite("ai.progress");
glyph.setProgress(0.42); // clamped to 0..1
// Equivalent low-level form:
glyph.signal("progress", { value: 0.42 });
```
`transfer.active` also consumes progress. Register a direction-specific custom
glyph if the product must distinguish upload from download visually.
The engine dispatches `spritechange`, `transitionqueued`, `transitioncomplete`,
`timelinecomplete`, `resume`, `configchange`, `signal`, `activate`, `play`,
`pause`, `qualitychange`, `stats`, and `destroy` events:
```js
glyph.addEventListener("spritechange", ({ detail }) => {
console.log(`${detail.from}${detail.to}`);
});
```
## Export
`downloadPNG()` captures the current canvas. `toSVG()` / `downloadSVG()` create
a vector snapshot using the selected pixel geometry. `toAnimatedSVG()` /
`downloadAnimatedSVG()` simulate an isolated deterministic clone, sample its
pixel gates, and encode shape, opacity, and scale frames without changing the
live engine.
```js
await glyph.downloadPNG("thinking.png");
await glyph.downloadSVG("thinking.svg");
await glyph.downloadAnimatedSVG("thinking.animated.svg", {
duration: 2.4,
fps: 12,
maxGridSize: 48,
});
```
Animated export is intentionally capped and yields between frame batches to
keep the studio responsive. Raise `fps`, duration, or `maxGridSize` only after
checking file size and export time.
## Timed and pre-recorded state sequences
`StateSequencePlayer` turns product-owned state choreography into a small,
reusable API. Definitions are frozen and JSON-friendly, so a host can keep them
beside an agent workflow, load them from its own configuration, or construct a
one-off sequence at the point of use.
```js
import {
StateSequencePlayer,
defineStateSequence,
} from "@joan/procedural-glyph-engine/state-sequence";
const idleThenDone = defineStateSequence("idle-then-done", [
{ sprite: "ai.idle", holdMs: 5_000 },
{ sprite: "status.success", transition: "path-draw" },
]);
const states = new StateSequencePlayer(glyph, {
sequences: [idleThenDone],
transition: "field-morph",
});
const controller = new AbortController();
const result = await states.play("idle-then-done", {
signal: controller.signal,
});
```
The first step is entered immediately by default. Each later step waits for its
visual transition to complete, then holds for `holdMs` before advancing.
`durationMs` is accepted as an alias for `holdMs`; transition `duration` values
continue to use the engine's seconds-based API.
For an inline command, `playStateSequence()` starts immediately and returns the
playback controller:
```js
import { playStateSequence } from
"@joan/procedural-glyph-engine/state-sequence";
const playback = playStateSequence(glyph, [
{ sprite: "ai.ambient-idle", holdMs: 1_500 },
{ sprite: "ai.progress", transition: "contour-trace" },
]);
playback.pause(); // freezes the current hold clock
playback.resume();
await playback.finished;
```
Call `stop()` for an intentional early finish, or pass an `AbortSignal` when
the surrounding task owns cancellation. Aborts reject with `AbortError`;
`stop()` resolves with a `stopped` result. Starting another playback safely
stops the active run and starts the new one. Pausing freezes the hold clock and
step progression;
an already-running visual transition continues to settle. `sequencestart`,
`stepstart`, `statechange`, `stepenter`,
`stepcomplete`, `sequencepause`, `sequenceresume`, `sequencestop`,
`sequencecancel`, `sequencecomplete`, and `sequenceerror` events expose the
full lifecycle. Completed, stopped, cancelled, and destroyed players clear
their timeout and abort listeners.
## StateDirector
`StateDirector` is an optional presentation timer. The host still owns the real
task state; the director never guesses whether work started, succeeded, or
failed.
```js
import { StateDirector } from
"@joan/procedural-glyph-engine/state-director";
const director = new StateDirector(glyph, {
deepThinkingAfterMs: 7_000,
stillWorkingAfterMs: 18_000,
transition: "field-morph",
});
director.beginThinking();
try {
await performWork();
director.complete(); // status.success + a completion impulse
} catch (error) {
director.fail(); // status.error
}
// Return to a clean phase for the next operation.
director.reset();
// Clear pending escalation timers when the owner unmounts.
director.destroy();
glyph.destroy();
```
`beginThinking()` moves from `ai.thinking` to `ai.thinking-deep`, then to
`ai.still-working` at the configured thresholds. `set()`, `complete()`,
`fail()`, or `reset()` cancels pending timers.
For the common one-task lifecycle, `run()` removes the surrounding state
boilerplate and rethrows failures after presenting them:
```js
const controller = new AbortController();
const result = await director.run(
({ signal }) => performWork({ signal }),
{
signal: controller.signal,
deepThinkingAfterMs: 7_000,
stillWorkingAfterMs: 18_000,
successSprite: "status.success",
errorSprite: "status.error",
cancelledSprite: "status.cancelled",
},
);
```
Starting another director operation prevents earlier asynchronous work from
overwriting its state. Aborting moves to `status.cancelled` and rejects with an
`AbortError`. Every manual, escalated, successful, failed, cancelled, and reset
state dispatches `statechange` with `{ state, from, to, reason }`.
## Studio preset library
`Save preset` and `Save current` add the tuned recipe to the visible **Saved
presets** tile library above the State Atlas. The Studio keeps up to 24 entries
in this browser's local storage under `joan.studio.recent-presets.v1`; they do
not sync to another browser or device. A tile restores its recipe, Delete has
an immediate Undo action, and **Prepare recipe** creates a portable `.joan.json`
file when a preset must move beyond browser-local Studio state.
## Accessibility and reduced motion
- The engine assigns the canvas `role="img"` and updates its accessible label
when interaction is disabled. Interactive canvases use button semantics,
support Enter and Space activation, and expose a visible focus treatment.
Pass an `ariaLive` element when state changes should be announced.
- Keep a visible text status beside the glyph. Do not communicate success,
warning, failure, progress, or waiting through motion or color alone.
- Avoid duplicate announcements: use `ariaLive` only if the surrounding product
does not already announce the same state.
- `reducedMotion: "system"` is the default. It follows
`prefers-reduced-motion`, pauses continuous autoplay, renders representative
glyph phases, and uses 100120 ms transitions for the built-in recipes;
explicit reduced-motion durations remain capped at 200 ms.
- Set `reducedMotion: true` to force the static behavior or `false` only when the
product has a deliberate, user-controlled motion policy.
- Semantic recipes include a reduced-motion representation and use palettes
designed to remain understandable in monochrome, but application-level
contrast and surrounding copy still need product accessibility review.
- Call `setProgress()` even in reduced motion. Data changes remain meaningful
when spatial animation is removed.
## Development, test, and build
Requirements: Node.js 18 or newer; Node.js 20 is the tested handoff target.
> [!NOTE]
> The commands in this section require a repository clone. The downloadable
> integration kit intentionally includes runtime sources, types, documentation,
> examples, and the offline Studio—not the project build scripts or test suite.
```sh
# Run the local studio at http://127.0.0.1:4173
npm run dev
# Run dependency-free node:test contract tests
npm test
# Recreate dist/, the standalone HTML build, and the website download kit
npm run build
# Run tests, build, then verify the complete download artifact
npm run check
# Validate, build, and create the installable .tgz package
npm pack
```
`npm pack` is suitable for local integration testing. The resulting package
includes `LICENSE` and `NOTICE` and carries the same source-available terms as
the repository.
Set `JOAN_ENGINE_PORT` to use a different development port:
```sh
JOAN_ENGINE_PORT=4400 npm run dev
```
The studio preview defaults to **Fit**. Switch it to **1:1** to center the glyph
at its actual grid footprint, with one engine cell mapped to one CSS pixel. This
is a studio-only inspection view and does not alter exported recipe configuration.
The build copies the native module sources and studio assets into `dist/`,
creates the Sites-compatible `dist/client` and `dist/server/index.js` outputs,
generates the versioned ZIP, checksums, and release manifest in `downloads/`,
and creates `joan-engine-v5.standalone.html`. Commit source files rather than
editing generated output.
## Performance guidance
Rendering cost grows approximately with `gridSize²`. The runtime defaults to
68×68 and uses adaptive quality to hold its frame budget. Override that with
2436 for compact product icons or dense multi-glyph surfaces.
- Reuse an engine and call `setSprite()`; do not construct an engine for every
state change.
- For thumbnail grids, set `autoplay: false`, `autoResize: false`,
`interactive: false`, `quality: "low"`, `dprMax: 1`, and call `renderOnce()`
only when a preview needs updating.
- Use `fps: 24` or `30` for ambient UI. Reserve 60 fps for close, interactive
motion.
- Set `offPixels: false` to remove the background-dot draw pass. Disable the
painted background with `background: false` when the product surface already
supplies one.
- `quality: "auto"` adapts between `high`, `balanced`, and `low` from measured
frame cost. Read the effective tier from `stats.quality` or subscribe to
`qualitychange`. Balanced and low tiers time-slice field sampling across the
grid, and low also skips the glow path. Use a fixed high tier or
`renderOnce()` for full-grid deterministic visual fixtures.
- Cap device-pixel work with `dprMax`; a value of `1` is often enough for
pixel-art thumbnails and dense dashboards.
- Disable pointer work with `interactive: false` for decorative or
noninteractive instances.
- Keep custom sampler functions allocation-free and avoid DOM reads, object
creation, and network/state access inside them.
- Let the built-in visibility observation cancel animation-frame scheduling for
hidden and off-screen canvases; it resumes automatically when visible. Always
call `destroy()` when removing an instance.
- Watch the `stats` event (`fps`, `frameMs`, `sampledPixels`, `activePixels`, and `resolution`) in
realistic multi-glyph screens, not just an isolated demo.
For deterministic visual regression fixtures, use `autoplay: false`, disable
interaction and auto-resize, set a fixed canvas size and seed, then call
`renderOnce(fixedTime)`.
## License
Orby is source-available under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)
subject to the [Commons Clause License Condition v1.0](https://commonsclause.com/).
- You may use, copy, modify, and redistribute Orby, and include it in a larger
value-added product, subject to the full terms.
- You may not sell Orby or offer a product or service whose value derives
entirely or substantially from Orby's functionality.
- Keep the required license and attribution notices when redistributing it.
Because it restricts selling, this is a **source-available** license rather than
an OSI-approved open-source license. Read [`LICENSE`](./LICENSE) for the complete
terms and [`NOTICE`](./NOTICE) for attribution.
---
<p align="center">
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/">Live Studio</a>
·
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/#download">Download kit</a>
·
<a href="#orby">Back to top</a>
</p>

36
web/vendor/SHA256SUMS.txt vendored Normal file
View File

@@ -0,0 +1,36 @@
6dea5372df18fef1b3056ad6423552c7785a8a17e2f9fec705463f6028439b28 docs/assets/readme-hero.svg
99b15d457eeba4e7414bc210d7040019a54f9fa838291bde1d3875c1bbca78e9 docs/QUICKSTART.md
01b1bc8b37a39be3d8ae3be7da71668f0bcdfa0bcb07f847f91a59950ecebbc2 examples/canvas.html
cc08f683e352db9566b5839bc918302b0e681937cdc035aadcfc7bcae588c291 examples/example.css
0c46216df56ce372cafd26033a925f0462b47f510a014f1bc66a8b706b75d410 examples/README.md
c0b22bf741c75cc025ab7717a6db44dbc5fd93fb28396767d369c2003a9d83af examples/state-sequence.html
6802fde51637fe054cf870eb2c5f9a49907c474bc5ff347ea71cee7b3d636541 examples/web-component.html
cd26e4164fa1fac69c93c45723a4412f68bd667be2c94337d99877a374a71418 joan-engine-v5.standalone.html
31763c5e3583d9a5ad610f17456ad1e68f4fb2b73b7f53a735e418c3e1a7e81e LICENSE
7a9441bdc68f23ada12bc4782fc3f7962d795f3738735638b754c195fdd6c21d manifest.json
55cf377b8808993288f9aadfa309965eb0aaef8df3896fb487cc94c4f65c03b4 NOTICE
77fc5e0a77ee82154d3a328827b4cc3e530729bdda8bc18db656f1aa1d916909 package.json
a85a9d9b7f723672b630882d54a3c7e82675afb6d14ebcedf63f0e66e16f4bcd README.md
324f45dfca15074ab65508e433cc20aa2124a196dad09d98d0a1ed7c27e7c503 src/config.js
2bb9921205babd2204bdc03abc8a1bd2c916fa4ce4c203d8822e18289dd6f15d src/control-help.js
547603442b1db4739f047833a3da8ea6ed518c49753260e2d488417004340fae src/fields.js
a36015cbe69f7ce3b5c218189a4250a6baef7afe814492ed051643312bd34bf5 src/glyphs.js
a759eaad654030e5b143f02641a2a4c41f997641e0add34ba03ee217861d2dc0 src/joan-engine.js
1237112f05e55f46341e2b10e12f4b372d39cd7c34e05e62af8bb7ceb894250b src/preview-layout.js
75c0be5f862135dac7b598974f99fb91e1b3a925c28d69815f374d4ff17ef86b src/preview-thumbnails.js
c69b77f9c9592a0f9e417ee7be17f430139713945120d01478d43b7de587390d src/sprites.js
175a7172707611fe456db159182350d0967665f673c231f443e69660ae09038c src/state-director.js
ea45c075e503ee0dd4545556f61bbceff309dea722ceda96d5eb7baafa4f4e67 src/state-sequence.js
55f4d43cf50928aed47d9233653ab971593995923005154f926814acecb43129 src/studio.js
4372628fbca51ab2a19e4fbb0d2fd21bb0ef852fe17a61876188f1cacdab6aea src/styles.css
7b61ac0803413c4c766db4f03c049dc24ee47688086abb85e7ddfdc9db0250b7 src/web-component-register.js
4952d2ccdfdf98cc778840b464521983ac6e8ab215f90b1d865680258f808ac6 src/web-component.js
f1d8fe2141b766da1b4bba4b88306a436f0f1971fce8caf26f18a562e0e65abb types/config.d.ts
5d0c433ecf5356ed46a3edd318612ab76e818df1b4497a6c195b4c0c1f4c7cb5 types/fields.d.ts
59aa75e1149319de9b191c44feddeeef8e1a12ce4e662b057c2ecc31a7ca6e3a types/glyphs.d.ts
a62e4a17927b318a05580863141610b0767a00ee87031e17090e3597019572a3 types/index.d.ts
99309aab1f114260456db816072039a7f1d65a8ec5bf3fb5d3d0ec4c4fcd5536 types/sprites.d.ts
22a56518acd9626699d5bb39bb63a29aa285ef825b0b08bc4ec6bb6111cf4fb2 types/state-director.d.ts
3a6bc92f0e3e774d8bbabac49b6b35209ff6995cde04d5f9e37b8cc7c4b4f5db types/state-sequence.d.ts
59f77bee8bfe215a88d248f8ca06362905362154026f4868bbab58e733271a25 types/web-component-register.d.ts
127ee9b7678fb4090207a4d8784643ab52f5ec5db2567bf61dd0fefee40c8b50 types/web-component.d.ts

112
web/vendor/docs/QUICKSTART.md vendored Normal file
View File

@@ -0,0 +1,112 @@
# Orby — quick start
**A procedural glyph engine by Joan Sterjo.**
The download kit is self-contained. Start with the path that matches how you
want to use the engine; no registry download or third-party runtime dependency
is required.
## 1. Open the standalone Studio
Open `joan-engine-v5.standalone.html` in a modern browser. It contains the
Studio, engine modules, and styles in one file, so it works without a build
step or local server. Use this path to explore states, tune a recipe, and export
PNG, SVG, animated SVG, or `.joan.json` output.
## 2. Import the native ES modules
Serve the extracted folder from a local HTTP server:
```sh
cd joan-procedural-glyph-engine-5.0.0
python3 -m http.server 4173
```
Then import the engine directly from the included source:
```html
<canvas id="ai-glyph" width="160" height="160"></canvas>
<script type="module">
import { createGlyph } from "./src/joan-engine.js";
const glyph = createGlyph("#ai-glyph", {
sprite: "ai.idle",
seed: "product-session",
gridSize: 68,
});
await glyph.transitionTo("ai.thinking");
await glyph.transitionTo("status.success", {
transition: "path-draw",
});
</script>
```
Open `examples/canvas.html` for a complete interactive version.
## 3. Install the extracted folder locally
From an application beside the extracted kit:
```sh
npm install ./joan-procedural-glyph-engine-5.0.0
```
Use the package and its TypeScript declarations through the included exports:
```js
import { createGlyph } from "@joan/procedural-glyph-engine";
import { playStateSequence } from
"@joan/procedural-glyph-engine/state-sequence";
const glyph = createGlyph("#ai-glyph", {
sprite: "ai.ambient-idle",
seed: "conversation-42",
gridSize: 68,
});
const playback = playStateSequence(glyph, [
{ sprite: "ai.ambient-idle", holdMs: 5_000 },
{ sprite: "ai.progress", transition: "contour-trace" },
{ sprite: "status.success", transition: "path-draw" },
]);
await playback.finished;
```
## Web component
Import `src/web-component-register.js`, then use `<joan-glyph>` anywhere in the
page. Its intrinsic default is 68 × 68 CSS pixels.
```html
<script type="module" src="./src/web-component-register.js"></script>
<joan-glyph sprite="ai.thinking" seed="conversation-42"></joan-glyph>
```
Open `examples/web-component.html` for a runnable example.
## Runtime essentials
- `glyph.transitionTo(sprite, options)` performs and awaits a visual handoff.
- `glyph.setSprite(sprite)` changes state immediately and remains chainable.
- `glyph.setOptions(patch)` updates the live engine safely.
- `glyph.signal(type, payload)` injects short-lived product input.
- `glyph.exportConfig()` returns a JSON-safe recipe.
- `glyph.toSVG()` and `glyph.downloadPNG()` create portable output.
- `glyph.destroy()` releases animation frames, observers, and listeners.
See `README.md` for the complete state catalog, configuration surface, API,
events, custom fields and glyphs, orchestration, exports, performance guidance,
accessibility, and testing.
## Verify the kit
`SHA256SUMS.txt` lists every payload file included in the archive. The website also
publishes a checksum beside the ZIP so the archive itself can be verified
before extraction.
Orby is source-available under the Apache License 2.0 subject to the Commons
Clause License Condition v1.0. You may use, copy, modify, and redistribute it,
but may not sell Orby or a product or service whose value derives entirely or
substantially from Orby's functionality. See `LICENSE` and `NOTICE` in the kit.

162
web/vendor/docs/assets/readme-hero.svg vendored Normal file
View File

@@ -0,0 +1,162 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 760" role="img" aria-labelledby="title description" preserveAspectRatio="xMidYMid meet">
<title id="title">Orby — A procedural glyph engine by Joan Sterjo.</title>
<desc id="description">A monochrome editorial banner for Orby, a procedural glyph engine by Joan Sterjo, showing a procedural pixel orb and engine statistics: 25 semantic states, 19 fields, 12 transitions, and 9 pixel shapes.</desc>
<defs>
<pattern id="page-grid" width="64" height="64" patternUnits="userSpaceOnUse">
<path d="M64 0H0V64" fill="none" stroke="#ffffff" stroke-opacity="0.045" stroke-width="1"/>
<circle cx="1" cy="1" r="1" fill="#ffffff" fill-opacity="0.09"/>
</pattern>
<pattern id="micro-grid" width="12" height="12" patternUnits="userSpaceOnUse">
<rect x="5.25" y="5.25" width="1.5" height="1.5" fill="#f2f1ee" fill-opacity="0.46"/>
</pattern>
<pattern id="dither-a" width="32" height="32" patternUnits="userSpaceOnUse">
<rect x="3" y="3" width="3" height="3" fill="#f2f1ee"/>
<rect x="19" y="7" width="2" height="2" fill="#f2f1ee" fill-opacity="0.72"/>
<rect x="10" y="22" width="4" height="4" fill="#f2f1ee" fill-opacity="0.9"/>
<rect x="27" y="26" width="2" height="2" fill="#f2f1ee" fill-opacity="0.5"/>
</pattern>
<pattern id="dither-b" width="48" height="48" patternUnits="userSpaceOnUse" patternTransform="rotate(9)">
<rect x="5" y="8" width="6" height="6" fill="#f2f1ee"/>
<rect x="29" y="3" width="3" height="3" fill="#f2f1ee" fill-opacity="0.7"/>
<rect x="18" y="31" width="4" height="4" fill="#f2f1ee" fill-opacity="0.86"/>
<rect x="41" y="38" width="2" height="2" fill="#f2f1ee" fill-opacity="0.48"/>
</pattern>
<linearGradient id="field-tone" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f2f1ee" stop-opacity="0.92"/>
<stop offset="0.38" stop-color="#c9c8c4" stop-opacity="0.62"/>
<stop offset="0.72" stop-color="#8b8d90" stop-opacity="0.25"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.08"/>
</linearGradient>
<radialGradient id="orb-ground" cx="35%" cy="28%" r="78%">
<stop offset="0" stop-color="#252628"/>
<stop offset="0.55" stop-color="#111214"/>
<stop offset="1" stop-color="#080809"/>
</radialGradient>
<linearGradient id="density-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="0.24" stop-color="#ffffff" stop-opacity="0.9"/>
<stop offset="0.56" stop-color="#ffffff" stop-opacity="0.38"/>
<stop offset="0.82" stop-color="#ffffff" stop-opacity="0.12"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.02"/>
</linearGradient>
<clipPath id="orb-clip">
<circle cx="1232" cy="362" r="238"/>
</clipPath>
<mask id="orb-density" maskUnits="userSpaceOnUse" x="975" y="105" width="514" height="514">
<rect x="975" y="105" width="514" height="514" fill="url(#density-gradient)"/>
</mask>
<mask id="crescent" maskUnits="userSpaceOnUse" x="975" y="105" width="514" height="514">
<rect x="975" y="105" width="514" height="514" fill="#000000"/>
<circle cx="1202" cy="362" r="222" fill="#ffffff"/>
<circle cx="1280" cy="326" r="204" fill="#000000"/>
</mask>
</defs>
<rect width="1600" height="760" fill="#050505"/>
<rect width="1600" height="760" fill="url(#page-grid)"/>
<path d="M0 80H1600M800 0V760M0 632H1600" fill="none" stroke="#ffffff" stroke-opacity="0.08"/>
<rect x="24" y="24" width="1552" height="712" rx="24" fill="none" stroke="#ffffff" stroke-opacity="0.12"/>
<g fill="none" stroke="#f2f1ee" stroke-opacity="0.38">
<path d="M48 72V48H72"/>
<path d="M1528 48H1552V72"/>
<path d="M48 688V712H72"/>
<path d="M1528 712H1552V688"/>
</g>
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" fill="#8b8d90">
<text x="88" y="84" font-size="13" letter-spacing="4">SYSTEM / 05</text>
<text x="312" y="84" font-size="13" letter-spacing="4" fill="#c9c8c4">DETERMINISTIC · LIVE · PORTABLE</text>
<text x="1512" y="84" text-anchor="end" font-size="13" letter-spacing="3">V5.0.0</text>
</g>
<g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">
<text x="82" y="238" fill="#f2f1ee" font-size="128" font-weight="650" letter-spacing="12">ORBY</text>
<text x="88" y="292" fill="#c9c8c4" font-size="18" font-weight="600" letter-spacing="3">A PROCEDURAL GLYPH ENGINE BY JOAN STERJO.</text>
<text x="86" y="398" fill="#f2f1ee" font-size="43" font-weight="420" letter-spacing="-1.3">
<tspan x="86" dy="0">A living signal for every</tspan>
<tspan x="86" dy="51">state of intelligence.</tspan>
</text>
<text x="88" y="514" fill="#8b8d90" font-size="18" font-weight="400">
<tspan x="88" dy="0">Deterministic pixel motion for expressive AI states,</tspan>
<tspan x="88" dy="29">responsive transitions, and coherent product presence.</tspan>
</text>
</g>
<g aria-hidden="true">
<path d="M941 363c-68-58-105-120-88-171 20-60 111-83 224-64 114 19 231 76 305 146 74 70 105 145 77 197-23 43-91 62-179 52" fill="none" stroke="#f2f1ee" stroke-opacity="0.11" stroke-dasharray="2 9"/>
<path d="M1004 576c-78 23-137 12-153-29-22-57 47-151 161-221 114-70 249-102 333-72 69 25 76 87 27 158" fill="none" stroke="#c9c8c4" stroke-opacity="0.13" stroke-dasharray="1 8"/>
<circle cx="1232" cy="362" r="266" fill="none" stroke="#ffffff" stroke-opacity="0.07"/>
<circle cx="1232" cy="362" r="250" fill="none" stroke="#c9c8c4" stroke-opacity="0.18" stroke-dasharray="2 7"/>
<circle cx="1232" cy="362" r="238" fill="url(#orb-ground)" stroke="#f2f1ee" stroke-opacity="0.38"/>
<g clip-path="url(#orb-clip)">
<rect x="990" y="120" width="484" height="484" fill="url(#micro-grid)" opacity="0.48"/>
<rect x="984" y="114" width="496" height="496" fill="url(#dither-a)" mask="url(#orb-density)" opacity="0.82"/>
<rect x="984" y="114" width="496" height="496" fill="url(#dither-b)" mask="url(#crescent)" opacity="0.95"/>
<path d="M1017 425c82-53 172-45 212 10 39 54 9 136-69 178" fill="none" stroke="#f2f1ee" stroke-opacity="0.42" stroke-width="2" stroke-dasharray="2 10"/>
<path d="M1091 172c60 56 146 74 207 39 57-32 79-99 55-159" fill="none" stroke="#f2f1ee" stroke-opacity="0.24" stroke-dasharray="2 8"/>
<path d="M1140 322c18-63 83-99 143-79 60 20 92 87 69 148-24 61-92 89-150 62-57-26-80-95-51-151" fill="none" stroke="#f2f1ee" stroke-opacity="0.36" stroke-dasharray="1 7"/>
<path d="M1178 342c9-37 47-59 83-49 37 10 58 49 46 86-12 36-51 56-87 42-35-13-52-54-37-88" fill="none" stroke="#f2f1ee" stroke-opacity="0.28" stroke-dasharray="1 6"/>
<path d="M994 362H1470M1232 124V600" stroke="#ffffff" stroke-opacity="0.055" stroke-dasharray="1 11"/>
</g>
<circle cx="1232" cy="362" r="176" fill="none" stroke="#ffffff" stroke-opacity="0.16" stroke-dasharray="1 8"/>
<circle cx="1232" cy="362" r="72" fill="none" stroke="#f2f1ee" stroke-opacity="0.32" stroke-dasharray="3 8"/>
<circle cx="1232" cy="362" r="20" fill="none" stroke="#f2f1ee" stroke-opacity="0.62"/>
<rect x="1228" y="358" width="8" height="8" fill="#f2f1ee"/>
<g fill="none" stroke="#c9c8c4" stroke-opacity="0.5">
<rect x="948" y="178" width="10" height="10"/>
<rect x="1440" y="148" width="8" height="8"/>
<rect x="1498" y="336" width="12" height="12"/>
<rect x="1434" y="568" width="9" height="9"/>
<rect x="968" y="552" width="12" height="12"/>
</g>
<g fill="#f2f1ee">
<rect x="923" y="300" width="5" height="5" opacity="0.75"/>
<rect x="1481" y="242" width="6" height="6" opacity="0.55"/>
<rect x="1518" y="453" width="5" height="5" opacity="0.8"/>
<rect x="1032" y="612" width="4" height="4" opacity="0.52"/>
</g>
</g>
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
<text x="854" y="84" fill="#8b8d90" font-size="12" letter-spacing="3">LIVE FIELD / 68×68</text>
<text x="1510" y="675" fill="#8b8d90" text-anchor="end" font-size="12" letter-spacing="3">STATE / AI.IDLE</text>
</g>
<g transform="translate(88 664)" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
<g>
<text y="0" fill="#f2f1ee" font-size="30">25</text>
<text y="28" fill="#8b8d90" font-size="11" letter-spacing="2.4">SEMANTIC STATES</text>
</g>
<g transform="translate(190 0)">
<text y="0" fill="#f2f1ee" font-size="30">19</text>
<text y="28" fill="#8b8d90" font-size="11" letter-spacing="2.4">FIELDS</text>
</g>
<g transform="translate(330 0)">
<text y="0" fill="#f2f1ee" font-size="30">12</text>
<text y="28" fill="#8b8d90" font-size="11" letter-spacing="2.4">TRANSITIONS</text>
</g>
<g transform="translate(510 0)">
<text y="0" fill="#f2f1ee" font-size="30">9</text>
<text y="28" fill="#8b8d90" font-size="11" letter-spacing="2.4">PIXEL SHAPES</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

18
web/vendor/examples/README.md vendored Normal file
View File

@@ -0,0 +1,18 @@
# Orby runnable examples
Serve the extracted download folder over HTTP, then open one of these files:
- `canvas.html` mounts the native canvas engine and invokes semantic states.
- `web-component.html` uses the browser-native `<joan-glyph>` element.
- `state-sequence.html` runs, pauses, resumes, and restarts a timed sequence.
For example:
```sh
python3 -m http.server 4173
```
Then visit `http://127.0.0.1:4173/examples/canvas.html`.
Every import is relative to the files included in the kit. No package registry,
bundler, framework, or third-party dependency is required.

50
web/vendor/examples/canvas.html vendored Normal file
View File

@@ -0,0 +1,50 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Orby · Canvas example</title>
<link rel="stylesheet" href="./example.css" />
</head>
<body>
<main>
<h1>Native canvas</h1>
<p>
One long-lived engine instance moves between semantic states while
preserving its seeded visual identity.
</p>
<div class="glyph-stage">
<canvas id="aiGlyph" width="440" height="440" aria-label="Animated AI state"></canvas>
</div>
<div class="actions" role="group" aria-label="Choose a semantic state">
<button type="button" data-sprite="ai.idle">Ready</button>
<button type="button" data-sprite="ai.thinking">Thinking</button>
<button type="button" data-sprite="ai.progress">Progress</button>
<button type="button" data-sprite="status.success">Done</button>
</div>
<p id="stateStatus" role="status">Current state: ai.idle</p>
</main>
<script type="module">
import { createGlyph } from "../src/joan-engine.js";
const glyph = createGlyph("#aiGlyph", {
sprite: "ai.idle",
seed: "self-service-example",
gridSize: 68,
});
const status = document.querySelector("#stateStatus");
document.querySelectorAll("[data-sprite]").forEach((button) => {
button.addEventListener("click", async () => {
const sprite = button.dataset.sprite;
status.textContent = `Transitioning to ${sprite}`;
await glyph.transitionTo(sprite);
status.textContent = `Current state: ${sprite}`;
});
});
window.addEventListener("pagehide", () => glyph.destroy(), { once: true });
</script>
</body>
</html>

90
web/vendor/examples/example.css vendored Normal file
View File

@@ -0,0 +1,90 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #050505;
color: #f2f1ee;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
background:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
#050505;
background-size: 24px 24px;
}
main {
width: min(680px, calc(100% - 32px));
padding: 32px;
border: 1px solid #2b2b2d;
border-radius: 14px;
background: #0b0b0c;
}
h1 {
margin: 0;
font-size: clamp(2rem, 7vw, 4rem);
font-weight: 300;
letter-spacing: -0.055em;
}
p {
max-width: 52ch;
color: #9b9b9f;
line-height: 1.65;
}
.glyph-stage {
min-height: 320px;
margin-top: 28px;
display: grid;
place-items: center;
border: 1px solid #232426;
border-radius: 10px;
background: #070708;
}
canvas,
joan-glyph {
width: 220px;
height: 220px;
display: block;
}
.actions {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
button {
min-height: 42px;
padding: 0 14px;
border: 1px solid #35373a;
border-radius: 7px;
background: #0f1011;
color: #e8e8e4;
font: 600 0.72rem ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.06em;
text-transform: uppercase;
}
button:hover,
button:focus-visible {
border-color: #f2f1ee;
outline: 0;
}
[role="status"] {
min-height: 1.4em;
font: 0.78rem ui-monospace, SFMono-Regular, Menlo, monospace;
}

84
web/vendor/examples/state-sequence.html vendored Normal file
View File

@@ -0,0 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Orby · State sequence example</title>
<link rel="stylesheet" href="./example.css" />
</head>
<body>
<main>
<h1>Timed sequence</h1>
<p>
Define a product-owned timeline, then pause, resume, stop, or replay it
through one controller.
</p>
<div class="glyph-stage">
<canvas id="aiGlyph" width="440" height="440" aria-label="Animated AI state sequence"></canvas>
</div>
<div class="actions" role="group" aria-label="Sequence controls">
<button id="replay" type="button">Replay</button>
<button id="pause" type="button">Pause</button>
<button id="resume" type="button">Resume</button>
<button id="stop" type="button">Stop</button>
</div>
<p id="stateStatus" role="status">Sequence ready</p>
</main>
<script type="module">
import { createGlyph } from "../src/joan-engine.js";
import { playStateSequence } from "../src/state-sequence.js";
const glyph = createGlyph("#aiGlyph", {
sprite: "ai.idle",
seed: "self-service-sequence",
gridSize: 68,
});
const status = document.querySelector("#stateStatus");
let playback;
const play = () => {
playback?.stop();
const nextPlayback = playStateSequence(glyph, [
{ sprite: "ai.idle", holdMs: 1_200 },
{ sprite: "ai.thinking", holdMs: 2_000 },
{ sprite: "ai.progress", holdMs: 1_200 },
{ sprite: "status.success", transition: "path-draw" },
]);
playback = nextPlayback;
status.textContent = "Sequence playing";
nextPlayback.finished.then(
() => {
if (playback === nextPlayback) status.textContent = "Sequence complete";
},
(error) => {
if (playback === nextPlayback) {
status.textContent = `Sequence ended: ${error.name}`;
}
},
);
};
document.querySelector("#replay").addEventListener("click", play);
document.querySelector("#pause").addEventListener("click", () => {
playback?.pause();
status.textContent = "Sequence paused";
});
document.querySelector("#resume").addEventListener("click", () => {
playback?.resume();
status.textContent = "Sequence playing";
});
document.querySelector("#stop").addEventListener("click", () => {
playback?.stop();
status.textContent = "Sequence stopped";
});
window.addEventListener("pagehide", () => {
playback?.stop();
glyph.destroy();
}, { once: true });
play();
</script>
</body>
</html>

48
web/vendor/examples/web-component.html vendored Normal file
View File

@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Orby · Web component example</title>
<link rel="stylesheet" href="./example.css" />
<script type="module" src="../src/web-component-register.js"></script>
</head>
<body>
<main>
<h1>Web component</h1>
<p>
Drop the dependency-free custom element into a page, then drive its
attributes or call the forwarded engine methods.
</p>
<div class="glyph-stage">
<joan-glyph
id="glyph"
sprite="ai.ambient-thinking"
seed="self-service-component"
resolution="68"
orb-boundary="gestalt"
></joan-glyph>
</div>
<div class="actions" role="group" aria-label="Choose a semantic state">
<button type="button" data-sprite="ai.ambient-idle">Ambient ready</button>
<button type="button" data-sprite="ai.ambient-thinking">Thinking</button>
<button type="button" data-sprite="status.success">Done</button>
</div>
<p id="stateStatus" role="status">Current state: ai.ambient-thinking</p>
</main>
<script type="module">
const glyph = document.querySelector("#glyph");
const status = document.querySelector("#stateStatus");
document.querySelectorAll("[data-sprite]").forEach((button) => {
button.addEventListener("click", async () => {
const sprite = button.dataset.sprite;
status.textContent = `Transitioning to ${sprite}`;
await glyph.transitionTo(sprite);
status.textContent = `Current state: ${sprite}`;
});
});
</script>
</body>
</html>

5592
web/vendor/joan-engine-v5.standalone.html vendored Normal file

File diff suppressed because one or more lines are too long

11
web/vendor/manifest.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "@joan/procedural-glyph-engine",
"version": "5.0.0",
"license": "SEE LICENSE IN LICENSE",
"entry": "./src/joan-engine.js",
"demo": "./index.html",
"assets": [
"./src/styles.css"
],
"format": "esm"
}

106
web/vendor/package.json vendored Normal file
View File

@@ -0,0 +1,106 @@
{
"name": "@joan/procedural-glyph-engine",
"version": "5.0.0",
"description": "Orby — A procedural glyph engine by Joan Sterjo.",
"author": "Joan Sterjo",
"homepage": "https://joansterjo-celonis.github.io/Procedural-glyph-engine/",
"repository": {
"type": "git",
"url": "git+https://github.com/joansterjo-celonis/Procedural-glyph-engine.git"
},
"bugs": {
"url": "https://github.com/joansterjo-celonis/Procedural-glyph-engine/issues"
},
"type": "module",
"types": "./types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./src/joan-engine.js",
"default": "./src/joan-engine.js"
},
"./config": {
"types": "./types/config.d.ts",
"import": "./src/config.js",
"default": "./src/config.js"
},
"./fields": {
"types": "./types/fields.d.ts",
"import": "./src/fields.js",
"default": "./src/fields.js"
},
"./glyphs": {
"types": "./types/glyphs.d.ts",
"import": "./src/glyphs.js",
"default": "./src/glyphs.js"
},
"./sprites": {
"types": "./types/sprites.d.ts",
"import": "./src/sprites.js",
"default": "./src/sprites.js"
},
"./state-director": {
"types": "./types/state-director.d.ts",
"import": "./src/state-director.js",
"default": "./src/state-director.js"
},
"./state-sequence": {
"types": "./types/state-sequence.d.ts",
"import": "./src/state-sequence.js",
"default": "./src/state-sequence.js"
},
"./web-component": {
"types": "./types/web-component.d.ts",
"import": "./src/web-component.js",
"default": "./src/web-component.js"
},
"./web-component/register": {
"types": "./types/web-component-register.d.ts",
"import": "./src/web-component-register.js",
"default": "./src/web-component-register.js"
},
"./styles.css": "./src/styles.css",
"./package.json": "./package.json"
},
"sideEffects": [
"./src/web-component.js",
"./src/web-component-register.js",
"./src/styles.css"
],
"files": [
"src",
"types",
"docs",
"examples",
"README.md",
"LICENSE",
"NOTICE",
"dist/manifest.json",
"dist/joan-engine-v5.standalone.html"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "node scripts/dev.mjs",
"build": "node scripts/build.mjs",
"test": "node --test",
"verify:download": "node scripts/verify-download.mjs",
"check": "npm run test && npm run build && npm run verify:download",
"prepack": "npm run check"
},
"keywords": [
"orby",
"canvas",
"glyph",
"pixel",
"procedural-animation",
"ai-status",
"sprite",
"dither"
],
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=18"
}
}

687
web/vendor/src/config.js vendored Normal file
View File

@@ -0,0 +1,687 @@
import { FIELD_ALIASES, FIELD_IDS } from "./fields.js";
import { SPRITE_ALIASES, SPRITE_IDS } from "./sprites.js";
export const TRANSITION_IDS = Object.freeze([
"field-morph",
"seeded-dissolve",
"radial-cascade",
"angular-sweep",
"scanline",
"contour-trace",
"path-draw",
"axis-flip",
"cluster-dissolve",
"neighbor-ignite",
"glitch-bands",
"instant",
]);
export const PIXEL_SWITCH_IDS = Object.freeze([
"ordered-dither",
"temporal-blue-noise",
"threshold-hysteresis",
"sdf-wavefront",
"contour-trace",
"curl-advect",
"neighbor-propagation",
"radial-cascade",
"path-draw",
"axis-flip",
"seeded-dissolve",
"field-morph",
]);
export const PIXEL_SHAPE_IDS = Object.freeze([
"disc",
"square",
"diamond",
"capsule",
"line",
"ring",
"cross",
"square-cross",
"square-cross-ring",
]);
const TRANSITION_ALIASES = Object.freeze({
dissolve: "seeded-dissolve",
radial: "radial-cascade",
bloom: "radial-cascade",
spiral: "angular-sweep",
wave: "scanline",
shutter: "axis-flip",
glitch: "glitch-bands",
"sdf-wavefront": "contour-trace",
"neighbor-propagation": "neighbor-ignite",
});
const PIXEL_SWITCH_ALIASES = Object.freeze({
"cluster-dissolve": "seeded-dissolve",
"neighbor-ignite": "neighbor-propagation",
});
const PIXEL_SHAPE_ALIASES = Object.freeze({
circle: "disc",
"rounded-square": "square",
dot: "disc",
});
const ENGINE_OPTION_KEYS = Object.freeze([
"ariaLive",
"autoResize",
"autoplay",
"background",
"canvas",
"contrast",
"density",
"direction",
"dprMax",
"field",
"fieldMode",
"fps",
"gridSize",
"interactive",
"nonErrorPalette",
"offPixels",
"orbBoundary",
"orbBackgroundColor",
"orbBackgroundMode",
"package",
"palette",
"paletteOverride",
"pixelShape",
"pixelSwitch",
"previewMode",
"progress",
"quality",
"reducedMotion",
"seed",
"speed",
"sprite",
"targetGlyph",
"transition",
"version",
]);
const NUMBER_OPTIONS = Object.freeze({
contrast: { minimum: 0.25, maximum: 2 },
density: { minimum: 0.35, maximum: 1.6 },
dprMax: { minimum: 0.25, maximum: 8 },
fps: { minimum: 1, maximum: 120, integer: true },
gridSize: { minimum: 8, maximum: 96, integer: true },
progress: { minimum: 0, maximum: 1 },
speed: { minimum: 0.05, maximum: 5 },
});
const BOOLEAN_OPTIONS = Object.freeze([
"autoResize",
"autoplay",
"background",
"interactive",
"offPixels",
]);
const normalizeKey = (value) =>
String(value ?? "")
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-");
function distanceBetween(left, right) {
const a = [...String(left)];
const b = [...String(right)];
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
const current = new Array(b.length + 1);
for (let row = 1; row <= a.length; row += 1) {
current[0] = row;
for (let column = 1; column <= b.length; column += 1) {
current[column] = Math.min(
current[column - 1] + 1,
previous[column] + 1,
previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1),
);
}
for (let column = 0; column <= b.length; column += 1) {
previous[column] = current[column];
}
}
return previous[b.length];
}
export function nearestName(value, candidates, options = {}) {
const input = normalizeKey(value);
if (!input || !Array.isArray(candidates) || candidates.length === 0) {
return null;
}
let nearest = null;
let nearestDistance = Infinity;
for (const candidate of candidates) {
const distance = distanceBetween(input, normalizeKey(candidate));
if (distance < nearestDistance) {
nearest = candidate;
nearestDistance = distance;
}
}
const maximum =
options.maxDistance ?? Math.max(2, Math.floor(input.length * 0.34));
return nearestDistance <= maximum ? nearest : null;
}
export class JoanConfigurationError extends RangeError {
constructor(message, details = {}) {
super(message);
this.name = "JoanConfigurationError";
this.code = details.code || "invalid_configuration";
this.path = details.path || null;
this.value = details.value;
this.suggestion = details.suggestion || null;
this.allowed = details.allowed ? [...details.allowed] : null;
}
toIssue() {
return {
code: this.code,
message: this.message,
path: this.path,
value: this.value,
suggestion: this.suggestion,
severity: "error",
};
}
}
function aliasLookup(aliases) {
return new Map(
Object.entries(aliases || {}).map(([alias, canonical]) => [
normalizeKey(alias),
canonical,
]),
);
}
export function validateKnownValue(value, allowed, options = {}) {
const label = options.label || "value";
const path = options.path || label;
const normalized = normalizeKey(value);
const canonical = new Map(
allowed.map((candidate) => [normalizeKey(candidate), candidate]),
);
if (canonical.has(normalized)) return canonical.get(normalized);
const aliases = aliasLookup(options.aliases);
if (aliases.has(normalized)) return aliases.get(normalized);
const accepted = [...allowed, ...Object.keys(options.aliases || {})];
const suggestion = nearestName(value, accepted);
const suffix = suggestion ? ` Did you mean "${suggestion}"?` : "";
throw new JoanConfigurationError(
`Unknown ${label} "${String(value)}".${suffix}`,
{
code: `unknown_${label.replaceAll(" ", "_")}`,
path,
value,
suggestion,
allowed,
},
);
}
export const validateSpriteId = (value) =>
validateKnownValue(value, SPRITE_IDS, {
aliases: SPRITE_ALIASES,
label: "sprite",
path: "sprite",
});
export const validateFieldId = (value) =>
validateKnownValue(value, FIELD_IDS, {
aliases: FIELD_ALIASES,
label: "field",
path: "field",
});
export const validateTransition = (value) =>
validateKnownValue(
typeof value === "string"
? value
: value?.name || value?.type || value?.enter,
TRANSITION_IDS,
{
aliases: TRANSITION_ALIASES,
label: "transition",
path: "transition",
},
);
export const validatePixelShape = (value) =>
validateKnownValue(
typeof value === "string" ? value : value?.shape || value?.name,
PIXEL_SHAPE_IDS,
{
aliases: PIXEL_SHAPE_ALIASES,
label: "pixel shape",
path: "pixelShape",
},
);
export const validatePixelSwitch = (value) =>
validateKnownValue(
typeof value === "string"
? value
: value?.mode || value?.name || value?.type,
PIXEL_SWITCH_IDS,
{
aliases: PIXEL_SWITCH_ALIASES,
label: "pixel switch",
path: "pixelSwitch",
},
);
function issueFrom(error, fallbackPath) {
if (error instanceof JoanConfigurationError) return error.toIssue();
return {
code: "invalid_configuration",
message: error?.message || "Invalid engine configuration.",
path: fallbackPath || null,
value: undefined,
suggestion: null,
severity: "error",
};
}
function inspectNumber(key, value, definition, coerce) {
if (!coerce && typeof value !== "number") {
throw new JoanConfigurationError(`${key} must be a number.`, {
code: "invalid_type",
path: key,
value,
});
}
let next = coerce ? Number(value) : value;
if (!Number.isFinite(next)) {
throw new JoanConfigurationError(`${key} must be a finite number.`, {
code: "invalid_number",
path: key,
value,
});
}
if (definition.integer && !coerce && !Number.isInteger(next)) {
throw new JoanConfigurationError(`${key} must be an integer.`, {
code: "invalid_number",
path: key,
value,
});
}
if (definition.integer) next = Math.round(next);
if (next < definition.minimum || next > definition.maximum) {
if (!coerce) {
throw new JoanConfigurationError(
`${key} must be between ${definition.minimum} and ${definition.maximum}.`,
{
code: "out_of_range",
path: key,
value,
},
);
}
next = Math.min(Math.max(next, definition.minimum), definition.maximum);
}
return next;
}
function validatePalette(value, path, { allowVariants = true } = {}) {
if (value === null) return null;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new JoanConfigurationError(`${path} must be a palette object or null.`, {
code: "invalid_type",
path,
value,
});
}
const allowed = new Set([
"name",
"background",
"shadow",
"off",
"ink",
"accent",
"glow",
...(allowVariants ? ["variants"] : []),
]);
const next = {};
for (const [key, color] of Object.entries(value)) {
if (!allowed.has(key)) {
const suggestion = nearestName(key, [...allowed]);
throw new JoanConfigurationError(
`Unknown ${path} channel "${key}".${
suggestion ? ` Did you mean "${suggestion}"?` : ""
}`,
{
code: "unknown_palette_channel",
path: `${path}.${key}`,
value: color,
suggestion,
},
);
}
if (key === "variants") {
if (!color || typeof color !== "object" || Array.isArray(color)) {
throw new JoanConfigurationError(`${path}.variants must be an object.`, {
code: "invalid_type",
path: `${path}.variants`,
value: color,
});
}
next.variants = Object.fromEntries(
Object.entries(color).map(([name, palette]) => [
name,
validatePalette(palette, `${path}.variants.${name}`, {
allowVariants: false,
}),
]),
);
continue;
}
if (typeof color !== "string" || !color.trim()) {
throw new JoanConfigurationError(`${path}.${key} must be a CSS color string.`, {
code: "invalid_type",
path: `${path}.${key}`,
value: color,
});
}
next[key] = color.trim();
}
return next;
}
const PORTABLE_COLOR_KEYWORDS = new Set([
"aqua",
"black",
"blue",
"fuchsia",
"gray",
"green",
"grey",
"lime",
"maroon",
"navy",
"olive",
"orange",
"purple",
"red",
"silver",
"teal",
"transparent",
"white",
"yellow",
]);
function validateOptionalColor(value, path) {
if (value === null) return null;
if (typeof value !== "string" || !value.trim()) {
throw new JoanConfigurationError(`${path} must be a CSS color string or null.`, {
code: "invalid_type",
path,
value,
});
}
const candidate = value.trim();
if (candidate.toLowerCase() === "transparent") return null;
const functionMatch =
/^(rgb|rgba|hsl|hsla)\(([\d\s.,%+/-]+)\)$/i.exec(candidate);
const portable =
/^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(candidate) ||
PORTABLE_COLOR_KEYWORDS.has(candidate.toLowerCase()) ||
(Boolean(functionMatch) && /\d/.test(functionMatch[2]));
if (!portable) {
throw new JoanConfigurationError(
`${path} must be a portable CSS color (hex, rgb, hsl, a supported keyword) or null.`,
{
code: "invalid_color",
path,
value,
},
);
}
return candidate;
}
export function inspectEngineOptions(input, options = {}) {
const coerce = options.coerce !== false;
if (!input || typeof input !== "object" || Array.isArray(input)) {
const error = new JoanConfigurationError(
"Engine options must be an object.",
{ code: "invalid_type", path: "options", value: input },
);
return { ok: false, value: {}, issues: [error.toIssue()] };
}
const value = { ...input };
const issues = [];
const allowedKeys = new Set(ENGINE_OPTION_KEYS);
if (options.allowUnknown !== true) {
for (const key of Object.keys(value)) {
if (allowedKeys.has(key)) continue;
const suggestion = nearestName(key, ENGINE_OPTION_KEYS);
issues.push({
code: "unknown_option",
message: `Unknown engine option "${key}".${
suggestion ? ` Did you mean "${suggestion}"?` : ""
}`,
path: key,
value: value[key],
suggestion,
severity: "error",
});
delete value[key];
}
}
const apply = (key, callback) => {
if (!Object.hasOwn(value, key) || value[key] === undefined) return;
try {
value[key] = callback(value[key]);
} catch (error) {
issues.push(issueFrom(error, key));
delete value[key];
}
};
apply("sprite", (sprite) =>
sprite && typeof sprite === "object" ? validateRecipe(sprite) : validateSpriteId(sprite),
);
apply("field", (field) => (field === null ? null : validateFieldId(field)));
apply("transition", (transition) =>
transition === null
? null
: transition && typeof transition === "object"
? {
...transition,
name: validateTransition(
transition.name || transition.type || transition.enter,
),
}
: validateTransition(transition),
);
apply("pixelShape", (shape) =>
shape === null ? null : validatePixelShape(shape),
);
apply("pixelSwitch", (pixelSwitch) =>
pixelSwitch === null
? null
: pixelSwitch && typeof pixelSwitch === "object"
? {
...pixelSwitch,
mode: validatePixelSwitch(
pixelSwitch.mode || pixelSwitch.name || pixelSwitch.type,
),
}
: validatePixelSwitch(pixelSwitch),
);
apply("fieldMode", (mode) =>
validateKnownValue(mode, ["recipe", "override"], {
label: "field mode",
path: "fieldMode",
}),
);
apply("quality", (quality) =>
validateKnownValue(quality, ["low", "balanced", "high", "auto"], {
label: "quality",
path: "quality",
}),
);
apply("previewMode", (mode) =>
validateKnownValue(mode, ["fit", "actual"], {
label: "preview mode",
path: "previewMode",
}),
);
apply("orbBoundary", (boundary) =>
validateKnownValue(boundary, ["defined", "gestalt"], {
label: "orb boundary",
path: "orbBoundary",
}),
);
apply("orbBackgroundColor", (color) =>
validateOptionalColor(color, "orbBackgroundColor"),
);
apply("orbBackgroundMode", (mode) =>
validateKnownValue(mode, ["none", "solid", "pixelated"], {
label: "orb background mode",
path: "orbBackgroundMode",
}),
);
apply("reducedMotion", (mode) => {
if (typeof mode === "boolean" || mode === "system") return mode;
throw new JoanConfigurationError(
'reducedMotion must be true, false, or "system".',
{ code: "invalid_type", path: "reducedMotion", value: mode },
);
});
for (const [key, definition] of Object.entries(NUMBER_OPTIONS)) {
apply(key, (number) => inspectNumber(key, number, definition, coerce));
}
for (const key of BOOLEAN_OPTIONS) {
apply(key, (boolean) => {
if (typeof boolean === "boolean") return boolean;
if (coerce && (boolean === "true" || boolean === "false")) {
return boolean === "true";
}
throw new JoanConfigurationError(`${key} must be a boolean.`, {
code: "invalid_type",
path: key,
value: boolean,
});
});
}
for (const key of ["palette", "paletteOverride", "nonErrorPalette"]) {
apply(key, (palette) => validatePalette(palette, key));
}
return { ok: issues.length === 0, value, issues };
}
export function validateEngineOptions(input, options = {}) {
const strict = options.strict !== false;
const result = inspectEngineOptions(input, {
...options,
coerce: options.coerce ?? !strict,
});
if (!result.ok && strict) {
const issue = result.issues[0];
throw new JoanConfigurationError(issue.message, issue);
}
return result.value;
}
export function validateRecipe(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new JoanConfigurationError("A recipe must be an object.", {
code: "invalid_type",
path: "recipe",
value: input,
});
}
if (typeof input.id !== "string" || !input.id.trim()) {
throw new JoanConfigurationError("A recipe requires a non-empty id.", {
code: "missing_recipe_id",
path: "recipe.id",
value: input.id,
});
}
if (typeof input.glyph !== "string" || !input.glyph.trim()) {
throw new JoanConfigurationError("A recipe requires a glyph id.", {
code: "missing_recipe_glyph",
path: "recipe.glyph",
value: input.glyph,
});
}
const recipe = { ...input, id: input.id.trim(), glyph: input.glyph.trim() };
if (recipe.field !== undefined) recipe.field = validateFieldId(recipe.field);
if (Array.isArray(recipe.fieldMix)) {
recipe.fieldMix = recipe.fieldMix.map((layer, index) => {
if (!layer || typeof layer !== "object") {
throw new JoanConfigurationError(`recipe.fieldMix[${index}] must be an object.`, {
code: "invalid_type",
path: `recipe.fieldMix[${index}]`,
value: layer,
});
}
return { ...layer, field: validateFieldId(layer.field) };
});
} else if (!recipe.field) {
throw new JoanConfigurationError("A recipe requires field or fieldMix.", {
code: "missing_recipe_field",
path: "recipe.field",
value: recipe.field,
});
}
if (recipe.pixelShape !== undefined) {
recipe.pixelShape = validatePixelShape(recipe.pixelShape);
}
if (recipe.pixel?.shape !== undefined) {
recipe.pixel = {
...recipe.pixel,
shape: validatePixelShape(recipe.pixel.shape),
};
}
if (recipe.pixelSwitch !== undefined) {
if (typeof recipe.pixelSwitch === "string") {
recipe.pixelSwitch = validatePixelSwitch(recipe.pixelSwitch);
} else if (recipe.pixelSwitch?.mode !== undefined) {
recipe.pixelSwitch = {
...recipe.pixelSwitch,
mode: validatePixelSwitch(recipe.pixelSwitch.mode),
};
}
}
if (recipe.transition !== undefined) {
if (typeof recipe.transition === "string") {
recipe.transition = validateTransition(recipe.transition);
} else if (recipe.transition && typeof recipe.transition === "object") {
const transition = { ...recipe.transition };
for (const key of ["name", "type", "enter", "exit"]) {
if (transition[key] !== undefined) {
transition[key] = validateTransition(transition[key]);
}
}
recipe.transition = transition;
}
}
return recipe;
}
function deepFreeze(value, seen = new WeakSet()) {
if (!value || typeof value !== "object" || seen.has(value)) return value;
seen.add(value);
for (const nested of Object.values(value)) deepFreeze(nested, seen);
return Object.freeze(value);
}
export function defineRecipe(recipe) {
return deepFreeze(validateRecipe(recipe));
}

207
web/vendor/src/control-help.js vendored Normal file
View File

@@ -0,0 +1,207 @@
export const FIELD_HELP = Object.freeze({
__recipe__:
"Uses the selected states authored blend of fields, preserving its intended visual character.",
fbm: "Builds soft, organic variation by layering several scales of smooth gradient noise.",
ridged:
"Folds layered noise into sharp ridges and filaments for a more etched texture.",
"domain-warp":
"Distorts layered noise with two moving noise fields, creating fluid folds and swirls.",
curl: "Turns the curl magnitude of animated noise into turbulent, smoke-like density.",
flow: "Carries animated ribbons along a curl-noise direction for a flowing texture.",
worley:
"Creates bright cellular islands around moving feature points, separated by darker gaps.",
voronoi:
"Smoothly morphs between seeded cellular layouts while keeping their moving division lines crisp.",
plasma:
"Combines layered sine waves with noise-driven phase shifts for an energetic plasma pattern.",
interference:
"Overlaps circular waves from moving emitters to create traveling beats and bands.",
vortex:
"Spins noisy spiral arms around the center for a rotating whirlpool-like field.",
metaballs:
"Blends several seeded moving blobs so nearby forms merge into one liquid mass.",
caustics:
"Produces sharp cellular highlights that resemble refracted light moving across water.",
strata:
"Stacks noise-warped sediment-like bands with a directional slope and sharp edges.",
radar:
"Combines a rotating beam, concentric rings, and seeded target blips.",
constellation:
"Connects twinkling cellular stars with faint Voronoi filaments.",
liquid:
"Layers domain-warped waves and highlights into a gently moving water surface.",
electric:
"Generates repeating branching lightning channels with pulses and traveling sparks.",
ripple: "Sends noise-distorted concentric rings outward from the center.",
kaleidoscope:
"Folds rotating polar noise into mirrored, mandala-like symmetry.",
});
export const TRANSITION_HELP = Object.freeze({
"field-morph":
"Morphs cells on a soft schedule combining seeded randomness, radius, and vertical position.",
"seeded-dissolve":
"Changes pixels in a stable, seed-determined random order.",
"radial-cascade":
"Moves the new state outward from the origin with slight seeded variation.",
"angular-sweep":
"Rotates the new state around the origin with a subtle outward offset.",
scanline:
"Reveals the new state from top to bottom in a lightly rippled scan.",
"contour-trace":
"Starts on a circular mid-radius contour, then spreads inward and outward.",
"path-draw":
"Draws the new state diagonally from the upper left toward the lower right.",
"axis-flip":
"Staggers alternating columns while advancing top to bottom, producing a shutter-like flip.",
"cluster-dissolve":
"Changes coarse pixel clusters in a seeded random order for a chunkier dissolve.",
"neighbor-ignite":
"Spreads the new state in a connected-looking wave from the origin.",
"glitch-bands":
"Switches horizontal bands in a scrambled order for a brief glitch effect.",
instant: "Applies the new state immediately with no spatial interpolation.",
});
export const PIXEL_SHAPE_HELP = Object.freeze({
disc: "Draws each cell as a filled round dot.",
square: "Draws each cell as a filled square for a crisp grid texture.",
diamond: "Draws each cell as a compact square rotated 45 degrees.",
capsule: "Draws each cell as a rounded horizontal pill.",
line: "Draws each cell as a thin bar aligned tangentially around the center.",
ring: "Draws each cell as a hollow circular outline.",
cross: "Draws each cell as a compact plus sign.",
"square-cross":
"Uses filled squares for dim sampled signal cells and crosses for bright cells.",
"square-cross-ring":
"Uses filled squares, crosses, then rings as sampled signal lightness rises.",
});
export const PIXEL_SWITCH_HELP = Object.freeze({
"ordered-dither":
"Uses a fixed Bayer pattern for stable, evenly distributed pixel activation.",
"temporal-blue-noise":
"Blends an ordered grid with time-varying noise for lively, fine-grained switching.",
"threshold-hysteresis":
"Uses a uniform midpoint threshold with hysteresis for calm, stable switching.",
"sdf-wavefront":
"Adds a moving radial wavefront to ordered thresholds so activation travels in rings.",
"contour-trace":
"Modulates thresholds along repeating field-value contours so similar intensity bands switch together.",
"curl-advect":
"Offsets ordered thresholds with a traveling wave so activation appears to drift diagonally.",
"neighbor-propagation":
"Lowers a cells threshold when adjacent cells are on, encouraging activation to spread locally.",
"radial-cascade":
"Biases ordered activation from the center outward.",
"path-draw":
"Biases ordered activation along a top-left-to-bottom-right path.",
"axis-flip":
"Staggers alternating columns and compresses cells vertically as they turn on, creating a flip.",
"seeded-dissolve":
"Gives each cell a stable, seed-determined random threshold for a repeatable dissolve texture.",
"field-morph":
"Blends ordered and seed-random thresholds for both structure and organic variation.",
});
export const SELECT_CONTROL_HELP = Object.freeze({
fieldSelect: Object.freeze({
summary: "Chooses the procedural texture sampled inside the glyph.",
options: FIELD_HELP,
}),
transitionSelect: Object.freeze({
summary:
"Controls the spatial order used when moving between semantic states.",
options: TRANSITION_HELP,
}),
pixelShapeSelect: Object.freeze({
summary: "Changes the silhouette used to draw each active cell.",
options: PIXEL_SHAPE_HELP,
}),
pixelSwitchSelect: Object.freeze({
summary:
"Controls how continuous field intensity becomes persistent on/off pixels.",
options: PIXEL_SWITCH_HELP,
}),
orbBackgroundModeSelect: Object.freeze({
summary: "Chooses how the optional orb background disk is constructed.",
options: Object.freeze({
solid:
"Fills one smooth circle behind the orb while preserving the grid above it.",
pixelated:
"Builds the disk from full grid-aligned cells so its edge matches the selected resolution.",
}),
}),
});
export const VALUE_CONTROL_HELP = Object.freeze({
resolutionRange:
"Sets the number of cells per side. Higher values add detail but increase rendering work roughly with the square.",
speedRange:
"Multiplies the procedural animation clock, making textures and sprite motion run slower or faster.",
densityRange:
"Biases activation so higher values keep more pixels on and lower values make the signal sparser.",
seedInput:
"A stable text key that determines noise and per-cell randomness. Reuse it with the same settings to reproduce the signal.",
randomizeButton:
"Creates a new seed and a new deterministic variation of the current signal.",
backgroundColorInput:
"Sets the canvas fill behind the grid across non-error states.",
offColorInput:
"Sets the faint color of grid cells that are currently off across non-error states.",
inkColorInput:
"Sets the base color of active pixels across non-error states.",
accentColorInput:
"Sets the color active pixels move toward as sampled luminance rises.",
glowEnabledInput:
"Turns the halo around active pixels on or off across non-error states.",
orbBoundaryInput:
"Defined draws the authored rim. Gestalt removes the continuous outline and lets clustered edge pixels suggest the circle through proximity, closure, and continuing arcs.",
orbBackgroundEnabledInput:
"Adds or removes the optional orb background layer in supported presence orbs. It is separate from the canvas background.",
orbBackgroundColorInput:
"Sets the optional color behind supported orb pixels. The color carries across states but stays visually inert for non-orb recipes.",
glowColorInput:
"Sets the halo around active pixels. Glow renders in high quality at resolutions up to 48×48.",
resetPaletteButton:
"Clears custom color overrides and returns non-error states to the current themes defaults.",
});
const clamp = (value, minimum, maximum) =>
Math.min(Math.max(value, minimum), maximum);
export function placeControlTooltip(
anchor,
tooltip,
viewport,
options = {},
) {
const gap = Number(options.gap) || 8;
const gutter = Number(options.gutter) || 12;
const maximumLeft = Math.max(
gutter,
Number(viewport.width) - Number(tooltip.width) - gutter,
);
const centeredLeft =
Number(anchor.left) +
Number(anchor.width) / 2 -
Number(tooltip.width) / 2;
const left = clamp(centeredLeft, gutter, maximumLeft);
const below = Number(anchor.bottom) + gap;
const above = Number(anchor.top) - Number(tooltip.height) - gap;
const fitsBelow =
below + Number(tooltip.height) <= Number(viewport.height) - gutter;
const fitsAbove = above >= gutter;
const maximumTop = Math.max(
gutter,
Number(viewport.height) - Number(tooltip.height) - gutter,
);
const placement = !fitsBelow && fitsAbove ? "top" : "bottom";
const preferredTop = placement === "top" ? above : below;
return {
left: Math.round(left),
top: Math.round(clamp(preferredTop, gutter, maximumTop)),
placement,
};
}

1323
web/vendor/src/fields.js vendored Normal file

File diff suppressed because it is too large Load Diff

1127
web/vendor/src/glyphs.js vendored Normal file

File diff suppressed because it is too large Load Diff

4309
web/vendor/src/joan-engine.js vendored Normal file

File diff suppressed because it is too large Load Diff

63
web/vendor/src/preview-layout.js vendored Normal file
View File

@@ -0,0 +1,63 @@
export const PREVIEW_MODES = Object.freeze(["fit", "actual"]);
const DEFAULT_RESOLUTION = 68;
const MIN_RESOLUTION = 8;
const MAX_RESOLUTION = 96;
const FIT_INSET = 0.075;
function finite(value, fallback) {
return Number.isFinite(Number(value)) ? Number(value) : fallback;
}
export function normalizePreviewMode(mode) {
return mode === "actual" ? "actual" : "fit";
}
export function normalizePreviewResolution(resolution) {
return Math.min(
MAX_RESOLUTION,
Math.max(
MIN_RESOLUTION,
Math.round(finite(resolution, DEFAULT_RESOLUTION)),
),
);
}
export function previewModeDetails(mode, resolution) {
const normalizedMode = normalizePreviewMode(mode);
const normalizedResolution = normalizePreviewResolution(resolution);
return {
mode: normalizedMode,
resolution: normalizedResolution,
cellSize: normalizedMode === "actual" ? 1 : null,
footprint: normalizedMode === "actual" ? normalizedResolution : null,
};
}
export function canvasGridLayout(mode, resolution, width, height) {
const details = previewModeDetails(mode, resolution);
const safeWidth = Math.max(1, finite(width, details.resolution));
const safeHeight = Math.max(1, finite(height, details.resolution));
if (details.mode === "actual") {
const stageSize = details.resolution;
return {
...details,
stageSize,
cellSize: 1,
offsetX: Math.round((safeWidth - stageSize) / 2),
offsetY: Math.round((safeHeight - stageSize) / 2),
};
}
const shortSide = Math.min(safeWidth, safeHeight);
const margin = shortSide * FIT_INSET;
const stageSize = shortSide - margin * 2;
return {
...details,
stageSize,
cellSize: stageSize / details.resolution,
offsetX: (safeWidth - stageSize) / 2,
offsetY: (safeHeight - stageSize) / 2,
};
}

82
web/vendor/src/preview-thumbnails.js vendored Normal file
View File

@@ -0,0 +1,82 @@
const DEFAULT_PREVIEW_PROGRESS = 0.48;
function clamp01(value) {
return Math.min(1, Math.max(0, Number(value) || 0));
}
function representativeTime(recipe) {
const phase = clamp01(recipe?.reducedMotion?.phase ?? 0.25);
const cycleMs =
Number(recipe?.timeline?.macroCycleMs) ||
Number(recipe?.timeline?.durationMs) ||
4000;
return (Math.max(1, cycleMs) / 1000) * phase;
}
/**
* One-shot recipes may advance to a different state while their card is active.
* Atlas previews retain the requested recipe so their identity and resting frame
* always match the card label.
*/
export function createThumbnailRecipe(recipe) {
if (!recipe?.timeline?.next) return recipe;
return {
...recipe,
timeline: {
...recipe.timeline,
next: null,
},
};
}
export function shouldAnimateThumbnail(
id,
{
selectedId = null,
mainRunning = false,
hoveredIds = new Set(),
focusedIds = new Set(),
} = {},
) {
return (
(id === selectedId && mainRunning) ||
hoveredIds.has(id) ||
focusedIds.has(id)
);
}
/**
* Render a deterministic, authored representative phase without leaving the
* preview in reduced-motion mode. JoanGlyphEngine maps time zero to each
* recipe's reducedMotion.phase while reduced motion is enabled.
*/
export function renderThumbnailRestFrame(
preview,
{ progress = DEFAULT_PREVIEW_PROGRESS } = {},
) {
if (!preview || preview.destroyed) return null;
const nextProgress = clamp01(progress);
if (Math.abs(preview.progress - nextProgress) > 1e-6) {
preview.setProgress(nextProgress);
}
// Neighbor-propagation previews otherwise retain their previous gate buffer,
// making the supposedly static pose depend on how long the card was hovered.
preview.stateElapsed = representativeTime(preview.currentRecipe);
preview.gates?.fill(0);
preview.neighborBuffer?.fill(0);
preview.dwell?.fill(0);
const previousReducedMotion = preview.reducedMotion;
preview.reducedMotion = true;
let stats;
try {
stats = preview.renderOnce(0);
} finally {
preview.reducedMotion = previousReducedMotion;
}
if (preview.canvas?.dataset) preview.canvas.dataset.previewState = "ready";
return stats;
}

1766
web/vendor/src/sprites.js vendored Normal file

File diff suppressed because it is too large Load Diff

287
web/vendor/src/state-director.js vendored Normal file
View File

@@ -0,0 +1,287 @@
function detailEvent(type, detail) {
if (typeof CustomEvent === "function") {
return new CustomEvent(type, { detail });
}
const event = new Event(type);
Object.defineProperty(event, "detail", { value: detail });
return event;
}
function abortError(signal) {
if (signal?.reason instanceof Error) return signal.reason;
const message = signal?.reason ? String(signal.reason) : "The operation was aborted.";
if (typeof DOMException === "function") {
return new DOMException(message, "AbortError");
}
const error = new Error(message);
error.name = "AbortError";
return error;
}
function stateId(sprite) {
return typeof sprite === "string" ? sprite : sprite?.id || String(sprite);
}
/**
* Optional semantic timing helper. The host explicitly begins and completes work;
* the director only handles presentation thresholds and never infers AI state.
*/
export class StateDirector extends EventTarget {
constructor(engine, options = {}) {
super();
if (!engine?.setSprite) throw new TypeError("StateDirector requires an engine.");
this.engine = engine;
this.options = {
deepThinkingAfterMs: 7000,
stillWorkingAfterMs: 18000,
transition: "field-morph",
...options,
};
this.timers = new Set();
this.state = engine.currentRecipe?.id || "ai.idle";
this.operation = 0;
this.destroyed = false;
}
clearTimers() {
for (const timer of this.timers) clearTimeout(timer);
this.timers.clear();
}
schedule(callback, delay) {
const timer = setTimeout(() => {
this.timers.delete(timer);
callback();
}, Math.max(0, Number(delay) || 0));
this.timers.add(timer);
return timer;
}
changeState(sprite, engineOptions = {}, metadata = {}) {
if (metadata.clearTimers !== false) this.clearTimers();
const from = this.state;
const to = stateId(sprite);
this.state = to;
this.engine.setSprite(sprite, engineOptions);
this.dispatchEvent(
detailEvent("statechange", {
state: to,
from,
to,
reason: metadata.reason || "set",
}),
);
return this;
}
set(sprite, options = {}) {
this.operation += 1;
return this.changeState(
sprite,
{
transition: options.transition || this.options.transition,
preservePhase: options.preservePhase ?? true,
duration: options.duration,
},
{ reason: options.reason || "set" },
);
}
startThinking(options, operation) {
this.changeState(
"ai.thinking",
{
transition: options.transition || this.options.transition,
preservePhase: options.preservePhase ?? true,
duration: options.duration,
},
{ reason: options.reason || "thinking-start" },
);
this.schedule(() => {
if (this.operation !== operation || this.state !== "ai.thinking") return;
this.changeState(
"ai.thinking-deep",
{ transition: "neighbor-ignite", preservePhase: true },
{ clearTimers: false, reason: "thinking-escalation" },
);
}, options.deepThinkingAfterMs ?? this.options.deepThinkingAfterMs);
this.schedule(() => {
if (
this.operation !== operation ||
!["ai.thinking", "ai.thinking-deep"].includes(this.state)
) {
return;
}
this.changeState(
"ai.still-working",
{ transition: "radial-cascade", preservePhase: true },
{ clearTimers: false, reason: "thinking-escalation" },
);
}, options.stillWorkingAfterMs ?? this.options.stillWorkingAfterMs);
return this;
}
beginThinking(options = {}) {
const operation = ++this.operation;
return this.startThinking(options, operation);
}
finish(options = {}, reason = "complete") {
const sprite = options.sprite || "status.success";
this.changeState(
sprite,
{
transition: options.transition || "path-draw",
preservePhase: options.preservePhase ?? true,
duration: options.duration,
},
{ reason },
);
this.engine.signal?.("complete", { energy: 1 });
return this;
}
complete(options = {}) {
this.operation += 1;
return this.finish(options, options.reason || "complete");
}
reject(options = {}, reason = "failure") {
return this.changeState(
options.sprite || "status.error",
{
transition: options.transition || "glitch-bands",
preservePhase: options.preservePhase ?? true,
duration: options.duration,
},
{ reason },
);
}
fail(options = {}) {
this.operation += 1;
return this.reject(options, options.reason || "failure");
}
cancel(options = {}) {
this.operation += 1;
return this.changeState(
options.sprite || "status.cancelled",
{
transition: options.transition || "seeded-dissolve",
preservePhase: options.preservePhase ?? true,
duration: options.duration,
},
{ reason: options.reason || "cancelled" },
);
}
reset(options = {}) {
this.operation += 1;
return this.changeState(
options.sprite || "ai.idle",
{
transition: options.transition || "field-morph",
preservePhase: options.preservePhase ?? false,
duration: options.duration,
},
{ reason: options.reason || "reset" },
);
}
/**
* Present one asynchronous task through thinking escalation and a terminal
* state. The work receives `{ signal, director, engine }`; existing zero-arg
* functions remain valid because JavaScript ignores extra arguments.
*/
async run(work, options = {}) {
if (this.destroyed) throw new Error("StateDirector has been destroyed.");
if (typeof work !== "function" && !work?.then) {
throw new TypeError("StateDirector.run requires a function or Promise.");
}
const signal = options.signal;
const operation = ++this.operation;
if (signal?.aborted) {
if (this.operation === operation) {
this.changeState(
options.cancel?.sprite || options.cancelledSprite || "status.cancelled",
{
transition: options.cancel?.transition || "seeded-dissolve",
preservePhase: options.cancel?.preservePhase ?? true,
duration: options.cancel?.duration,
},
{ reason: "run-aborted" },
);
}
throw abortError(signal);
}
this.startThinking(options.thinking || options, operation);
let removeAbortListener = () => {};
let task;
try {
task =
typeof work === "function"
? Promise.resolve().then(() =>
work({ signal, director: this, engine: this.engine }),
)
: Promise.resolve(work);
if (signal) {
const aborted = new Promise((_, reject) => {
const onAbort = () => reject(abortError(signal));
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
});
task = Promise.race([task, aborted]);
}
const result = await task;
if (this.operation === operation) {
this.finish(
{
...(options.complete || {}),
sprite: options.complete?.sprite || options.successSprite,
},
"run-complete",
);
}
return result;
} catch (error) {
if (this.operation === operation) {
if (signal?.aborted || error?.name === "AbortError") {
this.changeState(
options.cancel?.sprite || options.cancelledSprite || "status.cancelled",
{
transition: options.cancel?.transition || "seeded-dissolve",
preservePhase: options.cancel?.preservePhase ?? true,
duration: options.cancel?.duration,
},
{ reason: "run-aborted" },
);
} else {
this.reject(
{
...(options.fail || {}),
sprite: options.fail?.sprite || options.errorSprite,
},
"run-failed",
);
}
}
throw error;
} finally {
removeAbortListener();
}
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.operation += 1;
this.clearTimers();
}
}
export default StateDirector;

649
web/vendor/src/state-sequence.js vendored Normal file
View File

@@ -0,0 +1,649 @@
export const STATE_SEQUENCE_SCHEMA_VERSION = 1;
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
function detailEvent(type, detail) {
if (typeof CustomEvent === "function") {
return new CustomEvent(type, { detail });
}
const event = new Event(type);
Object.defineProperty(event, "detail", { value: detail });
return event;
}
function abortError(source) {
if (source?.reason instanceof Error) return source.reason;
const reason = source?.reason ?? source;
const message = reason ? String(reason) : "The state sequence was aborted.";
if (typeof DOMException === "function") {
return new DOMException(message, "AbortError");
}
const error = new Error(message);
error.name = "AbortError";
return error;
}
function stateId(sprite) {
return typeof sprite === "string" ? sprite : sprite?.id || String(sprite);
}
function normalizeTransition(value, path) {
if (value === undefined || value === false || typeof value === "string") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${path} must be false, a transition name, or an options object.`);
}
return Object.freeze({ ...value });
}
function normalizeOptions(value, path) {
if (value === undefined) return undefined;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${path} must be an options object.`);
}
return Object.freeze({ ...value });
}
function normalizeStep(input, index) {
const source =
typeof input === "string"
? { sprite: input }
: input && typeof input === "object"
? input
: null;
const sprite = source?.sprite;
if (
!source ||
(typeof sprite === "string"
? !sprite.trim()
: !sprite || typeof sprite !== "object")
) {
throw new TypeError(`State sequence step ${index} requires a sprite.`);
}
const rawHold = source.holdMs ?? source.durationMs ?? 0;
const holdMs = Number(rawHold);
if (!Number.isFinite(holdMs) || holdMs < 0) {
throw new RangeError(`State sequence step ${index} holdMs must be a non-negative number.`);
}
const step = {
sprite: source.sprite,
holdMs,
};
if (hasOwn(source, "transition")) {
step.transition = normalizeTransition(
source.transition,
`State sequence step ${index} transition`,
);
}
if (hasOwn(source, "options")) {
step.options = normalizeOptions(
source.options,
`State sequence step ${index} options`,
);
}
if (hasOwn(source, "label")) step.label = String(source.label);
if (hasOwn(source, "metadata")) {
step.metadata = normalizeOptions(
source.metadata,
`State sequence step ${index} metadata`,
);
}
return Object.freeze(step);
}
/**
* Create a frozen named sequence definition. Definitions that use sprite IDs
* and data-only metadata/options can be serialized directly as JSON.
*
* @example
* const readyThenDone = defineStateSequence("ready-then-done", [
* { sprite: "ai.idle", holdMs: 5000 },
* { sprite: "status.success", transition: "path-draw" },
* ]);
*/
export function defineStateSequence(nameOrDefinition, steps, options = {}) {
const source =
nameOrDefinition && typeof nameOrDefinition === "object" && !Array.isArray(nameOrDefinition)
? nameOrDefinition
: { ...options, name: nameOrDefinition, steps };
const name = String(source.name || "").trim();
if (!name) throw new TypeError("A state sequence requires a non-empty name.");
if (
source.schemaVersion !== undefined &&
Number(source.schemaVersion) !== STATE_SEQUENCE_SCHEMA_VERSION
) {
throw new RangeError(
`Unsupported state sequence schema version "${String(source.schemaVersion)}".`,
);
}
if (!Array.isArray(source.steps) || source.steps.length === 0) {
throw new TypeError(`State sequence "${name}" requires at least one step.`);
}
const sequence = {
schemaVersion: STATE_SEQUENCE_SCHEMA_VERSION,
name,
steps: Object.freeze(source.steps.map(normalizeStep)),
transitionFirst: source.transitionFirst === true,
};
if (hasOwn(source, "transition")) {
sequence.transition = normalizeTransition(
source.transition,
`State sequence "${name}" transition`,
);
}
if (hasOwn(source, "metadata")) {
sequence.metadata = normalizeOptions(
source.metadata,
`State sequence "${name}" metadata`,
);
}
return Object.freeze(sequence);
}
function createClock(clock) {
if (
clock &&
(typeof clock.now !== "function" ||
typeof clock.setTimeout !== "function" ||
typeof clock.clearTimeout !== "function")
) {
throw new TypeError(
"A custom sequence clock requires now(), setTimeout(), and clearTimeout().",
);
}
const now =
clock
? () => clock.now()
: () =>
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
const schedule =
clock
? (callback, delay) => clock.setTimeout(callback, delay)
: (callback, delay) => setTimeout(callback, delay);
const cancel =
clock
? (timer) => clock.clearTimeout(timer)
: (timer) => clearTimeout(timer);
return Object.freeze({ now, setTimeout: schedule, clearTimeout: cancel });
}
function transitionOptions(specification, preservePhase, stepOptions, signal) {
const options = { preservePhase };
if (typeof specification === "string") {
options.transition = specification;
} else if (specification && typeof specification === "object") {
Object.assign(options, specification);
if (!options.transition && typeof options.name === "string") {
options.transition = options.name;
delete options.name;
}
}
Object.assign(options, stepOptions || {});
options.signal = signal;
return options;
}
/**
* Plays serial state definitions against any target implementing setSprite().
* transitionTo() is awaited when available; otherwise setSprite() is used.
*/
export class StateSequencePlayer extends EventTarget {
constructor(target, options = {}) {
super();
if (!target || typeof target.setSprite !== "function") {
throw new TypeError("StateSequencePlayer requires a setSprite-capable target.");
}
this.target = target;
this.clock = createClock(options.clock);
this.options = Object.freeze({
transition: hasOwn(options, "transition")
? normalizeTransition(options.transition, "Default transition")
: "field-morph",
transitionFirst: options.transitionFirst === true,
preservePhase: options.preservePhase !== false,
});
this.sequences = new Map();
this.status = "idle";
this.state = target.currentRecipe?.id || null;
this.destroyed = false;
this.finished = Promise.resolve(null);
this._active = null;
this._serial = 0;
if (Array.isArray(options.sequences)) {
for (const sequence of options.sequences) this.register(sequence);
} else if (options.sequences && typeof options.sequences === "object") {
for (const [name, sequence] of Object.entries(options.sequences)) {
this.register(
Array.isArray(sequence)
? defineStateSequence(name, sequence)
: defineStateSequence({ ...sequence, name: sequence.name || name }),
);
}
}
}
get currentSequence() {
return this._active?.sequence || null;
}
get currentStepIndex() {
return this._active?.index ?? -1;
}
get isPlaying() {
return this.status === "running" || this.status === "paused";
}
get isPaused() {
return this.status === "paused";
}
register(nameOrDefinition, steps, options) {
if (this.destroyed) throw new Error("StateSequencePlayer has been destroyed.");
const sequence = defineStateSequence(nameOrDefinition, steps, options);
this.sequences.set(sequence.name, sequence);
return sequence;
}
unregister(name) {
return this.sequences.delete(String(name));
}
getSequence(name) {
return this.sequences.get(String(name)) || null;
}
listSequences() {
return Object.freeze([...this.sequences.values()]);
}
_resolveSequence(input, options) {
if (typeof input === "string") {
const sequence = this.getSequence(input);
if (!sequence) throw new RangeError(`Unknown state sequence "${input}".`);
return sequence;
}
if (Array.isArray(input)) {
return defineStateSequence(options.name || `sequence-${this._serial + 1}`, input);
}
return defineStateSequence(input);
}
_normalizePlayOptions(options) {
const normalized = {
signal: options.signal,
preservePhase: options.preservePhase ?? this.options.preservePhase,
transitionFirst: options.transitionFirst,
};
if (hasOwn(options, "transition")) {
normalized.transition = normalizeTransition(options.transition, "Play transition");
}
return normalized;
}
play(sequenceOrName, options = {}) {
if (this.destroyed) {
return Promise.reject(new Error("StateSequencePlayer has been destroyed."));
}
let sequence;
let playOptions;
try {
sequence = this._resolveSequence(sequenceOrName, options);
playOptions = this._normalizePlayOptions(options);
if (playOptions.signal?.aborted) throw abortError(playOptions.signal);
} catch (error) {
return Promise.reject(error);
}
if (this._active) this._stopRun(this._active, "superseded");
const run = {
id: ++this._serial,
sequence,
options: playOptions,
controller: new AbortController(),
index: -1,
completedSteps: 0,
paused: false,
delay: null,
gates: new Set(),
terminal: null,
removeExternalAbort: () => {},
};
this._active = run;
this.status = "running";
if (playOptions.signal) {
const onAbort = () => this._cancelRun(run, playOptions.signal);
playOptions.signal.addEventListener("abort", onAbort, { once: true });
run.removeExternalAbort = () =>
playOptions.signal.removeEventListener("abort", onAbort);
if (playOptions.signal.aborted) this._cancelRun(run, playOptions.signal);
}
this._emit("sequencestart", run, { totalSteps: sequence.steps.length });
const finished = this._execute(run).then(
() => {
if (!run.terminal) this._completeRun(run);
return this._result(run);
},
(error) => {
if (run.terminal?.status === "stopped") return this._result(run);
if (run.terminal?.status === "cancelled") throw run.terminal.error;
this._errorRun(run, error);
throw error;
},
).finally(() => this._finalizeRun(run));
run.finished = finished;
this.finished = finished;
return finished;
}
run(sequenceOrName, options = {}) {
return this.play(sequenceOrName, options);
}
async _execute(run) {
for (let index = 0; index < run.sequence.steps.length; index += 1) {
if (run.controller.signal.aborted) throw abortError(run.controller.signal);
const step = run.sequence.steps[index];
run.index = index;
this._emit("stepstart", run, {
step,
sprite: stateId(step.sprite),
});
await this._applyStep(run, step, index);
await this._pauseGate(run);
if (run.controller.signal.aborted) throw abortError(run.controller.signal);
const from = this.state;
const to = stateId(step.sprite);
this.state = to;
this._emit("statechange", run, { from, to, state: to, step });
this._emit("stepenter", run, { from, to, step, sprite: to });
if (step.holdMs > 0) await this._delay(run, step.holdMs);
await this._pauseGate(run);
if (run.controller.signal.aborted) throw abortError(run.controller.signal);
run.completedSteps = index + 1;
this._emit("stepcomplete", run, { step, sprite: to });
}
}
_transitionFor(run, step, index) {
if (hasOwn(step, "transition")) return step.transition;
const transitionFirst =
run.options.transitionFirst ??
(run.sequence.transitionFirst || this.options.transitionFirst);
if (index === 0 && !transitionFirst) return false;
if (hasOwn(run.options, "transition")) return run.options.transition;
if (hasOwn(run.sequence, "transition")) return run.sequence.transition;
return this.options.transition;
}
async _applyStep(run, step, index) {
const specification = this._transitionFor(run, step, index);
if (specification === false) {
const options = { immediate: true, ...(step.options || {}) };
this.target.setSprite(step.sprite, options);
return;
}
if (typeof this.target.transitionTo !== "function") {
const options = transitionOptions(
specification,
run.options.preservePhase,
step.options,
run.controller.signal,
);
delete options.signal;
this.target.setSprite(step.sprite, options);
return;
}
const options = transitionOptions(
specification,
run.options.preservePhase,
step.options,
run.controller.signal,
);
let transition;
try {
transition = this.target.transitionTo(step.sprite, options);
} catch (error) {
throw error;
}
await this._raceAbort(transition, run);
}
_raceAbort(value, run) {
if (run.controller.signal.aborted) {
return Promise.reject(abortError(run.controller.signal));
}
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, result) => {
if (settled) return;
settled = true;
run.controller.signal.removeEventListener("abort", onAbort);
callback(result);
};
const onAbort = () => finish(reject, abortError(run.controller.signal));
run.controller.signal.addEventListener("abort", onAbort, { once: true });
Promise.resolve(value).then(
(result) => finish(resolve, result),
(error) => finish(reject, error),
);
});
}
_pauseGate(run) {
if (!run.paused) return Promise.resolve();
if (run.controller.signal.aborted) {
return Promise.reject(abortError(run.controller.signal));
}
return new Promise((resolve, reject) => {
let settled = false;
const gate = {
resolve: () => finish(resolve),
};
const finish = (callback, value) => {
if (settled) return;
settled = true;
run.gates.delete(gate);
run.controller.signal.removeEventListener("abort", onAbort);
callback(value);
};
const onAbort = () => finish(reject, abortError(run.controller.signal));
run.gates.add(gate);
run.controller.signal.addEventListener("abort", onAbort, { once: true });
});
}
_delay(run, durationMs) {
return new Promise((resolve, reject) => {
let settled = false;
const delay = {
timer: null,
remainingMs: durationMs,
startedAt: 0,
pause: () => {
if (delay.timer === null) return;
const elapsed = Math.max(0, this.clock.now() - delay.startedAt);
delay.remainingMs = Math.max(0, delay.remainingMs - elapsed);
this.clock.clearTimeout(delay.timer);
delay.timer = null;
},
resume: () => {
if (settled || run.paused || delay.timer !== null) return;
delay.startedAt = this.clock.now();
delay.timer = this.clock.setTimeout(finish, delay.remainingMs);
},
};
const cleanup = () => {
if (delay.timer !== null) this.clock.clearTimeout(delay.timer);
delay.timer = null;
if (run.delay === delay) run.delay = null;
run.controller.signal.removeEventListener("abort", onAbort);
};
const finish = () => {
if (settled) return;
settled = true;
cleanup();
resolve();
};
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
reject(abortError(run.controller.signal));
};
run.delay = delay;
run.controller.signal.addEventListener("abort", onAbort, { once: true });
delay.resume();
});
}
/**
* Pause sequence progression and the active hold clock. A visual transition
* already running in the target may finish, but no step is entered or
* advanced until resume() is called.
*/
pause() {
const run = this._active;
if (!run || run.terminal || run.paused) return this;
run.paused = true;
run.delay?.pause();
this.status = "paused";
this._emit("sequencepause", run);
return this;
}
resume() {
const run = this._active;
if (!run || run.terminal || !run.paused) return this;
run.paused = false;
this.status = "running";
this._emit("sequenceresume", run);
run.delay?.resume();
for (const gate of [...run.gates]) gate.resolve();
return this;
}
stop(reason = "stopped") {
if (this._active) this._stopRun(this._active, reason);
return this;
}
_stopRun(run, reason) {
if (run.terminal) return;
run.terminal = { status: "stopped", reason: String(reason || "stopped") };
run.controller.abort(abortError(reason));
if (this._active === run) this.status = "idle";
this._emit("sequencestop", run, { reason: run.terminal.reason });
}
_cancelRun(run, signal) {
if (run.terminal) return;
const error = abortError(signal);
run.terminal = { status: "cancelled", reason: signal?.reason, error };
run.controller.abort(error);
if (this._active === run) this.status = "idle";
this._emit("sequencecancel", run, { reason: signal?.reason, error });
}
_completeRun(run) {
run.terminal = { status: "completed", reason: "completed" };
if (this._active === run) this.status = "idle";
this._emit("sequencecomplete", run, {
completedSteps: run.completedSteps,
});
}
_errorRun(run, error) {
if (run.terminal) return;
run.terminal = { status: "error", reason: "error", error };
if (this._active === run) this.status = "idle";
this._emit("sequenceerror", run, { error });
}
_result(run) {
return Object.freeze({
runId: run.id,
name: run.sequence.name,
sequence: run.sequence,
status: run.terminal?.status || "completed",
reason: run.terminal?.reason,
completedSteps: run.completedSteps,
});
}
_emit(type, run, extra = {}) {
const detail = Object.freeze({
runId: run.id,
name: run.sequence.name,
sequence: run.sequence,
index: run.index,
...extra,
});
this.dispatchEvent(detailEvent(type, detail));
return detail;
}
_finalizeRun(run) {
run.removeExternalAbort();
run.delay?.pause();
run.delay = null;
for (const gate of [...run.gates]) gate.resolve();
run.gates.clear();
if (this._active === run) {
this._active = null;
if (!this.destroyed) this.status = "idle";
}
}
destroy() {
if (this.destroyed) return;
if (this._active) this._stopRun(this._active, "destroyed");
this.destroyed = true;
this.status = "destroyed";
this.sequences.clear();
}
}
/**
* Ergonomic one-off playback. The returned player is also the pause/resume/stop
* handle; await player.finished for completion.
*
* @example
* const playback = playStateSequence(engine, [
* { sprite: "ai.ambient-idle", holdMs: 1200 },
* { sprite: "ai.progress", transition: "contour-trace" },
* ], { signal });
* await playback.finished;
*/
export function playStateSequence(target, steps, options = {}) {
const player = new StateSequencePlayer(target, {
clock: options.clock,
transition: hasOwn(options, "transition") ? options.transition : "field-morph",
transitionFirst: options.transitionFirst,
preservePhase: options.preservePhase,
});
const sequence = defineStateSequence(options.name || "one-off", steps, {
metadata: options.metadata,
});
player.finished = player.play(sequence, { signal: options.signal });
return player;
}
export default StateSequencePlayer;

3033
web/vendor/src/studio.js vendored Normal file

File diff suppressed because it is too large Load Diff

4273
web/vendor/src/styles.css vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
import JoanGlyphElement, {
JOAN_GLYPH_TAG_NAME,
defineJoanGlyph,
} from "./web-component.js";
defineJoanGlyph(JOAN_GLYPH_TAG_NAME);
export { JOAN_GLYPH_TAG_NAME, JoanGlyphElement, defineJoanGlyph };
export default JoanGlyphElement;

308
web/vendor/src/web-component.js vendored Normal file
View File

@@ -0,0 +1,308 @@
import JoanGlyphEngine from "./joan-engine.js";
export const JOAN_GLYPH_TAG_NAME = "joan-glyph";
const observed = [
"sprite",
"seed",
"resolution",
"speed",
"density",
"field",
"pixel-shape",
"pixel-switch",
"orb-boundary",
"orb-background-color",
"orb-background-mode",
"non-error-background",
"non-error-off",
"non-error-ink",
"non-error-accent",
"non-error-glow",
"noninteractive",
"paused",
"transparent",
];
const paletteAttributes = {
"non-error-background": "background",
"non-error-off": "off",
"non-error-ink": "ink",
"non-error-accent": "accent",
"non-error-glow": "glow",
};
// Keeping the module evaluable without DOM globals lets SSR builds inspect and
// bundle it. Instances are still browser elements and should only be created
// after a real HTMLElement implementation exists.
const HTMLElementBase =
typeof globalThis.HTMLElement === "undefined"
? class JoanGlyphSsrBase extends EventTarget {}
: globalThis.HTMLElement;
export class JoanGlyphElement extends HTMLElementBase {
static observedAttributes = observed;
constructor() {
super();
const root = this.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = `
:host {
display: inline-block;
inline-size: 68px;
block-size: 68px;
aspect-ratio: 1;
contain: layout paint style;
}
canvas {
display: block;
inline-size: 100%;
block-size: 100%;
image-rendering: pixelated;
touch-action: none;
}
`;
this.canvas = document.createElement("canvas");
root.append(style, this.canvas);
}
connectedCallback() {
if (this._engine) return;
this._engine = new JoanGlyphEngine(this.canvas, this.readOptions());
}
disconnectedCallback() {
this._engine?.destroy();
this._engine = null;
}
attributeChangedCallback(name, oldValue, newValue) {
if (!this._engine || oldValue === newValue) return;
switch (name) {
case "sprite":
this._engine.setSprite(newValue || "ai.idle");
break;
case "seed":
this._engine.setSeed(newValue || "joan-v5");
break;
case "resolution":
this._engine.setResolution(Number(newValue) || 68);
break;
case "field":
if (newValue === null) this._engine.useRecipeFields();
else this._engine.setField(newValue);
break;
case "pixel-shape":
if (newValue === null) this._engine.setOptions({ pixelShape: null });
else this._engine.setPixelShape(newValue);
break;
case "pixel-switch":
if (newValue === null) this._engine.setOptions({ pixelSwitch: null });
else this._engine.setPixelSwitch(newValue);
break;
case "orb-boundary":
this._engine.setOptions({
orbBoundary:
String(newValue || "").trim().toLowerCase() === "gestalt"
? "gestalt"
: "defined",
});
break;
case "orb-background-color":
case "orb-background-mode":
this._engine.setOptions(this.readOrbBackground());
break;
case "non-error-background":
case "non-error-off":
case "non-error-ink":
case "non-error-accent":
case "non-error-glow":
this._engine.setNonErrorPalette(this.readNonErrorPalette());
break;
case "paused":
if (newValue === null) this._engine.play();
else this._engine.pause();
break;
case "noninteractive":
this._engine.setOptions({ interactive: newValue === null });
break;
case "transparent":
this._engine.setOptions({ background: newValue === null });
break;
case "speed":
this._engine.setOptions({ speed: Number(newValue) || 1 });
break;
case "density":
this._engine.setOptions({ density: Number(newValue) || 1 });
break;
default:
break;
}
}
readOptions() {
return {
sprite: this.getAttribute("sprite") || "ai.idle",
seed: this.getAttribute("seed") || "joan-v5",
gridSize: Number(this.getAttribute("resolution")) || 68,
speed: Number(this.getAttribute("speed")) || 1,
density: Number(this.getAttribute("density")) || 1,
field: this.getAttribute("field") || undefined,
pixelShape: this.getAttribute("pixel-shape") || undefined,
pixelSwitch: this.getAttribute("pixel-switch") || undefined,
orbBoundary:
String(this.getAttribute("orb-boundary") || "").trim().toLowerCase() ===
"gestalt"
? "gestalt"
: "defined",
...this.readOrbBackground(),
nonErrorPalette: this.readNonErrorPalette(),
autoplay: !this.hasAttribute("paused"),
interactive: !this.hasAttribute("noninteractive"),
background: !this.hasAttribute("transparent"),
};
}
readOrbBackground() {
const colorAttribute = this.getAttribute("orb-background-color")?.trim();
const orbBackgroundColor =
colorAttribute && colorAttribute.toLowerCase() !== "transparent"
? colorAttribute
: null;
const modeAttribute = this.getAttribute("orb-background-mode");
const requestedMode = String(modeAttribute || "").trim().toLowerCase();
const orbBackgroundMode =
modeAttribute === null
? orbBackgroundColor
? "solid"
: "none"
: ["none", "solid", "pixelated"].includes(requestedMode)
? requestedMode
: "none";
return { orbBackgroundColor, orbBackgroundMode };
}
readNonErrorPalette() {
const palette = {};
for (const [attribute, key] of Object.entries(paletteAttributes)) {
const value = this.getAttribute(attribute)?.trim();
if (value) palette[key] = value;
}
return Object.keys(palette).length ? palette : null;
}
get engine() {
return this._engine;
}
setSprite(sprite, options) {
return this._engine?.setSprite(sprite, options);
}
setProgress(value) {
return this._engine?.setProgress(value);
}
setNonErrorPalette(palette) {
return this._engine?.setNonErrorPalette(palette);
}
setSeed(seed) {
return this._engine?.setSeed(seed);
}
setResolution(size) {
return this._engine?.setResolution(size);
}
setField(field) {
return this._engine?.setField(field);
}
useRecipeFields() {
return this._engine?.useRecipeFields();
}
setPixelShape(shape) {
return this._engine?.setPixelShape(shape);
}
setPixelSwitch(pixelSwitch) {
return this._engine?.setPixelSwitch(pixelSwitch);
}
setOptions(options) {
return this._engine?.setOptions(options);
}
configure(options, configureOptions) {
return this._engine?.configure(options, configureOptions);
}
setAudioLevel(value) {
return this._engine?.setAudioLevel(value);
}
activate(options) {
return this._engine?.activate(options);
}
resume(options) {
return this._engine?.resume(options);
}
play() {
return this._engine?.play();
}
pause() {
return this._engine?.pause();
}
toggle() {
return this._engine?.toggle();
}
renderOnce(time) {
return this._engine?.renderOnce(time);
}
transitionTo(sprite, options) {
return this._engine?.transitionTo(sprite, options);
}
whenTransitionComplete(options) {
return this._engine?.whenTransitionComplete(options);
}
inspect() {
return this._engine?.inspect();
}
on(type, listener, options) {
return this._engine?.on(type, listener, options);
}
exportConfig() {
return this._engine?.exportConfig();
}
signal(type, payload) {
return this._engine?.signal(type, payload);
}
}
export function defineJoanGlyph(tagName = JOAN_GLYPH_TAG_NAME) {
const registry = globalThis.customElements;
if (!registry) return JoanGlyphElement;
if (!registry.get(tagName)) registry.define(tagName, JoanGlyphElement);
return JoanGlyphElement;
}
// Preserve the original import-and-register behavior. Applications that prefer
// an explicit side-effect entry can import `web-component/register` instead.
if (typeof globalThis.customElements !== "undefined") defineJoanGlyph();
export default JoanGlyphElement;

86
web/vendor/types/config.d.ts vendored Normal file
View File

@@ -0,0 +1,86 @@
import type {
BuiltInSpriteId,
FieldId,
JoanEngineOptions,
PixelShape,
PixelSwitch,
SpriteRecipe,
Transition,
} from "./index.js";
export interface ConfigurationIssue {
code: string;
message: string;
path: string | null;
value: unknown;
suggestion: string | null;
severity: "error";
}
export interface InspectEngineOptionsResult {
ok: boolean;
value: Partial<JoanEngineOptions> & Record<string, unknown>;
issues: ConfigurationIssue[];
}
export interface InspectEngineOptionsSettings {
coerce?: boolean;
allowUnknown?: boolean;
}
export interface ValidateEngineOptionsSettings
extends InspectEngineOptionsSettings {
strict?: boolean;
}
export class JoanConfigurationError extends RangeError {
readonly name: "JoanConfigurationError";
readonly code: string;
readonly path: string | null;
readonly value: unknown;
readonly suggestion: string | null;
readonly allowed: unknown[] | null;
constructor(
message: string,
details?: Partial<ConfigurationIssue> & { allowed?: readonly unknown[] },
);
toIssue(): ConfigurationIssue;
}
export const TRANSITION_IDS: readonly Transition[];
export const PIXEL_SWITCH_IDS: readonly PixelSwitch[];
export const PIXEL_SHAPE_IDS: readonly PixelShape[];
export function nearestName<T extends string>(
value: unknown,
candidates: readonly T[],
options?: { maxDistance?: number },
): T | null;
export function validateKnownValue<T extends string>(
value: unknown,
allowed: readonly T[],
options?: {
aliases?: Readonly<Record<string, T>>;
label?: string;
path?: string;
},
): T;
export function validateSpriteId(value: unknown): BuiltInSpriteId;
export function validateFieldId(value: unknown): FieldId;
export function validateTransition(value: unknown): Transition;
export function validatePixelShape(value: unknown): PixelShape;
export function validatePixelSwitch(value: unknown): PixelSwitch;
export function inspectEngineOptions(
input: unknown,
options?: InspectEngineOptionsSettings,
): InspectEngineOptionsResult;
export function validateEngineOptions<T extends Record<string, unknown>>(
input: T,
options?: ValidateEngineOptionsSettings,
): T & JoanEngineOptions;
export function validateRecipe(input: unknown): SpriteRecipe;
export function defineRecipe<T extends SpriteRecipe>(
recipe: T,
): Readonly<T & SpriteRecipe>;

110
web/vendor/types/fields.d.ts vendored Normal file
View File

@@ -0,0 +1,110 @@
import type { FieldAlias, FieldId, FieldName } from "./index.js";
export type FieldOptions = Readonly<Record<string, number | string | boolean>>;
export type FieldSampler = (
x: number,
y: number,
time?: number,
options?: FieldOptions,
kit?: FieldKit,
) => number;
export const TAU: number;
export const BAYER8_SIZE: 8;
export const BAYER8: readonly number[];
export function clamp(value: number, min?: number, max?: number): number;
export function lerp(a: number, b: number, amount: number): number;
export function smoothstep(edge0: number, edge1: number, value: number): number;
export function smootherstep(edge0: number, edge1: number, value: number): number;
export function fract(value: number): number;
export function bayer8(x: number, y: number): number;
export const bayerThreshold: typeof bayer8;
export function orderedDither(
value: number,
x: number,
y: number,
levels?: number,
): number;
export function hashSeed(seed?: unknown): number;
export class FieldKit {
constructor(seed?: unknown | { seed: unknown });
seed: number;
readonly permutation: Uint8Array;
reseed(seed?: unknown): this;
setSeed(seed?: unknown): this;
clone(): FieldKit;
list(): readonly FieldId[];
has(id: unknown): boolean;
get(id: FieldName): FieldSampler | undefined;
sample(
id: FieldName,
x: number,
y: number,
time?: number,
options?: FieldOptions,
): number;
hash2(x: number, y: number, salt?: number): number;
signedNoise2(x: number, y: number): number;
noise2(x: number, y: number): number;
signedNoise3(x: number, y: number, z: number): number;
noise3(x: number, y: number, z: number): number;
value2(x: number, y: number): number;
fbmNoise(x: number, y: number, z?: number, options?: FieldOptions): number;
ridgedNoise(x: number, y: number, z?: number, options?: FieldOptions): number;
curl2(
x: number,
y: number,
z?: number,
epsilon?: number,
out?: Float32Array | number[],
): Float32Array | number[];
cellular2(x: number, y: number, time?: number, options?: FieldOptions): number;
voronoi2(x: number, y: number, time?: number, options?: FieldOptions): number;
fbm(x: number, y: number, time?: number, options?: FieldOptions): number;
ridged(x: number, y: number, time?: number, options?: FieldOptions): number;
domainWarp(x: number, y: number, time?: number, options?: FieldOptions): number;
curl(x: number, y: number, time?: number, options?: FieldOptions): number;
flow(x: number, y: number, time?: number, options?: FieldOptions): number;
worley(x: number, y: number, time?: number, options?: FieldOptions): number;
voronoi(x: number, y: number, time?: number, options?: FieldOptions): number;
plasma(x: number, y: number, time?: number, options?: FieldOptions): number;
interference(x: number, y: number, time?: number, options?: FieldOptions): number;
vortex(x: number, y: number, time?: number, options?: FieldOptions): number;
metaballs(x: number, y: number, time?: number, options?: FieldOptions): number;
caustics(x: number, y: number, time?: number, options?: FieldOptions): number;
strata(x: number, y: number, time?: number, options?: FieldOptions): number;
radar(x: number, y: number, time?: number, options?: FieldOptions): number;
constellation(x: number, y: number, time?: number, options?: FieldOptions): number;
liquid(x: number, y: number, time?: number, options?: FieldOptions): number;
electric(x: number, y: number, time?: number, options?: FieldOptions): number;
ripple(x: number, y: number, time?: number, options?: FieldOptions): number;
kaleidoscope(x: number, y: number, time?: number, options?: FieldOptions): number;
}
export const fbm: FieldSampler;
export const ridged: FieldSampler;
export const domainWarp: FieldSampler;
export const curl: FieldSampler;
export const flow: FieldSampler;
export const worley: FieldSampler;
export const voronoi: FieldSampler;
export const plasma: FieldSampler;
export const interference: FieldSampler;
export const vortex: FieldSampler;
export const metaballs: FieldSampler;
export const caustics: FieldSampler;
export const strata: FieldSampler;
export const radar: FieldSampler;
export const constellation: FieldSampler;
export const liquid: FieldSampler;
export const electric: FieldSampler;
export const ripple: FieldSampler;
export const kaleidoscope: FieldSampler;
export const FIELDS: Readonly<Record<FieldId, FieldSampler>>;
export const FIELD_IDS: readonly FieldId[];
export const FIELD_ALIASES: Readonly<Record<FieldAlias, FieldId>>;
export const DEFAULT_FIELD_KIT: FieldKit;
export function createFieldKit(seed?: unknown): FieldKit;
export default FieldKit;

46
web/vendor/types/glyphs.d.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
import type { GlyphSampleContext, GlyphSampler } from "./index.js";
export type GlyphId =
| "idle"
| "listening"
| "thinking"
| "thinking-deep"
| "still-working"
| "loading"
| "progress"
| "generating"
| "searching"
| "tool-use"
| "speaking"
| "awaiting-input"
| "success"
| "warning"
| "error"
| "paused"
| "cancelled"
| "offline"
| "transfer"
| "handoff"
| "celebration"
| "custom";
export const GLYPHS: Readonly<Record<GlyphId, GlyphSampler>>;
export const GLYPH_IDS: readonly GlyphId[];
export const GLYPH_ALIASES: Readonly<Record<string, GlyphId>>;
export function resolveGlyphId(id: unknown): GlyphId;
export function getGlyph(id: unknown): GlyphSampler;
export function listGlyphs(): readonly GlyphId[];
export function gestaltArcSupport(
angle: number,
context?: GlyphSampleContext,
openness?: number,
): number;
export function sampleGlyph(
id: unknown,
x: number,
y: number,
time?: number,
context?: GlyphSampleContext,
): number;
export default GLYPHS;

636
web/vendor/types/index.d.ts vendored Normal file
View File

@@ -0,0 +1,636 @@
export type BuiltInSpriteId =
| "ai.idle"
| "ai.ambient-idle"
| "ai.ambient-thinking"
| "ai.ambient-thinking-symmetric"
| "ai.ambient-speaking"
| "ai.listening"
| "ai.thinking"
| "ai.thinking-deep"
| "ai.still-working"
| "ai.loading"
| "ai.progress"
| "ai.generating"
| "ai.searching"
| "ai.tool-use"
| "ai.speaking"
| "ai.awaiting-input"
| "status.success"
| "status.warning"
| "status.error"
| "status.paused"
| "status.cancelled"
| "status.offline"
| "transfer.active"
| "workflow.handoff"
| "status.celebration";
export type SpriteAlias =
| "idle"
| "ready"
| "ambient"
| "ambient-idle"
| "field-idle"
| "ambient-thinking"
| "field-thinking"
| "ambient-thinking-symmetric"
| "field-thinking-symmetric"
| "symmetric-thinking"
| "ambient-speaking"
| "field-speaking"
| "listening"
| "thinking"
| "thinking-deep"
| "deep-thinking"
| "still-working"
| "working"
| "loading"
| "progress"
| "generating"
| "searching"
| "tool-use"
| "tool"
| "speaking"
| "awaiting-input"
| "prompt"
| "success"
| "warning"
| "error"
| "paused"
| "cancelled"
| "offline"
| "transfer"
| "upload"
| "download"
| "sync"
| "handoff"
| "celebration";
export type SpriteName = BuiltInSpriteId | SpriteAlias;
export type FieldId =
| "fbm"
| "ridged"
| "domain-warp"
| "curl"
| "flow"
| "worley"
| "voronoi"
| "plasma"
| "interference"
| "vortex"
| "metaballs"
| "caustics"
| "strata"
| "radar"
| "constellation"
| "liquid"
| "electric"
| "ripple"
| "kaleidoscope";
export type FieldAlias =
| "noise"
| "turbulence"
| "ridge"
| "domainWarp"
| "domain_warp"
| "warp"
| "curl-flow"
| "curlFlow"
| "cellular"
| "cells"
| "cell"
| "water"
| "lightning"
| "waves"
| "mandala";
export type FieldName = FieldId | FieldAlias;
export type PixelShape =
| "disc"
| "square"
| "diamond"
| "capsule"
| "line"
| "ring"
| "cross"
| "square-cross"
| "square-cross-ring";
export type PixelShapeAlias = "circle" | "rounded-square" | "dot";
export type PixelShapeName = PixelShape | PixelShapeAlias;
export type PixelSwitch =
| "ordered-dither"
| "temporal-blue-noise"
| "threshold-hysteresis"
| "sdf-wavefront"
| "contour-trace"
| "curl-advect"
| "neighbor-propagation"
| "radial-cascade"
| "path-draw"
| "axis-flip"
| "seeded-dissolve"
| "field-morph";
export type PixelSwitchAlias = "cluster-dissolve" | "neighbor-ignite";
export type PixelSwitchName = PixelSwitch | PixelSwitchAlias;
export type Transition =
| "field-morph"
| "seeded-dissolve"
| "radial-cascade"
| "angular-sweep"
| "scanline"
| "contour-trace"
| "path-draw"
| "axis-flip"
| "cluster-dissolve"
| "neighbor-ignite"
| "glitch-bands"
| "instant";
export type TransitionAlias =
| "dissolve"
| "radial"
| "bloom"
| "spiral"
| "wave"
| "shutter"
| "glitch"
| "sdf-wavefront"
| "neighbor-propagation";
export type TransitionName = Transition | TransitionAlias;
export type Quality = "low" | "balanced" | "high" | "auto";
export type EffectiveQuality = Exclude<Quality, "auto">;
export type ReducedMotion = boolean | "system";
export type PreviewMode = "fit" | "actual";
export type OrbBoundary = "defined" | "gestalt";
export type OrbBackgroundMode = "none" | "solid" | "pixelated";
export type CSSColor = string;
export interface Palette {
/** Optional authored palette identifier preserved by exported configs. */
name?: string;
background?: CSSColor;
/** Authored inactive color alias; effective palettes also expose `off`. */
shadow?: CSSColor;
off?: CSSColor;
ink?: CSSColor;
accent?: CSSColor;
/** Use `transparent` to disable glow. */
glow?: CSSColor;
/** Optional overrides selected by a recipe's authored palette name. */
variants?: Readonly<Record<string, Palette>>;
}
export interface FieldLayer {
field: FieldName;
weight?: number;
scale?: number;
blend?: string;
phase?: number;
speed?: number;
contrast?: number;
[key: string]: unknown;
}
export interface TransitionRecipe {
name?: TransitionName;
type?: TransitionName;
enter?: TransitionName;
exit?: TransitionName;
duration?: number;
durationMs?: number;
exitDurationMs?: number;
preservePhase?: boolean;
preserveBuffer?: boolean;
interruptible?: boolean;
[key: string]: unknown;
}
export interface PixelSwitchRecipe {
mode: PixelSwitchName;
dither?: string;
hysteresis?: number;
temporalRate?: number;
neighborhood?: number;
direction?: string | null;
[key: string]: unknown;
}
export interface SpriteComposition {
mode?: "glyph" | "field-orb" | string;
/** Opts a circular recipe into the global Gestalt boundary preference. */
gestaltBoundary?: boolean;
/** Controls how much open space remains between implied boundary arcs. */
gestaltOpenness?: number;
[key: string]: unknown;
}
export interface SpriteRecipe {
schemaVersion?: number;
id: string;
glyph: string;
/** A recipe can use one primary field, a field mix, or both. */
field?: FieldName;
fieldMix?: readonly FieldLayer[];
palette?: Readonly<Palette>;
semantic?: Readonly<Record<string, unknown>>;
composition?: Readonly<SpriteComposition>;
pixel?: Readonly<{
shape?: PixelShapeName;
scale?: number;
gap?: number;
[key: string]: unknown;
}>;
pixelShape?: PixelShapeName;
pixelSwitch?: PixelSwitchName | Readonly<PixelSwitchRecipe>;
transition?: TransitionName | Readonly<TransitionRecipe>;
interactions?: Readonly<Record<string, unknown>> | false;
timeline?: Readonly<Record<string, unknown>>;
reducedMotion?: Readonly<Record<string, unknown>>;
labels?: Readonly<Record<string, string>>;
threshold?: number;
density?: number;
speed?: number;
[key: string]: unknown;
}
export interface JoanEngineOptions {
sprite?: SpriteName | SpriteRecipe;
seed?: string | number;
gridSize?: number;
speed?: number;
density?: number;
contrast?: number;
fps?: number;
pixelShape?: PixelShapeName | null;
pixelSwitch?: PixelSwitchName | PixelSwitchRecipe | null;
transition?: TransitionName | TransitionRecipe | null;
field?: FieldName | null;
fieldMode?: "recipe" | "override";
palette?: Palette | null;
/** Compatibility alias for the global palette override. */
paletteOverride?: Palette | null;
nonErrorPalette?: Palette | null;
previewMode?: PreviewMode;
/** Use an implied, discontinuous edge for supported circular presence states. */
orbBoundary?: OrbBoundary;
/** Optional color used by the background layer inside supported presence orbs. */
orbBackgroundColor?: CSSColor | null;
/** Selects no disk, a smooth disk, or a grid-aligned pixel disk. */
orbBackgroundMode?: OrbBackgroundMode;
background?: boolean;
offPixels?: boolean;
interactive?: boolean;
autoplay?: boolean;
autoResize?: boolean;
dprMax?: number;
reducedMotion?: ReducedMotion;
quality?: Quality;
ariaLive?: HTMLElement | null;
direction?: string;
targetGlyph?: string | null;
progress?: number;
}
export interface CreateProceduralGlyphOptions extends JoanEngineOptions {
canvas: HTMLCanvasElement;
}
export interface SetSpriteOptions {
transition?: TransitionName | TransitionRecipe;
duration?: number;
preservePhase?: boolean;
preserveBuffer?: boolean;
immediate?: boolean;
forceTransition?: boolean;
useRecipeFields?: boolean;
}
export interface TransitionToOptions extends SetSpriteOptions {
signal?: AbortSignal;
}
export interface ActivateOptions {
x?: number;
y?: number;
energy?: number;
life?: number;
source?: string;
key?: string | null;
}
export interface ResumeOptions extends SetSpriteOptions {
sprite?: SpriteName | SpriteRecipe;
source?: string;
}
export interface SignalPayload {
value?: unknown;
energy?: number;
life?: number;
x?: number;
y?: number;
direction?: string;
source?: string;
[key: string]: unknown;
}
export interface EngineStats {
fps: number;
activePixels: number;
frameMs: number;
resolution: number;
simulationSteps: number;
sampledPixels: number;
quality: EffectiveQuality;
}
export interface EngineInspection {
sprite: string | null;
recipeSource: string | null;
previousSprite: string | null;
fieldMode: "recipe" | "override";
field: string | null;
running: boolean;
visible: boolean;
destroyed: boolean;
reducedMotion: boolean;
transition: {
active: boolean;
name: Transition;
progress: number;
elapsed: number;
duration: number;
pending: string | null;
};
signals: Array<{ type: string; value: number; age: number; life: number }>;
semanticSignals: Record<string, unknown>;
palette: Palette;
orbBoundary: OrbBoundary;
orbBackgroundColor: CSSColor | null;
orbBackgroundMode: OrbBackgroundMode;
quality: { requested: Quality; effective: EffectiveQuality };
stats: EngineStats;
}
export interface TransitionDetail {
from: string | null;
to: string | null;
duration: number;
elapsed: number;
transition: Transition;
status: "completed" | "interrupted";
reason: string;
serial: number;
}
export interface JoanGlyphEventDetailMap {
activate: {
sprite: string;
x: number;
y: number;
source: string;
key: string | null;
};
configchange: Partial<JoanEngineOptions> & Record<string, unknown>;
destroy: Record<string, never>;
pause: { sprite: string };
play: { sprite: string };
qualitychange: {
from: EffectiveQuality;
to: EffectiveQuality;
frameMs: number;
budgetMs: number;
};
resume: {
from: string;
to: string;
restoredBuffer: boolean;
source: string;
};
signal: { type: string; payload: SignalPayload };
spritechange: { from?: string; to: string; label: string };
stats: EngineStats;
timelinecomplete: { from: string; to: string };
transitioncomplete: TransitionDetail;
transitionqueued: { from?: string; to?: string };
}
export type JoanGlyphEventName = keyof JoanGlyphEventDetailMap;
export type JoanGlyphEvent<K extends JoanGlyphEventName> = CustomEvent<
JoanGlyphEventDetailMap[K]
>;
export interface ExportedJoanConfig {
package: "@joan/procedural-glyph-engine";
version: string;
sprite: string;
seed: string;
gridSize: number;
speed: number;
density: number;
contrast: number;
fps: number;
field?: FieldName;
fieldMode: "recipe" | "override";
pixelShape: PixelShape | null;
pixelSwitch: PixelSwitch | PixelSwitchRecipe | null;
transition: Transition | TransitionRecipe | null;
palette: Palette &
Required<Pick<Palette, "background" | "off" | "ink" | "accent" | "glow">>;
paletteOverride: Palette | null;
nonErrorPalette: Palette | null;
orbBoundary: OrbBoundary;
orbBackgroundColor: CSSColor | null;
orbBackgroundMode: OrbBackgroundMode;
progress: number;
background: boolean;
offPixels: boolean;
interactive: boolean;
autoplay: boolean;
autoResize: boolean;
dprMax: number;
quality: Quality;
reducedMotion: ReducedMotion;
direction: string | null;
targetGlyph: string | null;
}
export interface SvgExportOptions {
size?: number;
background?: boolean;
offPixels?: boolean;
}
export interface AnimatedSvgExportOptions extends SvgExportOptions {
duration?: number;
fps?: number;
maxGridSize?: number;
}
export interface GlyphSampleContext extends Record<string, unknown> {
progress?: number;
audioLevel?: number;
energy?: number;
elapsed?: number;
resolution?: number;
reducedMotion?: boolean;
orbBoundary?: OrbBoundary;
gestaltOpenness?: number;
}
export type GlyphSampler = (
x: number,
y: number,
time: number,
context: GlyphSampleContext,
) => number;
export type GlyphBitmap =
| ArrayLike<number>
| readonly (readonly number[])[];
export interface RegisterGlyphDimensions {
width?: number;
height?: number;
}
export interface LoadGlyphFileOptions {
id?: string;
label?: string;
baseSprite?: SpriteName;
resolution?: number;
transition?: TransitionName | TransitionRecipe;
}
export const TRANSITIONS: readonly Transition[];
export const PIXEL_SWITCHES: readonly PixelSwitch[];
export const PIXEL_SHAPES: readonly PixelShape[];
export const ORB_BACKGROUND_MODES: readonly OrbBackgroundMode[];
export const SPRITES: Readonly<Record<BuiltInSpriteId, Readonly<SpriteRecipe>>>;
export function getSprite(id: SpriteName): Readonly<SpriteRecipe> | undefined;
export function listSprites(): readonly Readonly<SpriteRecipe>[];
export function transitionDelay(
transition: TransitionName,
x: number,
y: number,
random?: number,
origin?: { x: number; y: number },
): number;
export class JoanGlyphEngine extends EventTarget {
static readonly sprites: typeof SPRITES;
static listSprites(): readonly Readonly<SpriteRecipe>[];
constructor(canvas: HTMLCanvasElement, options?: JoanEngineOptions);
readonly canvas: HTMLCanvasElement;
readonly context: CanvasRenderingContext2D;
options: JoanEngineOptions;
currentRecipe: SpriteRecipe;
recipeSource: SpriteRecipe;
previousRecipe: SpriteRecipe;
fieldOverride: FieldName | null;
gridSize: number;
seed: string;
running: boolean;
destroyed: boolean;
visible: boolean;
reducedMotion: boolean;
progress: number;
audioLevel: number;
clock: number;
stats: EngineStats;
readonly values: Float32Array;
readonly gates: Uint8Array;
readonly luminance: Float32Array;
on<K extends JoanGlyphEventName>(
type: K,
listener: (event: JoanGlyphEvent<K>) => void,
options?: boolean | AddEventListenerOptions,
): () => void;
addEventListener<K extends JoanGlyphEventName>(
type: K,
listener: (this: JoanGlyphEngine, event: JoanGlyphEvent<K>) => unknown,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
activate(options?: ActivateOptions): this;
resume(options?: ResumeOptions): this;
setSprite(sprite: SpriteName | SpriteRecipe, options?: SetSpriteOptions): this;
transitionTo(
sprite: SpriteName | SpriteRecipe,
options?: TransitionToOptions,
): Promise<TransitionDetail>;
whenTransitionComplete(options?: { signal?: AbortSignal }): Promise<TransitionDetail>;
setField(field: FieldName): this;
useRecipeFields(): this;
setPixelSwitch(pixelSwitch: PixelSwitchName | PixelSwitchRecipe): this;
setPixelShape(pixelShape: PixelShapeName): this;
setResolution(size: number): this;
setSeed(seed: string | number): this;
setProgress(value: number): this;
setAudioLevel(value: number): this;
setNonErrorPalette(
palette: Palette | null,
options?: { render?: boolean },
): this;
configure(
options?: Partial<JoanEngineOptions>,
configureOptions?: { strict?: boolean },
): this;
setOptions(options?: Partial<JoanEngineOptions>): this;
signal(type: string, payload?: SignalPayload): this;
registerGlyph(
id: string,
source: GlyphSampler | GlyphBitmap,
dimensions?: RegisterGlyphDimensions,
): this;
loadGlyphFile(file: Blob, options?: LoadGlyphFileOptions): Promise<string>;
play(): this;
pause(): this;
toggle(): this;
renderFrame(time?: number, dt?: number, force?: boolean): EngineStats;
renderOnce(time?: number): EngineStats;
inspect(): EngineInspection;
exportConfig(): ExportedJoanConfig;
toSVG(options?: SvgExportOptions): string;
toAnimatedSVG(options?: AnimatedSvgExportOptions): Promise<string>;
downloadPNG(filename?: string): Promise<Blob>;
downloadSVG(filename?: string, options?: SvgExportOptions): Promise<string>;
downloadAnimatedSVG(
filename?: string,
options?: AnimatedSvgExportOptions,
): Promise<string>;
toDataURL(type?: string, quality?: number): string;
toBlob(type?: string, quality?: number): Promise<Blob>;
destroy(): void;
}
export function createProceduralGlyph(
options: CreateProceduralGlyphOptions,
): JoanGlyphEngine;
export function createProceduralGlyph(
target: string | HTMLCanvasElement,
options?: JoanEngineOptions,
): JoanGlyphEngine;
export function createGlyph(
target: string | HTMLCanvasElement,
options?: JoanEngineOptions,
): JoanGlyphEngine;
export function mountProceduralGlyph(
target: string | HTMLCanvasElement,
options?: JoanEngineOptions,
): JoanGlyphEngine;
export default JoanGlyphEngine;

23
web/vendor/types/sprites.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
import type {
BuiltInSpriteId,
FieldId,
Palette,
SpriteAlias,
SpriteName,
SpriteRecipe,
} from "./index.js";
export const FIELD_IDS: readonly FieldId[];
export const PALETTES: Readonly<Record<string, Readonly<Palette>>>;
export const SPRITES: Readonly<
Record<BuiltInSpriteId, Readonly<SpriteRecipe>>
>;
export const SPRITE_IDS: readonly BuiltInSpriteId[];
export const SPRITE_ALIASES: Readonly<Record<SpriteAlias, BuiltInSpriteId>>;
export function resolveSpriteId(id: unknown): BuiltInSpriteId | undefined;
export function getSprite(id: SpriteName): Readonly<SpriteRecipe> | undefined;
export function listSprites(): readonly Readonly<SpriteRecipe>[];
export function hasSprite(id: unknown): boolean;
export default SPRITES;

81
web/vendor/types/state-director.d.ts vendored Normal file
View File

@@ -0,0 +1,81 @@
import type {
JoanGlyphEngine,
SetSpriteOptions,
SpriteName,
SpriteRecipe,
TransitionName,
} from "./index.js";
export type DirectorSprite = SpriteName | SpriteRecipe;
export interface StateDirectorOptions {
deepThinkingAfterMs?: number;
stillWorkingAfterMs?: number;
transition?: TransitionName;
}
export interface DirectorSetOptions extends SetSpriteOptions {
reason?: string;
sprite?: DirectorSprite;
}
export interface StateChangeDetail {
state: string;
from: string;
to: string;
reason: string;
}
export interface DirectorRunContext {
signal?: AbortSignal;
director: StateDirector;
engine: JoanGlyphEngine;
}
export interface DirectorRunOptions extends StateDirectorOptions {
signal?: AbortSignal;
thinking?: StateDirectorOptions & DirectorSetOptions;
complete?: DirectorSetOptions;
fail?: DirectorSetOptions;
cancel?: DirectorSetOptions;
successSprite?: DirectorSprite;
errorSprite?: DirectorSprite;
cancelledSprite?: DirectorSprite;
}
export class StateDirector extends EventTarget {
constructor(engine: JoanGlyphEngine, options?: StateDirectorOptions);
readonly engine: JoanGlyphEngine;
readonly options: Required<StateDirectorOptions>;
readonly timers: Set<ReturnType<typeof setTimeout>>;
state: string;
destroyed: boolean;
addEventListener(
type: "statechange",
listener: (this: StateDirector, event: CustomEvent<StateChangeDetail>) => unknown,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
clearTimers(): void;
set(sprite: DirectorSprite, options?: DirectorSetOptions): this;
beginThinking(options?: StateDirectorOptions & DirectorSetOptions): this;
complete(options?: DirectorSetOptions): this;
fail(options?: DirectorSetOptions): this;
cancel(options?: DirectorSetOptions): this;
reset(options?: DirectorSetOptions): this;
run<T>(
work:
| PromiseLike<T>
| ((context: DirectorRunContext) => T | PromiseLike<T>),
options?: DirectorRunOptions,
): Promise<T>;
destroy(): void;
}
export default StateDirector;

214
web/vendor/types/state-sequence.d.ts vendored Normal file
View File

@@ -0,0 +1,214 @@
import type {
SetSpriteOptions,
SpriteName,
SpriteRecipe,
TransitionDetail,
TransitionName,
TransitionToOptions,
} from "./index.js";
export const STATE_SEQUENCE_SCHEMA_VERSION: 1;
export type StateSequenceSprite = SpriteName | SpriteRecipe;
export type StateSequenceTransition =
| false
| TransitionName
| (SetSpriteOptions & { name?: TransitionName });
export interface StateSequenceStep {
sprite: StateSequenceSprite;
/** Time to keep the entered state before advancing, in milliseconds. */
holdMs?: number;
/** Compatibility alias for holdMs; normalized definitions use holdMs. */
durationMs?: number;
transition?: StateSequenceTransition;
options?: SetSpriteOptions;
label?: string;
metadata?: Readonly<Record<string, unknown>>;
}
export interface DefinedStateSequenceStep
extends Omit<StateSequenceStep, "durationMs" | "holdMs"> {
readonly holdMs: number;
}
export interface StateSequenceDefinition {
schemaVersion?: number;
name: string;
steps: readonly (StateSequenceStep | SpriteName)[];
transition?: StateSequenceTransition;
transitionFirst?: boolean;
metadata?: Readonly<Record<string, unknown>>;
}
export interface DefinedStateSequence {
readonly schemaVersion: 1;
readonly name: string;
readonly steps: readonly Readonly<DefinedStateSequenceStep>[];
readonly transition?: StateSequenceTransition;
readonly transitionFirst: boolean;
readonly metadata?: Readonly<Record<string, unknown>>;
}
export interface StateSequenceTarget {
currentRecipe?: { id?: string } | null;
setSprite(sprite: StateSequenceSprite, options?: SetSpriteOptions): unknown;
transitionTo?(
sprite: StateSequenceSprite,
options?: TransitionToOptions,
): PromiseLike<TransitionDetail | unknown> | TransitionDetail | unknown;
}
export interface StateSequenceClock {
now(): number;
setTimeout(callback: () => void, delayMs: number): unknown;
clearTimeout(timer: unknown): void;
}
export interface StateSequencePlayerOptions {
transition?: StateSequenceTransition;
transitionFirst?: boolean;
preservePhase?: boolean;
clock?: StateSequenceClock;
sequences?:
| readonly (DefinedStateSequence | StateSequenceDefinition)[]
| Readonly<
Record<
string,
| readonly (StateSequenceStep | SpriteName)[]
| StateSequenceDefinition
>
>;
}
export interface StateSequencePlayOptions {
name?: string;
signal?: AbortSignal;
transition?: StateSequenceTransition;
transitionFirst?: boolean;
preservePhase?: boolean;
}
export interface StateSequenceResult {
readonly runId: number;
readonly name: string;
readonly sequence: DefinedStateSequence;
readonly status: "completed" | "stopped";
readonly reason: unknown;
readonly completedSteps: number;
}
export interface StateSequenceEventDetail {
readonly runId: number;
readonly name: string;
readonly sequence: DefinedStateSequence;
readonly index: number;
readonly step?: Readonly<DefinedStateSequenceStep>;
readonly sprite?: string;
readonly from?: string | null;
readonly to?: string;
readonly state?: string;
readonly reason?: unknown;
readonly error?: unknown;
readonly totalSteps?: number;
readonly completedSteps?: number;
}
export type StateSequenceEventName =
| "sequencestart"
| "stepstart"
| "statechange"
| "stepenter"
| "stepcomplete"
| "sequencepause"
| "sequenceresume"
| "sequencestop"
| "sequencecancel"
| "sequencecomplete"
| "sequenceerror";
export function defineStateSequence(
name: string,
steps: readonly (StateSequenceStep | SpriteName)[],
options?: Omit<StateSequenceDefinition, "name" | "steps" | "schemaVersion">,
): DefinedStateSequence;
export function defineStateSequence(
definition: StateSequenceDefinition,
): DefinedStateSequence;
export class StateSequencePlayer extends EventTarget {
constructor(target: StateSequenceTarget, options?: StateSequencePlayerOptions);
readonly target: StateSequenceTarget;
readonly sequences: Map<string, DefinedStateSequence>;
readonly currentSequence: DefinedStateSequence | null;
readonly currentStepIndex: number;
readonly isPlaying: boolean;
readonly isPaused: boolean;
status: "idle" | "running" | "paused" | "destroyed";
state: string | null;
destroyed: boolean;
finished: Promise<StateSequenceResult | null>;
addEventListener(
type: StateSequenceEventName,
listener: (
this: StateSequencePlayer,
event: CustomEvent<StateSequenceEventDetail>,
) => unknown,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
register(
name: string,
steps: readonly (StateSequenceStep | SpriteName)[],
options?: Omit<StateSequenceDefinition, "name" | "steps" | "schemaVersion">,
): DefinedStateSequence;
register(
definition: DefinedStateSequence | StateSequenceDefinition,
): DefinedStateSequence;
unregister(name: string): boolean;
getSequence(name: string): DefinedStateSequence | null;
listSequences(): readonly DefinedStateSequence[];
play(
sequence:
| string
| DefinedStateSequence
| StateSequenceDefinition
| readonly (StateSequenceStep | SpriteName)[],
options?: StateSequencePlayOptions,
): Promise<StateSequenceResult>;
run(
sequence:
| string
| DefinedStateSequence
| StateSequenceDefinition
| readonly (StateSequenceStep | SpriteName)[],
options?: StateSequencePlayOptions,
): Promise<StateSequenceResult>;
/**
* Pauses the hold clock and step progression. It does not rewind or suspend
* a visual transition already running inside the target engine.
*/
pause(): this;
resume(): this;
stop(reason?: string): this;
destroy(): void;
}
export interface PlayStateSequenceOptions extends StateSequencePlayOptions {
clock?: StateSequenceClock;
metadata?: Readonly<Record<string, unknown>>;
}
export function playStateSequence(
target: StateSequenceTarget,
steps: readonly (StateSequenceStep | SpriteName)[],
options?: PlayStateSequenceOptions,
): StateSequencePlayer;
export default StateSequencePlayer;

View File

@@ -0,0 +1,6 @@
export {
JOAN_GLYPH_TAG_NAME,
JoanGlyphElement,
defineJoanGlyph,
} from "./web-component.js";
export { default } from "./web-component.js";

93
web/vendor/types/web-component.d.ts vendored Normal file
View File

@@ -0,0 +1,93 @@
import type {
ActivateOptions,
EngineStats,
EngineInspection,
ExportedJoanConfig,
FieldName,
JoanEngineOptions,
JoanGlyphEngine,
Palette,
PixelSwitchRecipe,
PixelShapeName,
PixelSwitchName,
ResumeOptions,
SetSpriteOptions,
SignalPayload,
SpriteName,
SpriteRecipe,
TransitionDetail,
TransitionToOptions,
JoanGlyphEventName,
JoanGlyphEvent,
} from "./index.js";
export const JOAN_GLYPH_TAG_NAME: "joan-glyph";
export class JoanGlyphElement extends HTMLElement {
static readonly observedAttributes: readonly string[];
readonly canvas: HTMLCanvasElement;
readonly engine: JoanGlyphEngine | null | undefined;
connectedCallback(): void;
disconnectedCallback(): void;
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null,
): void;
readOptions(): JoanEngineOptions;
readNonErrorPalette(): Palette | null;
setSprite(
sprite: SpriteName | SpriteRecipe,
options?: SetSpriteOptions,
): JoanGlyphEngine | undefined;
setProgress(value: number): JoanGlyphEngine | undefined;
setNonErrorPalette(palette: Palette | null): JoanGlyphEngine | undefined;
setSeed(seed: string | number): JoanGlyphEngine | undefined;
setResolution(size: number): JoanGlyphEngine | undefined;
setField(field: FieldName): JoanGlyphEngine | undefined;
useRecipeFields(): JoanGlyphEngine | undefined;
setPixelShape(shape: PixelShapeName): JoanGlyphEngine | undefined;
setPixelSwitch(
pixelSwitch: PixelSwitchName | PixelSwitchRecipe,
): JoanGlyphEngine | undefined;
setOptions(options: Partial<JoanEngineOptions>): JoanGlyphEngine | undefined;
configure(
options?: Partial<JoanEngineOptions>,
configureOptions?: { strict?: boolean },
): JoanGlyphEngine | undefined;
setAudioLevel(value: number): JoanGlyphEngine | undefined;
activate(options?: ActivateOptions): JoanGlyphEngine | undefined;
resume(options?: ResumeOptions): JoanGlyphEngine | undefined;
play(): JoanGlyphEngine | undefined;
pause(): JoanGlyphEngine | undefined;
toggle(): JoanGlyphEngine | undefined;
renderOnce(time?: number): EngineStats | undefined;
transitionTo(
sprite: SpriteName | SpriteRecipe,
options?: TransitionToOptions,
): Promise<TransitionDetail> | undefined;
whenTransitionComplete(options?: {
signal?: AbortSignal;
}): Promise<TransitionDetail> | undefined;
inspect(): EngineInspection | undefined;
on<K extends JoanGlyphEventName>(
type: K,
listener: (event: JoanGlyphEvent<K>) => void,
options?: boolean | AddEventListenerOptions,
): (() => void) | undefined;
exportConfig(): ExportedJoanConfig | undefined;
signal(type: string, payload?: SignalPayload): JoanGlyphEngine | undefined;
}
export function defineJoanGlyph(
tagName?: string,
): typeof JoanGlyphElement;
declare global {
interface HTMLElementTagNameMap {
"joan-glyph": JoanGlyphElement;
}
}
export default JoanGlyphElement;