npm.io
0.1.0 • Published 7h ago

@cuestack/core

Licence
MIT
Version
0.1.0
Deps
1
Size
382 kB
Vulns
0
Weekly
0

@cuestack/core

The headless kernel. Computes what a slide looks like at a given time; renders nothing, reads no clock, touches no DOM. Zero runtime dependencies beyond @cuestack/schema, whose types are erased at compile time.

resolve

import { resolve } from '@cuestack/core'

const state = resolve(slide, 2500)
// state.elements — visible elements, already in paint order
// state.problems — non-fatal findings, e.g. content past the slide's end
// state.blocked  — set when the slide cannot meaningfully be played

resolve is a fold, not a state machine. Every effect active at the given time is evaluated and the results composed; nothing accumulates between calls. That one property is why:

  • Seeking is correct. There is no replay path — a time is just an argument.
  • Server rendering works. resolve(slide, 0) needs no browser and no clock.
  • Preview and playback cannot diverge. One function, no memory, so arriving by a different route cannot produce a different answer. Proven by a sweep over every state-change boundary in the test corpus.

elements arrives pre-sorted. Do not re-sort it: two consumers sorting independently is two chances to sort differently.

transform is kept separate from geometry. An element translated 40px by a slide-in is still authored where it was — the editor needs the authored value, the player needs the effective one, and collapsing them loses that distinction irrecoverably.

Transport

const transport = createTransport(lesson, ports)
transport.play()
transport.seek(4000)      // returns the resulting snapshot, synchronously
transport.subscribe((snapshot) => { /* already committed when called */ })

Lesson time accumulates from an injected source and is clamped at 250ms per tick. A larger gap is treated as time that did not happen in the lesson — which covers machine sleep, a blocked main thread, and a paused debugger identically. While the host document is hidden, time does not advance at all.

Advancement

const controller = createAdvanceController(ports)
const decision = controller.evaluate(slide, transport, signals)
if (decision) transport.goToSlide(transport.slideIndex + 1)

evaluate is a query, not a command: it decides nothing about performing the advance. That lets a test assert the decision without a transport, and lets an editor show "would advance now" without advancing.

A slide instance advances at most once. The guard keys on instance, not slide id, so a learner navigating backward can replay a slide and advance again.

controller.reachability(slide) reports a rule that can never be satisfied — a failed video, a required question that disappears before the slide ends. Without it, a stalled slide and a deliberately-manual slide look identical.

Registries

New element and effect types are added by registration; the resolution path contains no knowledge of any specific type, and a lint rule forbids dispatching on a type discriminant outside a registry.

const elements = createElementRegistry([myPlugin])
const effects = createEffectRegistry([...builtinEffects, myEffect])
resolve(slide, t, { elements, effects, theme })

A plugin must supply its full contract — schema, resolve, inspector, validate, and the RenderState version it targets — or registration is refused with the missing member named. A plugin receives only its own payload, geometry, the slide time, and the theme. Never the lesson, its siblings, the transport, or anything about the learner.

An effect declares its parameters

EffectDescriptor.parameters is a list of InspectorField, so a consumer can offer what an effect accepts rather than keeping a table of its own — which would be a per-effect branch by another name and would rot the first time a ninth effect registered.

export const shimmer: EffectDescriptor = {
  type: 'shimmer',
  phases: ['emphasis'],
  motion: false,
  defaultEasing: 'linear',
  parameters: [{ key: 'intensity', label: 'Intensity', kind: 'number' }],
  at: (progress, params) => ({ opacity: 1 - (Number(params?.intensity) || 0.5) * progress }),
}

One difference from an element's inspector fields, and it is load-bearing. On an element a key is a dotted path from the element root (payload.text); on an effect it is a flat key into effect.parameters (intensity). Sharing the type must not become sharing the read.

The declaration says what may be set — it is not a source of defaults. at() keeps its own, because it is called per frame on a server where parameters may be absent.

