Skip to content

v1.6.0

Latest

Choose a tag to compare

@github-actions github-actions released this 26 Jul 15:46
· 60 commits to main since this release
e6ca958

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.js encodeSuspend/_decodeV2: the v2 suspend_data envelope now embeds
    a deterministic djb2 fingerprint of the screen order array. 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-only drag_drop, non-adjustable term_match_race/timer_sec countdowns, 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 blocking scorm-probe job in .github/workflows/ci.yml — previously it existed only
    as a local npm run scorm-probe script and ran in nobody's CI. Unlike a11y-audit, this job is
    NOT continue-on-error: a broken cmi.interactions/cmi.objectives write 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.yml itself was newly added to this repo (it previously only existed in the
    public mirror) with the same test/lint/js-test/a11y-audit jobs, so the private dev repo now
    gets the same push/PR gate as the public one, plus the new scorm-probe job.

Added — contribution & language policy (Batch 3.3 / Y4)

  • New CONTRIBUTING.md: dev setup + guidelines, plus a one-sentence language policy — code,
    comments, commits, and CHANGELOG.md are English; docs/ and READMEs are multilingual, with
    README.tr.md treated as first-class.
  • CHANGELOG.md converted 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] on CourseSpec/Project (id required + machine-friendly
    [A-Za-z0-9_.-]{1,255}; description/success_criteria optional, authoring-only — no text is
    printed into the package). Optional objective_ids: list[str] on ALL graded screen types
    (14 QUIZ_TYPES). Unknown objective references and duplicate course objective ids are a HARD error
    in validate_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 = course passing_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 new unbound_objective WARN in lint_course
    (does not block the build).
  • Manifest (2004 only): course objectives are emitted after imsss:primaryObjective as non-primary
    imsss:objective/@objectiveID entries (bound ones, in course order; no rollup/rule → inert on the
    leaf, controlMode still absent). When passing_score is 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 = None field on CourseSpec/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_specProject.metadata).
  • Optional imsmd:lom block inside <metadata>: general/title (localized langstring, from
    project.title) + general/language are ALWAYS emitted (required/defaulted fields on Project
    a minimal-but-real LOM was preferred over an LOM-less manifest). general/description,
    general/keyword (one per entry), and educational/typicalLearningTime/datetime are emitted ONLY
    when the corresponding project.metadata field is set (no field → no element). intended_audience
    was not mapped to LOM — forcing free text into imsmd: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 defines grp.any inside
    generalType/educationalType/… with namespace="##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 to schema_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.xsd updated
    (fetch+sha256-pinned, imsglobal.org — IMS/W3C schemas are not vendored).
  • core/schema_validate.py: the xml.xsd import-URL rewrite rule was extended (besides
    /2001/xml.xsd, the /2001/03/xml.xsd variant is now also mapped to the local xml.xsd — the
    form used by imsmd_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, a visited hex 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; vars and 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_risk WARN — 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, a console.warn (once per event) +, if an xAPI forwarder is configured, a
    suspend.trouble statement (suspend-kind/size/limit extensions). Never throws.

Added — passing grade in the manifest (Batch 2.1 / S6)

  • SCORM 1.2: if the course passing_score is non-zero, adlcp:masteryscore (0–100 integer scale)
    is emitted under <item>, right after title (per the imscp itemType:
    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:controlMode was NOT added ([6022] rationale still applies — flow/choice is meaningless on
    a leaf item).
  • When passing_score is 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 default passing_score=80,
    content-only courses emitted masteryscore/completionThreshold/sequencing even though the
    runtime never wrote score.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 = False flag to render_html; the two roles of __PREVIEW__ (asset embedding vs.
    review UI) were split apart. /preview behaviour 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 .owner file (legacy files are
    honoured once and migrated). Demo HTML size is now included in the owner's quota accounting.
  • New list_demos() (28th) and unpublish_demo(slug) (29th) MCP tools: inventory + removal
    (ownership-checked; after removal /demo/{slug} → 404).

Added — demo caching + sharing + embedding (Batch 1.3–1.5)

  • /demo responses now carry ETag + Cache-Control: public, max-age, must-revalidate; on a
    matching If-None-Match, an empty-bodied 304. /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 optional canonical_url parameter was added to render_html
    (additive).
  • /demo/{slug}?embed=1 chromeless embed mode — the same cached HTML, driven client-side via
    body[data-embed="1"] CSS; a Content-Security-Policy: frame-ancestors header sourced from the
    DEMO_FRAME_ANCESTORS env var (default 'self'), documented in .env.example.

Added — permanent demo links (W12)

  • New publish_demo MCP 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) and visual_poverty (<25% visual screens in an ≥8-screen course) — "walls of text" are
    now machine-checked.
  • New search_images MCP tool (26th tool): CC0/Public-Domain image search via Openverse/Wikimedia;
    results carry license/author/source info, downloaded via the existing add_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's url and 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 existing fetch() (untouched); Wikimedia results are filtered to the PD/CC0
    license family; both go through assert_safe_url and degrade gracefully ([]) on error.