Demo surface hardening + SCORM contract completion + evidence & process. The permanent /demo
pages gain a real lifecycle (store-backed metadata, quota, caching, OG tags, embedding); the
generated packages gain mastery score in the manifest, overflow-safe suspend data, LOM metadata
and per-objective LMS reporting; the runtime probe now gates CI. All additive — existing specs
build unchanged.
Fixed — suspend_data v2 order fingerprint (Batch 3 / S5)
components/engine/scorm.jsencodeSuspend/_decodeV2: the v2 suspend_data envelope now embeds
a deterministic djb2 fingerprint of the screenorderarray. Previously, if a course package was
republished with screens reordered, inserted, or deleted, an old v2 payload would decode its
positional cursor/visited/results/ix indices against the new order and silently misattribute
progress to the wrong screens (wrong scores, wrong resume position). On decode, a fingerprint
mismatch (including append — decided as the simplest safe policy) now returns a fresh-start state
instead of corrupted data; v1 (legacy id-keyed JSON) migration is unaffected since it was already
immune to reorder by design.
Added — Accessibility conformance statement (Batch 3.2 / A1)
docs/ACCESSIBILITY-CONFORMANCE.md: WCAG 2.2 AA partial-conformance statement for produced course players — a 28-screen-type matrix (keyboard / screen reader / motion / time limits) grounded in verified code behavior, honest known limitations (no video captions/WebVTT, pointer-onlydrag_drop, non-adjustableterm_match_race/timer_seccountdowns, Lottie ignoring reduced-motion), test methodology (non-blocking axe CI audit, validator timer gate, alt-text lint), and RTL/language notes.
Added — CI: scorm-probe gate (Batch 3.1 / Y1)
tests/runtime/scorm-probe.mjs(the real-browser S1/S2/S3/S4 behavioural probe) is now wired into
CI via a new blockingscorm-probejob in.github/workflows/ci.yml— previously it existed only
as a localnpm run scorm-probescript and ran in nobody's CI. Unlikea11y-audit, this job is
NOTcontinue-on-error: a brokencmi.interactions/cmi.objectiveswrite now fails the build.- The probe script itself exits 0 both on "all checks passed" and on "environment unavailable"
(missing browser/Python) so local runs never fail spuriously. Since that skip must never happen
silently in CI, the workflow step greps the probe's own log for the skip marker and turns it into
an explicit::error::-annotated job failure if the environment came up unexpectedly broken —
verified locally by simulating both a broken interactions write (probe fails as expected) and a
forced environment-skip (job now fails loudly instead of passing green). .github/workflows/ci.ymlitself was newly added to this repo (it previously only existed in the
public mirror) with the sametest/lint/js-test/a11y-auditjobs, so the private dev repo now
gets the same push/PR gate as the public one, plus the newscorm-probejob.
Added — contribution & language policy (Batch 3.3 / Y4)
- New
CONTRIBUTING.md: dev setup + guidelines, plus a one-sentence language policy — code,
comments, commits, andCHANGELOG.mdare English;docs/and READMEs are multilingual, with
README.tr.mdtreated as first-class. CHANGELOG.mdconverted to a single language (English), including history: the previously
Turkish[Unreleased]entries (Batches 1 and 2) and the Turkish W10 subsection under[1.5.0]
were translated in place (structure, entries, and version headings preserved faithfully; no
content summarized or dropped). Quoted literal default strings that are actual runtime values
(e.g."Doğru!") were intentionally left untranslated since they document real code behavior.
Bulk translation of existing code comments is explicitly out of scope for this change.
Added — cmi.objectives goal reporting (Batch 2.4 / S2)
- Optional
objectives: list[Objective]onCourseSpec/Project(id required + machine-friendly
[A-Za-z0-9_.-]{1,255};description/success_criteriaoptional, authoring-only — no text is
printed into the package). Optionalobjective_ids: list[str]on ALL graded screen types
(14 QUIZ_TYPES). Unknown objective references and duplicate course objective ids are a HARD error
invalidate_project. - Single-source objective aggregation in
components/engine/scorm.js(S1 pattern: pure, DOM-free,
deterministic; vitest):aggregateObjectives(screen results + screen→objective map → per-objective
correct/total/scaled over the course),objectiveElements(1.2↔2004 element/vocabulary differences
in ONE place),objectiveIndices(collision-free, id-based indexing against records the LMS may
already hold). - The runtime writes at the SAME lifecycle point as the score commit (
evaluate()) —
1.2:cmi.objectives.n.id+.score.raw/min/max(0–100) +.status;
2004:.id+.score.scaled(0–1) +.success_status+.completion_status.
Score is only written once ≥1 answer exists; status vocabulary: never attempted →
not attempted/unknown, partially attempted →incomplete, fully attempted →passed/failed
(threshold = coursepassing_score, consistent with the S6 primaryObjective/minNormalizedMeasure). - POLICY: a cmi record is written only for objectives bound to ≥1 graded screen — an unbound
objective is an authoring error, caught by the newunbound_objectiveWARN inlint_course
(does not block the build). - Manifest (2004 only): course objectives are emitted after
imsss:primaryObjectiveas non-primary
imsss:objective/@objectiveIDentries (bound ones, in course order; no rollup/rule → inert on the
leaf,controlModestill absent). Whenpassing_scoreis 0, sequencing is not emitted at all
(2.1 contract) → objectives become runtime-only. Both versions validated against the official XSDs
(0 errors). If a 2004 LMS pre-populates these records, the runtime resolves indices by id and never
rewrites an existing.id(proven in scorm-probe with a fake-LMS pre-populate scenario). tests/runtime/scorm-probe.mjs: a 3-objective / 9-question course — verifies in a real browser
(1.2 + 2004) that 3 SEPARATE objective records reach the fake LMS, with correct scores
(33/0.3333) and correct vocabulary differences.
Added — LOM metadata (Batch 2.3 / S7)
- Optional
metadata: CourseMetadata | None = Nonefield onCourseSpec/Project(additive):
description,keywords: list[str],intended_audience,typical_learning_time(an ISO 8601
duration string, e.g."PT1H30M"— light regex validation; passed through
build_from_spec→Project.metadata). - Optional
imsmd:lomblock inside<metadata>:general/title(localizedlangstring, from
project.title) +general/languageare ALWAYS emitted (required/defaulted fields onProject—
a minimal-but-real LOM was preferred over an LOM-less manifest).general/description,
general/keyword(one per entry), andeducational/typicalLearningTime/datetimeare emitted ONLY
when the correspondingproject.metadatafield is set (no field → no element).intended_audience
was not mapped to LOM — forcing free text intoimsmd:intendedenduserrole, which requires a closed
vocabulary, would be the wrong semantics. - Both SCORM versions use the IMS Meta-data 1.2.4 binding (
imsmd_v1p2p4.xsd, namespace
http://www.imsglobal.org/xsd/imsmd_v1p2) — the "expected" official 1.2.1 binding for SCORM 1.2
(imsmd_rootv1p2p1.xsd) was DELIBERATELY not used: that schema definesgrp.anyinside
generalType/educationalType/… withnamespace="##any"(which also covers its own namespace) →
a UPA (Unique Particle Attribution) violation with neighbouring optional elements; libxml2 cannot
COMPILE that schema at all (it silently falls back toschema_unavailable, so XSD validation never
runs). The 1.2.4 binding (##other) is UPA-clean and actually compiles and validates.
runtime/schemas/ims_sources.json+driver_12.xsd/driver_2004.xsdupdated
(fetch+sha256-pinned, imsglobal.org — IMS/W3C schemas are not vendored). core/schema_validate.py: thexml.xsdimport-URL rewrite rule was extended (besides
/2001/xml.xsd, the/2001/03/xml.xsdvariant is now also mapped to the localxml.xsd— the
form used byimsmd_v1p2p4.xsd). Both cases validated against the official IMS XSDs (0 errors).
Added — suspend_data overflow safety (Batch 2.2 / S5)
- Compact v2 suspend_data encoding (
components/engine/scorm.js): base36 indices instead of screen
ids, avisitedhex bitfield, minimal results/ix — on a synthetic 64-screen / 30-graded-item
course the payload stays well under the SCORM 1.2 limit (4096). The old (v1 flat JSON) format is
recognized and migrated: courses already in production survive a resume after a package update.
On limit overflow, navigation history is dropped first;varsand interaction indices (ix) are
now PRESERVED (the v1 fallback used to drop both → re-answered questions could produce duplicate
interactions on the LMS). lint_course:suspend_size_riskWARN — for a 1.2 target, if the estimated suspend size (via a
conservative mirror of the encoder's cost model) exceeds the 4096×0.9 threshold, the author is
warned IN ADVANCE (to decide whether to split the course / move to 2004; not a FAIL).- Runtime visibility: suspend_data writes are now checked (
sSetChecked); on a write error or
truncation, aconsole.warn(once per event) +, if an xAPI forwarder is configured, a
suspend.troublestatement (suspend-kind/size/limit extensions). Never throws.
Added — passing grade in the manifest (Batch 2.1 / S6)
- SCORM 1.2: if the course
passing_scoreis non-zero,adlcp:masteryscore(0–100 integer scale)
is emitted under<item>, right aftertitle(per the imscpitemType:
title?, item*, metadata?, ##other). - SCORM 2004:
<adlcp:completionThreshold>+imsss:sequencing → imsss:objectives → imsss:primaryObjective → imsss:minNormalizedMeasure(0–1 scale,passing_score/100, e.g.0.8).
imsss:controlModewas NOT added ([6022] rationale still applies — flow/choice is meaningless on
a leaf item). - When
passing_scoreis 0 (or unset) → none of these elements are emitted in either version
(additive, backward compatible). Both cases validated against the official ADL/IMS XSDs (0 errors). - Fix (review Important-1): in both versions, emission was ADDITIONALLY gated on the course
having ≥1 graded screen (QUIZ_TYPES) — otherwise, with the defaultpassing_score=80,
content-only courses emittedmasteryscore/completionThreshold/sequencing even though the
runtime never wrotescore.raw, which on older 1.2 LMSs turned the mastery override from
"completed" into "failed".
Fixed — demo surface (Batch 1.1)
- The review UI (FAB/panel) is no longer rendered at all on
/demo/{slug}pages — previously it was
both visible AND broken (rToken()only resolved/preview/paths). Added an independent
review: bool = Falseflag torender_html; the two roles of__PREVIEW__(asset embedding vs.
review UI) were split apart./previewbehaviour is unchanged. Note: the package HTML changed at
the byte level — the dead (hidden, JS-disabled) review markup it used to contain is no longer
emitted at all; no behavioural difference.
Added — demo lifecycle (Batch 1.2)
- Demo metadata now lives in the store (
DemoMeta: slug, project, owner, title, language, size,
timestamps) — ownership is checked against the store, not the.ownerfile (legacy files are
honoured once and migrated). Demo HTML size is now included in the owner's quota accounting. - New
list_demos()(28th) andunpublish_demo(slug)(29th) MCP tools: inventory + removal
(ownership-checked; after removal/demo/{slug}→ 404).
Added — demo caching + sharing + embedding (Batch 1.3–1.5)
/demoresponses now carryETag+Cache-Control: public, max-age, must-revalidate; on a
matchingIf-None-Match, an empty-bodied304./preview(TTL-based) was deliberately left out
of scope.- OG/twitter meta tags (
og:title/description/type/url+twitter:card) — if a field is empty, the
tag is not emitted at all; an optionalcanonical_urlparameter was added torender_html
(additive). /demo/{slug}?embed=1chromeless embed mode — the same cached HTML, driven client-side via
body[data-embed="1"]CSS; aContent-Security-Policy: frame-ancestorsheader sourced from the
DEMO_FRAME_ANCESTORSenv var (default'self'), documented in.env.example.
Added — permanent demo links (W12)
- New
publish_demoMCP tool (27th tool) +GET /demo/{slug}route: publishes a project to a
TTL-free, permanent public URL (README/showcase links no longer break). Upsert; slug is locked to
its owner; the slug regex prevents path traversal.
Added — systematized visual storytelling (W11)
- Two new visual-density rules (WARN) in
lint_course:text_only_run(≥4 consecutive screens with
no visual) andvisual_poverty(<25% visual screens in an ≥8-screen course) — "walls of text" are
now machine-checked. - New
search_imagesMCP tool (26th tool): CC0/Public-Domain image search via Openverse/Wikimedia;
results carry license/author/source info, downloaded via the existingadd_asset. The query is
URL-encoded (no CC0 filter bypass), each result gets a per-item license-family check, and all API
requests go through SSRF protection.
Added — search_images (W11 part 2)
- New
search_images(query, source="openverse", limit=5)MCP tool wires the previously-dormant
Openverse/Wikimedia adapters into a real CC0/Public-Domain image search (26th MCP tool).
Candidate-only (no download); pick a result'surland pass it to the existing
add_asset(project_id, source=url, filename=...)to attach it (download + SSRF checks happen
there). Each result carries license/creator/source-page for attribution. OpenverseAdapter.search()/WikimediaAdapter.search()— new candidate-listing methods
alongside the existingfetch()(untouched); Wikimedia results are filtered to the PD/CC0
license family; both go throughassert_safe_urland degrade gracefully ([]) on error.