Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .agents/skills/idevice/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ Creating or modifying interactive devices (iDevices) in `public/files/perm/idevi

**Reference iDevices** (well-tested, good to study): `checklist`, `rubric`, `geogebra-activity`

## TypeScript iDevices (`src/`)

An iDevice with a `src/` directory is a **TypeScript iDevice**: its
`edition/<name>.js` and `export/<name>.js` are GENERATED bundles (gitignored)
— never edit them; edit `src/` and rebuild. Convention and commands:

- `src/edition/index.ts` → `edition/<name>.js` (assigns `window.$exeDevice`);
`src/export/index.ts` → `export/<name>.js` (assigns the runtime global).
- Build/typecheck: `bun run bundle:idevices` / `bun run typecheck:idevices`
(central runner `scripts/build-idevices.ts`; `--only <name>`, `--watch`).
Run `make bundle` after src/ edits and BEFORE E2E, or the preview serves the
stale bundle from `public/bundles/idevices.zip`.
- Tests are colocated `*.spec.ts` (Vitest — `bun test` ignores `public/**`),
plus bundle-contract smoke tests over the compiled IIFEs.
- Deviations (custom bundle name, externals, minify) go in an optional
`build.config.json` — see `doc/development/idevices-typescript.md` and
ADR-2147-01. Reference implementations: `three-sixty-viewer` (full convention),
`slide` (manifest).

## Structure

```
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
- name: Build all assets once
run: bun run build:static

# The path list must include every GENERATED (gitignored) file the
# workarea serves — the test runners get a fresh checkout, so anything
# missing here 404s at runtime (e.g. TypeScript-iDevice bundles, ADR-2147-01).
- name: Upload dynamic bundles (chromium/firefox)
uses: actions/upload-artifact@v7
with:
Expand All @@ -64,6 +67,8 @@ jobs:
public/bundles/**
public/style/workarea/main.css
public/files/perm/idevices/base/slide/edition/slide-editor.bundle.js
public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.js
public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js

- name: Upload static distribution (static project)
uses: actions/upload-artifact@v7
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ public/app/dist/
/app/dist/
/app/node_modules/

# Slide iDevice — pre-built editor bundle (regenerated by package.json postinstall)
# TypeScript iDevice bundles — generated from each iDevice's src/ by scripts/build-idevices.ts
/public/files/perm/idevices/base/slide/edition/slide-editor.bundle.js
/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.js
/public/files/perm/idevices/base/three-sixty-viewer/edition/three-sixty-viewer.js.map
/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js
/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js.map
.omc/
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
---
id: ADR-2147-01
title: "TypeScript iDevices: src/ sources compiled by one convention-based build"
status: Proposed
date: 2026-07-30
tracking_issue: 2147
deciders:
- "@erseco"
reviewers:
- "@mnunezcedec"
- "@cristinavaldera"
related:
prs: [2147]
changes:
- "39-three-sixty-viewer-typescript-refactor"
adrs: []
supersedes: []
superseded_by: []
ai_assistance:
tool: "Claude Code"
model: "claude-fable-5"
---

# ADR-2147-01: TypeScript iDevices: src/ sources compiled by one convention-based build

## Context

iDevices are classic-script objects loaded by the workarea and the exporters.
Historically each one is hand-written vanilla JavaScript committed directly
under `edition/` and `export/`. Two iDevices now keep their maintained source
in TypeScript instead — Slide (`src/` + a bespoke `scripts/build-slide-editor.ts`)
and, with this refactor, the 360° Viewer. Per-iDevice build scripts duplicate
Bun plumbing and diverge in flags and behaviour, and every future TypeScript
iDevice would have added another copy plus more package.json entries.

## Problem

How does the repository recognise, build, type-check and test an iDevice whose
maintained source is TypeScript, without a new build pipeline per iDevice?

## Decision drivers

- One obvious convention for the next TypeScript iDevice (zero new scripts).
- The shipped output must remain plain classic-script IIFEs; the language and
compile step are not a framework.
- Generated artifacts must never be committed; a clean checkout must
regenerate them through the existing pipeline (`build:all` / `make bundle`).
- Existing iDevices with special needs (Slide) must fit without renaming their
shipped bundles.

## Decision

**An iDevice that keeps a `src/` directory is a TypeScript iDevice**, built by
the centralized `scripts/build-idevices.ts`:

- **Convention:** `src/edition/index.ts` → `edition/<name>.js` and
`src/export/index.ts` → `export/<name>.js` — self-contained IIFEs
(`target: browser`, linked source maps, unminified), whose entry points
explicitly assign their window globals (`$exeDevice`, `$<name>`).
- **Escape hatch:** an optional `build.config.json` next to `config.xml`
replaces the convention for that iDevice (custom entries/naming/globalName/
minify/sourcemap, plus `externals` mapping bare imports to page-provided
globals so vendored libraries are never inlined). Slide uses it.
- **Type checking:** each TypeScript iDevice ships its own `tsconfig.json`
(strict for new code); the runner executes `tsc -p` for every one it finds.
- **Tests:** colocated `*.spec.ts` next to each module, run by **Vitest**
(`bun test` ignores `public/**`), plus bundle-contract smoke tests that
evaluate the compiled IIFEs.
- **Artifacts:** generated bundles and source maps are gitignored;
`build:all` runs `typecheck:idevices` + `bundle:idevices` before
`bundle:resources` (export bundles ship inside `idevices.zip`).

Package scripts: `typecheck:idevices`, `bundle:idevices`,
`bundle:idevices:watch`; the runner accepts `--only <names>` and `--watch`.

## Options considered

### Option 1: One bespoke build script per TypeScript iDevice (status quo)

Pros: each script is trivially readable. Cons: duplicated plumbing, per-iDevice
package.json entries, drift between scripts (they already differed in
sourcemaps, watch support and failure reporting).

### Option 2: Convention-based central runner + per-iDevice manifest (chosen)

Pros: the next TypeScript iDevice needs no build changes at all; one place to
fix bundler behaviour; deviations are declared, not programmed. Cons: one more
convention to know; the manifest is a small new format (documented in the
runner header and `doc/development/idevices-typescript.md`).

## Consequences

### Positive

- Adding a TypeScript iDevice = create `src/edition|export/index.ts` (+ a
strict `tsconfig.json`); building, type-checking and watching come for free.
- Slide and the 360° Viewer share one build path; Slide's output stayed
byte-identical apart from the generic externals shim's message strings.

### Negative

- A hidden convention: `src/` now has meaning. Mitigated by this ADR,
`doc/development/idevices-typescript.md` and the idevice skill.

### Neutral

- Classic-script iDevices are untouched; nothing forces a migration.

## Validation

- `scripts/build-idevices.spec.ts` covers discovery, the convention, the
manifest and its validation against the real repository state.
- `bun run build:all` exercises typecheck + build for every TypeScript
iDevice on every bundle/test target.

## References

- `scripts/build-idevices.ts` (runner; manifest schema in its header).
- `doc/development/idevices-typescript.md` (developer guide).
- PR [#2147](https://github.com/exelearning/exelearning/pull/2147), which
introduced this convention upstream alongside the Interactive Video refactor.
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
tracking_issue: 39
title: "360° Viewer iDevice: TypeScript refactor on the centralized build convention"
status: implemented
date: 2026-07-30
authors:
- "@erseco"
reviewers: []
implementation_prs: [39]
related_adrs:
- ADR-2147-01
supersedes: []
superseded_by: []
ai_assistance:
tool: "Claude Code"
model: "Claude"
---

# 360° Viewer iDevice — TypeScript refactor design

## Summary

The 360° Viewer (`public/files/perm/idevices/base/three-sixty-viewer/`) moves
its maintained source from two hand-written classic scripts
(`edition/three-sixty-viewer.js`, `export/three-sixty-viewer.js`) to a typed,
modular `src/` tree compiled by the centralized TypeScript-iDevice build
([ADR-2147-01](../../adr/ADR-2147-01-typescript-idevices-build-convention.md)). The
generic conventions — discovery, bundling, typecheck, testing, gitignored
bundles — are documented in
[doc/development/idevices-typescript.md](../../../development/idevices-typescript.md);
this design records only what is specific to the 360° Viewer.

## Source architecture

```text
src/
├── globals.d.ts # THREE / eXeLearning / _ ambient declarations
├── shared/ # pure, DOM-free logic used by BOTH bundles
│ ├── types.ts # v1/v2 document model, hotspot-action union
│ ├── schema.ts # hydrateDocument / serializeDocument
│ ├── migration.ts # v1 → v2 lift
│ ├── normalization.ts# idempotent v2 normalization + defaults
│ ├── hotspot-actions.ts # per-action normalize/serialize/validate/repair
│ ├── geometry.ts # yaw/pitch ↔ direction, letterbox math, NDC
│ ├── ids.ts, urls.ts, html.ts
├── viewer/ # browser layer shared by preview and runtime
│ ├── panorama-renderer.ts, flat-image-renderer.ts, hotspot-renderer.ts
│ ├── scene-controller.ts, controls.ts, lifecycle.ts, assets.ts, types.ts
├── edition/ # window.$exeDevice (editor)
│ ├── index.ts, device.ts, editor.ts, state.ts, form.ts
│ ├── scene-list.ts, scene-editor.ts, hotspot-list.ts, hotspot-editor.ts
│ ├── hotspot-placement.ts, preview.ts, asset-picker.ts, three-loader.ts
├── export/ # window.$threesixtyviewer (learner runtime)
│ ├── index.ts, runtime.ts, renderer.ts, instance.ts, modal.ts, actions.ts
└── test/ # THREE mock harness, bundle-contract, fixtures
```

Before the refactor, edition and export each carried a full copy of the state
normalization and letterbox geometry ("mirror edition/three-sixty-viewer.js"
comments in the legacy bundles). `src/shared/` is now the single source of
truth for both.

## Persisted formats and compatibility

- **v1** (original single-image shape: top-level `src`, `alt`, `initialView`,
`autorotate`, `zoomEnabled`, `fullscreenEnabled`, `showNavControls`) is
never written any more but remains readable; `hydrateDocument()` lifts it
into a one-scene v2 tour with nothing lost. Detection mirrors the legacy
checks exactly.
- **v2** (`version: 2`, `ideviceId`, `startSceneId`, `scenes[]`, `behaviour`)
is unchanged by this refactor: same property names, same ranges, same enum
values, same hotspot actions (`goToScene`, `text`, `image`, `video`,
`link`). The persisted `version` property stays `version: 2`.
- **Future versions** (`version > 2`) are rejected explicitly
(`status: 'unsupported-version'`): the editor shows a notice and `save()`
returns the ORIGINAL payload untouched; the runtime renders an accessible
notice. Unknown hotspot ACTION types inside a v2 document are preserved as
`{ type: 'unsupported', originalType, originalPayload }` in memory and
serialized back verbatim — opening and saving old or future content never
destroys data.

## Runtime contracts

- `edition/three-sixty-viewer.js` (generated) assigns
`globalThis.$exeDevice` on every evaluation — the workarea re-runs the
script for each edit session. Contract: `init(element, previousData,
idevicePath)`, `save(): document | false`, `destroy()`.
- `export/three-sixty-viewer.js` (generated) assigns
`globalThis.$threesixtyviewer` with the JSON-iDevice engine API
(`renderView` / `renderBehaviour` / `init`) used by
`public/app/common/exe_export.js`.
- three.js and OrbitControls stay EXTERNAL vendored files
(`export/three.min.js`, `export/OrbitControls.js`, declared in
`config.xml`'s `<export-js>`). Neither bundle inlines them; the bundles
only dereference `THREE` when a viewer is actually built
(`renderBehaviour()` / preview construction), so bundle evaluation order
relative to the vendor scripts is not critical, and the editor lazy-loads
them (`edition/three-loader.ts`) for its preview. This is asserted by the
bundle-contract tests.

## Lifecycle

Every runtime viewer is one instance in a `WeakMap`-backed registry keyed by
its wrapper element. An instance owns its scene controller, panorama/flat
renderers, hotspot layer, nav/fullscreen controls, animation frame, resize
observer, drag blockers and modal; `destroy()` releases all of them (LIFO
disposer bag) and re-rendering a node disposes its predecessor first.
Multiple viewers per page never share state. The editor mirrors the same
pattern: one Editor per `init()`, destroyed on re-init.

## Hotspot placement

Direct placement is an additional authoring path next to list-based creation:
an explicit "Place hotspot by clicking" toggle (`aria-pressed`, visible hint,
aria-live announcements, Escape cancels), then one click on the preview.
Equirectangular scenes unproject the click through the camera to yaw/pitch
(`shared/geometry.ts` + `viewer/panorama-renderer.ts`); flat scenes convert
the click to percentages of the `object-fit: contain` rectangle, and clicks
on letterbox bars are ignored rather than snapped to an edge. Numeric fields
remain available for precise adjustment. Deleting a scene referenced by
`goToScene` hotspots asks for confirmation, states how many hotspots are
affected, and clears their targets deterministically (flagged inline until
retargeted).

## Testing

- Colocated `*.spec.ts` (Vitest, happy-dom) next to every module; three.js is
injected as a structural mock (`src/test/helpers.ts`), frames are stepped
manually.
- `src/test/bundle-contract.spec.ts` evaluates the real generated IIFEs and
asserts the window globals and their public APIs.
- `test/e2e/playwright/specs/idevices/three-sixty-viewer.spec.ts` covers the
authoring flows (scenes, hotspots, placement, persistence) and the bundle
contracts in a real browser.
- Fixtures for v1, v2, future-version and invalid payloads live in
`src/test/fixtures/`.

## ADRs required or referenced

- [ADR-2147-01](../../adr/ADR-2147-01-typescript-idevices-build-convention.md) —
TypeScript iDevices build convention (reused, no new durable decision
introduced by this refactor).
Loading
Loading