Whichever registry the editor offers from must be the one resolve uses. A registry reaching a menu but not ResolveContext.effects produces an effect a teacher can add and the resolver reports as UNKNOWN_EFFECT_TYPE.

An unregistered optional type degrades to a placeholder and the slide still resolves. An unregistered required interaction type blocks. The asymmetry is deliberate: losing a decoration costs some content, while silently skipping a question that gates progression strands the learner.

Ports the host must supply

The complete list of things the kernel cannot do itself:

Port Direction Supplies
time read monotonic milliseconds
media read only position, duration, ended, paused, failed
visibility read + subscribe whether the document is hidden
storage read/write lesson load and save, with a conflict token
assets read asset id to location
analytics write event recording

memoryAdapters() implements the last three so the framework works with no host code at all. Its storage issues real tokens and genuinely rejects stale saves, so the conflict path is exercised by default rather than only by the first real host.

What this package will never do

Render, read a clock, touch the DOM, validate a manifest, or decide about reduced motion. It reports which effects are motion and stops there — the preference cannot be read on a server and the substitution is a stylesheet concern.

The storage boundary, extended (feature 008)

StorageAdapter gained three things when ED-5 became its first consumer. All additive; nothing in the lesson manifest changed, so no schemaVersion bump follows.

A save may declare itself a checkpoint.

saveDraft(lessonId, manifest, token, { checkpoint: { label } })

Every save advances the token, checkpoint or not — a conflict cannot be detected otherwise. Only a checkpoint adds an entry to listVersions. A save that records no checkpoint still persists: it is absent from the history, not absent from storage. An adapter treating one as a no-op would pass every history test and lose an hour of work.

VersionSummary is now VersionEntry, carrying recordedAt (epoch milliseconds, stamped by you — your storage is the only participant with an authoritative clock) and an optional label. listVersions returns checkpoints, not saves: with autosave firing every 1.5 seconds of idle, one entry per save is not a history anybody can read.

loadVersion(lessonId, token) returns an earlier version's content. Note the token it returns: the current draft's, not the loaded version's. What comes back is content to be saved forward as a new version, and returning the old token would make the very next save look like a conflict. That single rule is why restoring is additive rather than destructive.

New ports, declared here and implemented in @cuestack/react: Scheduler (deferred execution) and Connectivity (the network signal). Neither joins Ports — playback defers nothing and does not care whether the network is up — and both are the first ports here with no consumer inside core, which follows from core being the contract package.

The publishing boundary (feature 009)

The fourth adapter, and the first thing this framework produces that has no edit path at all.

publish(lessonId, manifest, by)      -> PublishResult
listPublished(lessonId)              -> readonly PublishedVersion[]   // newest first
loadPublished(lessonId, versionId?)  -> LoadPublishedResult
withdraw(lessonId, by)               -> ActionResult
restore(lessonId, by)                -> ActionResult
readRecord(lessonId)                 -> readonly RecordEntry[]

What it deliberately lacks is the interface. There is no update, no delete, no route to edit the record, and no arbitrary setActive. BR-008 says a published version is never modified, and a rule expressed as a guard is a rule some adapter forgets: expressed as an absence, a host implementing this interface has nowhere to put such a route even if it wants one. If a version is wrong, publish another and withdraw this one — both leave the wrong one on the record, which is the point.

Versions are deeply frozen on read, and the draft never is. The object handed out is the one a renderer might mutate, and this framework ships a renderer that takes manifests. Affordable here in a way it would not be for a draft: a published version is read rarely, a draft is resolved sixty times a second.

The active pointer is a property of the lesson, not a field on a version. That is what lets withdrawal change availability without touching anything immutable — withdraw clears the pointer and deletes nothing, so loadPublished answers withdrawn rather than not found, and restore puts the pointer back without creating a version.

publishedAt is the host's clock, following the rule ED-5 set for checkpoints: your storage is the only participant with an authoritative clock, and the studio may not read one at all. schemaVersion is recorded so it can be honoured, never upgraded — bringing a published version forward would change what a learner receives.

Validation (feature 009)

checkLesson(manifest, { elements, effects, policy }) answers a different question from @cuestack/schema's validate. That one asks is this structurally a lesson; this one asks is this a lesson worth giving to a learner — dead ends, unreachable slides, missing alt text, elements outside their slide.

The test for which side a new rule belongs on: could a well-formed lesson fail it? If yes it is semantic and belongs here; if a manifest failing it could not be loaded at all, it belongs in the schema.

It composes rather than checks. validate, checkReachability, collectProblems, resolveElement's unknown-type reporting, each type's own ElementPlugin.validate, and one rule of its own — the static dead end, which lives in interactions/policy.ts beside the runtime predicate it mirrors. Pure, deterministic, and complete in one pass.

The asset check is separate and async on purpose. collectAssetRefs is pure and shared by both the warning pass and the publish check; checkAssets is the round trip, and a caller that cannot afford it skips it and still gets every other issue.

builtinElements registers the seven MVP types. Their resolve is inert — { visible: true }, exactly what the resolver already did with no plugin — so registering them changes nothing a learner sees. Note the consequence: resolve treats an empty registry as "every type is known", so with a non-empty default an unregistered type is now reported. A host adding one composes createElementRegistry([...builtinElements, mine]); a registry holding only a custom plugin reports all seven MVP types as unknown.

Portable packages (feature 010)

exportLesson(manifest, { kind }) produces one JSON document. readPackage(text) reads one back, and importLesson(read, { lessonId, assets, elements }) turns it into a lesson.

The format is fixed by the framework, which is unlike every other boundary here. Storage, assets, analytics, and publishing are all interfaces a host implements however it likes; a package is not, because a format each host serialized its own way would be portable within a system and nowhere else — the lock-in §7.7 exists to prevent, arriving one layer down.

Two asset modes, and the default is the harmless one. Reference mode names the assets and is pure: no network, no waiting, no provider argument to supply. Files mode carries the bytes, is asynchronous, and fails loudly when content cannot be obtained — a package silently missing one image is worse than no package. The document says which mode produced it, so a reader can never mistake one for the other.

The framework never fetches. AssetAdapter.resolve returns an address, not bytes. Files-mode export takes a content provider from the caller, because the host is the only participant that can reach its own assets — behind a signed URL, on a server's filesystem, or already in memory.

Bytes cross both boundaries as Uint8Array. Base64 exists inside the document and nowhere else; a caller that had to encode would be reimplementing half the format in order to use it.

Import produces a lesson and stores nothing. This is the point a host will otherwise learn the hard way: the caller saves the result through the path it already uses, so there is exactly one route by which a lesson reaches storage and exactly one place conflict, offline, and acknowledgement are handled. "A failed import leaves nothing behind" follows from that rather than being arranged.

Reading is separable from producing. readPackage yields the versions, the kind, and the asset content as decoded bytes; importLesson then takes the identity you minted and a map from the package's asset ids to the ones you stored them under. That ordering is what makes "a lesson referencing an asset that was never stored" unreachable by ordinary use — you meet a storage failure before you have a lesson to save.

Pass your element registry to importLesson if you have one. A supplied registry replaces the default rather than extending it, so compose [...builtinElements, mine]; without it your plugins' own validate never contributes to the issues an import reports.

A round trip preserves the lesson, not its key order. importLesson delegates to migrate, which ends with validate, and the schema rebuilds objects in its own declared field order. So export → import → export produces a document with the same data and possibly a different byte sequence. Two exports of one lesson object are byte-identical; an export of a round-tripped lesson is normalised. Worth knowing before you diff two packages.

Import is hardened, and the boundary is stated. Size is checked before parsing, depth immediately after, and address-bearing fields are restricted to schemes that cannot execute. Asset content is not inspected and markup is not rewritten — this package renders nothing, so it would be sanitizing against a renderer it has to guess at.

Keywords