npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@creaditor/cdtr-course-builder

v0.5.0

Published

Embeddable <cdtr-course-builder> web component. Wraps CourseCore + CourseBuilder + <course-sidebar> + <lesson-settings> behind a host-owned-data event contract. No networking: the host answers reads and persists writes.

Readme

<cdtr-course-builder>

An embeddable course builder: a curriculum sidebar, a lesson-settings panel and a content editor, wrapped behind a host-owned-data event contract.

The component holds no database and opens no network connection. It asks, and your application answers:

That claim is about course data, and it is unchanged: your course never leaves your listeners, and the built bundle contains zero occurrences of fetch, XMLHttpRequest, EventSource, WebSocket or sendBeacon. The one feature that reaches a server — AI generation — still issues no request from this component: it writes to an in-process signal bus, and the actual call is made by a sibling element's iframe. See What generation does to the network claim.

<cdtr-course-builder course-id="1"></cdtr-course-builder>

<script>
  const b = document.querySelector('cdtr-course-builder');

  b.addEventListener('get-lesson-data', async (e) => {
    const lesson = await myBackend.fetchLesson(e.detail.id);
    b.loadLessonData(lesson);          // { id, title, settings, content }
  });

  b.addEventListener('save-lesson-data', (e) => {
    myBackend.saveLesson(e.detail);
  });

  b.addEventListener('course-builder-ready', () => { /* … */ });
</script>

Your course data stays in your database, in your shape, under your control. Nothing is persisted anywhere else, and nothing but your own listeners ever sees it.


The event contract

Four events in, two methods on the element, two writes out, two failures, one lifecycle event. This is a fixed public API.

Reads — you answer

| Event | Payload | Fires | |---|---|---| | get-course-data | { id } | Once, on connect | | get-lesson-data | { id } | On every lesson selection |

Answer them with the two methods:

| Method | Argument | |---|---| | loadCourseData(course) | { id, title, sections } — CourseCore JSON | | loadLessonData(lesson) | { id, title, settings, content } |

loadCourseData() takes exactly the shape POST /api/course/generate returns, so generation output pipes straight in with no mapping layer.

Writes — you persist

| Event | Payload | Timing | |---|---|---| | save-course-structure | { id, title, sections } — no lesson doc | 800 ms trailing debounce | | save-lesson-data | { id, title, settings, content } | Immediate, undebounced |

Failures

| Event | Payload | Fires | |---|---|---| | course-load-failed | { id, reason: 'timeout' } | 10 s after an unanswered get-course-data | | lesson-load-failed | { id, reason: 'timeout' } | 10 s after an unanswered get-lesson-data |

Lifecycle

| Event | Fires | |---|---| | course-builder-ready | After loadCourseData() is accepted and the sidebar has painted |

Not on connectedCallback. If you wire a "generate" action to the ready event, it must not fire against an empty course.

All events are bubbles: true, composed: true, so you can delegate from a container element instead of binding to each builder instance.


The rules a creator sets, and which are yours to enforce

Every one of these is stored and never enforced. The builder is an authoring surface: it records what the creator decided and hands it to you. Nothing here locks a lesson, blocks a learner or refuses a click — your player does that, or it does not happen.

This is the same division as DEC-host-owns-course-data, applied to rules rather than content, and it is deliberate: a component that enforced gating would need to know who the learner is and what they have finished, and those are yours.

Each lesson in save-course-structure carries these fields. Read them, and decide what your player does:

| Field | Shape | What the creator meant | If you ignore it | |---|---|---|---| | status | "draft" | "published" | A draft is not finished | Learners see unfinished lessons | | access | "free" | "locked" | Free is a preview; locked needs enrolment | Your paid course is readable by anyone | | prerequisiteId | lesson id | null | Finish that lesson before this one opens | Learners skip ahead | | drip | { days } | null | Release this many days after enrolment | The whole course opens on day one | | graded | boolean | This lesson counts toward finishing | Completion and certificates are wrong | | mustPass | boolean | Passing this gates the next lesson | A final exam stops nobody | | type | "lesson" | "quiz" | "video" | What the lesson is made of | Cosmetic — picks an icon |

graded and mustPass are separate questions and both are needed. graded asks whether a lesson COUNTS toward the total; mustPass asks whether it BLOCKS the road. Most graded lessons add to a score without stopping anyone; a final exam does both.

mustPass is stored even while graded is false, where it means nothing — a lesson that counts for nothing has nothing to pass. The authoring UI disables the control in that state, and the pair is kept so that ungrading and regrading a lesson returns the creator's choice instead of silently discarding it. When enforcing, treat mustPass as live only when graded is also true.

Enforcing mustPass

Two halves, from two components. The rule arrives on the lesson in save-course-structure; the result arrives from <cdtr-quiz-player> as quiz-completed, whose payload carries passed (true, false, or null when the quiz has no pass mark set) alongside percent and passingGrade.

Store passed against the learner and the lesson. Before opening the next lesson, check that the previous one either was not mustPass, or was passed.

Attempts are not counted, and "one attempt only" is not enforced. Within a mounted player each question is answered once and quiz-completed fires once — but a reload starts a fresh attempt, and nothing stands in the way. The player cannot hold that rule, because only you know who is watching. If a course needs a single attempt, record the first result and refuse to mount the quiz again.


The stored quiz shape

A quiz is an element inside a lesson's page, carried by the save-lesson-data.content you already persist. There is no new event, no new top-level field, and settings still holds exactly its four keys. A host integrates quizzes by changing nothing.

The shape below is documented because it is the boundary between what this builder authors and what your application renders to learners. Once it is on npm, the alternative is that every host reverse-engineers it from a console dump — and the first change breaks all of them.

The element node

content is the flat node array described under The blank document. A quiz is one node in it, with type: "quiz":

{
  "id": "quiz-0",
  "_id": "quiz-0",
  "type": "quiz",
  "props": {
    "questions": [
      {
        "id": "q_48c9574f",
        "text": "What is the capital of France?",
        "options": [
          { "id": "o_959bda38", "text": "Lyon", "correct": false },
          { "id": "o_178416ec", "text": "Paris", "correct": true },
          { "id": "o_855cfa0f", "text": "Marseille", "correct": false }
        ]
      }
    ],
    "displayMode": "all-at-once",
    "passingGrade": 70,
    "intro": "Read carefully — you get one attempt.",
    "showAnswers": true
  },
  "children": [],
  "elements": []
}

id/_id/children/elements are the editor's own node envelope and behave exactly as they do for every other element. props.questions is the quiz contract, and it is the part to write code against:

| Key | | | |---|---|---| | questions[] | array | One entry per question, in author order | | questions[].id | string | Stable for the life of the question | | questions[].text | string | The question, as the creator typed it | | questions[].options[] | array | The choices, in author order | | questions[].options[].id | string | Stable for the life of the option | | questions[].options[].text | string | The choice, as the creator typed it | | questions[].options[].correct | boolean | Whether this choice is the right answer | | displayMode | string | "all-at-once" or "one-at-a-time" — how the learner sees the quiz | | passingGrade | number | null | The percentage needed to pass, or null for no pass mark | | intro | string | Instructions shown before the questions. "" for none | | showAnswers | boolean | false hides the right answer after a wrong one. Defaults to true |

A question the learner writes

A question carrying kind: "open" has no options and no correct answer — the learner types a reply instead of picking one.

{ "id": "q_9f2", "text": "Why did you choose that shutter speed?", "kind": "open", "options": [] }

It is never graded, and that is deliberate. The typed text reaches you on quiz-completed as text on that question's entry in answers, and the question is excluded from gradable — so it cannot move the score or the pass mark. Marking free text by comparison only appears to work: "light sensitivity" and "sensitivity to light" are the same answer and would score differently.

If you want these marked, that is yours to do with the text you receive.

displayMode is a layout choice the creator makes with the toggle above the questions. It says nothing about grading and nothing about when feedback appears; both modes still check one question at a time. @creaditor/cdtr-quiz-player reads it from its display-mode attribute, and anything unrecognised — including the key being absent, as it is on every quiz authored before this existed — means all-at-once. Resolve it the same way if you render quizzes yourself: an unreadable value showing the whole quiz is a quiz that works, while one windowed to a single question looks like content that went missing.

intro is what the creator writes in the box above the questions. It belongs to the quiz rather than to the lesson around it, because a paragraph placed above the quiz never reaches the learner — the host renders lesson content, and the drop-ins render the quiz. @creaditor/cdtr-quiz-player opens on it with a Start button; a host writing its own renderer is free to draw it however it likes, or not at all.

passingGrade is the percentage a learner must reach for the quiz to say they passed, set in the field above the questions. null is a real setting, not a missing one: it means this quiz reports a score and passes no judgement, which is what a practice quiz wants. @creaditor/cdtr-quiz-player reads it from its passing-grade attribute and shows a pass/fail verdict only when there is a mark to judge against, the attempt is complete, and something in it was gradable.

Two things to match if you render quizzes yourself. A value out of 0–100 means no pass mark rather than the nearest end of the range — clamping a typed 150 to 100 would silently change what the creator meant. And blank is not zero: Number("") is 0, so a reader that skips the blank check turns an empty setting into a 0% pass mark and passes every learner alive.

It is a percentage rather than a question count on purpose. A creator sets it once and keeps editing the quiz: 70 survives a seventh question being added, where "5 of 6" quietly becomes wrong.

What a well-formed question satisfies

  • Between 2 and 6 options inclusive.
  • At least one option with correct: true. Two or more is a "choose all that apply" question, which the player grades against the whole set; zero is the only invalid count, and it is the one thing that blocks publishing the lesson, because a question with no right answer cannot be graded at all.
  • Non-blank text on the question and on every option. Whitespace-only input is stored as "", never as a run of spaces — so a blank is always one value, and you never have to decide whether " " counts as authored.

The builder enforces all three on authoring transitions and flags a question that breaks any of them as incomplete on the canvas. It does not enforce them on data it did not write. If something else in your stack produced a quiz, or an older version of this package did, you can be handed a question with seven options, or with none marked correct. The builder renders exactly what it was given rather than silently repairing it — opening a lesson never rewrites its stored content — so a renderer should degrade rather than assume. Treat the three rules as what a creator is guided to produce, not as a schema you may skip validating.

Two warnings you would otherwise learn the hard way

text is arbitrary creator-authored content. It is a plain string that may contain anything a person can type, including <script> and <img onerror=…>. The builder stores it verbatim and never turns it into markup on its own surface. A host rendering it to a learner must escape it, exactly as it would any other rich-text field — an injection authored here is a stored one until you do.

The correct flags are in the payload. They have to be: the right answer is authored content, and this is where it is stored. But that means a host that ships the whole lesson document to a learner's browser before grading has shipped them the answer key. Strip correct server-side for any learner-facing response, or keep the grading on the server.

What this milestone does and does not do

Authoring only. The builder stores the correct answer because it is authored content; it evaluates nothing — no scoring, no student answers, no submissions, no gradebook. A host that wants those builds them against the shape above.

A quiz lesson may also carry ordinary page content alongside its questions — a paragraph of context, an image. A renderer must not assume a quiz lesson's document contains only a quiz element, and a document may hold more than one.


Why 800 ms, and why 10 s

800 ms trailing debounce on structure. A lesson doc is a full page tree. Resending every doc because someone renamed a section is the difference between a snappy builder and a slow one. 800 ms is long enough to collapse a drag-reorder into one write, short enough that a host saving on every event still feels live. It is trailing, and the window restarts on each mutation, so eleven rapid changes produce exactly one save — carrying the final state, not a replay of stale intermediate ones.

10 s read timeout. loadCourseData() and loadLessonData() are called back whenever you feel like it, including never. Without a bound, a stuck spinner is the default outcome of any host-side bug, and the failure looks like ours rather than yours.

Which mutations debounce

Eleven structural topics coalesce into one save-course-structure:

course:renamed     section:created   section:removed   section:renamed
section:moved      lesson:created    lesson:removed    lesson:renamed
lesson:moved       settings:changed  lesson:selected

content:changed is the twelfth topic and the only one routed to save-lesson-data, because it is the only one carrying a page tree.


Five behaviours you cannot infer from the event names

These are the points where the contract was silent and the implementation had to choose. Each is deliberate.

1. createEditor is host-supplied, and the component works without it. The editor lives in your application, not in this package. Most hosts want the Creaditor editor, so a ready-made factory ships alongside the component — see The default editor:

import { createDefaultEditor } from '@creaditor/cdtr-course-builder';

b.createEditor = createDefaultEditor({ bundleUrl: '/vendor/creaditor.bundle.js' });

Supplying your own is still the supported path — the factory is handed a target, the lesson's page and an onSave, and returns anything with editor.setContent(nodes).

Omit it entirely and the component still loads courses, paints the sidebar, fires ready and emits structure saves — only content editing is unavailable, and the editor pane renders an empty state. Structure editing is fully functional.

2. A course answered without an id falls back to the course-id attribute. The engine mints an id when one is absent, and every subsequent save-course-structure would then carry an id you never issued. Answering with a different id is taken at its word, and every later save carries that answered id — so if you re-key a course mid-session, that is a deliberate act, not a bug.

3. lesson.title in a read answer is not applied. Titles are structure, owned by loadCourseData. Applying a title here would emit lesson:renamed straight back out as a structure save. If the answered title differs from the one already in the tree, the component warns once on the console rather than silently diverging — you have two sources of truth and should hear about it.

4. A read answered after its 10-second timeout is still accepted. The component resumes normal operation, and the *-load-failed event already emitted is not retracted. Treat those events as a warning about host latency, not a terminal state. Conversely, a read that has already timed out is terminal for timeout purposes: one get-course-data can never produce two course-load-failed events, even across a re-parent.

5. Re-selecting an already-loaded lesson asks you again. get-lesson-data fires on every selection, per the contract's literal wording — there is no client-side cache deciding on your behalf that your data has not changed. Selecting a second lesson cancels the first pending read, since only one lesson can be selected at a time.

Re-parenting

Moving the element in the DOM — routine in React and Angular — does not re-ask and does not produce a dead component. A disconnect flushes any pending structure save rather than dropping it, stops the clocks, and a reconnect resumes. A rename in flight survives a router moving the element.


The default editor

Nearly every host wants the same editor, so the factory ships here rather than being copied out of the demo page.

import '@creaditor/cdtr-course-builder';
import { createDefaultEditor } from '@creaditor/cdtr-course-builder';

document.querySelector('cdtr-course-builder').createEditor = createDefaultEditor({
  bundleUrl: '/vendor/creaditor.bundle.js',
  language: 'en',
});

@creaditor/web-starterkit is an optional peer dependency — declared so npm tells you the version this was built against, never installed or bundled on your behalf. Skip the factory and you never pay for it.

The bundle is loaded by <script>, and that is not an oversight

@creaditor/web-starterkit is a code-split webpack bundle: one entry file plus ~450 numbered chunks fetched at runtime. It derives the base URL for those chunks by walking the document's <script> elements and taking the last one with a src, throwing outright when there is none.

So import '@creaditor/web-starterkit/...' from inside your bundler is the wrong integration. Once the entry is inlined into your own chunk there is no script tag pointing at it, and the base URL resolves to whatever unrelated script happened to be last in the document. The chunks then 404 the moment a user opens a lesson — not at build time, and not in any test that never opens one.

Injecting a real script tag makes the bundle's own directory the answer, which is the only arrangement its chunk loading was built for. Verified in Chrome against the preview page: 113 chunks requested, none failed.

Two ways to satisfy it:

| | | |---|---| | Pass bundleUrl | The factory injects the tag on first use and buffers until it loads | | Put <script src="…/creaditor.bundle.js"> on the page yourself | The factory takes the synchronous path, no buffering |

There is deliberately no default URL. A white-label drop-in embedded in your site must not silently reach a CDN this package picked; where the editor is served from is your decision, and for many hosts a same-origin copy is the only acceptable answer. Omit bundleUrl with no bundle on the page and the factory throws with that instruction rather than rendering a dead pane.

Loads are shared per URL and an existing tag is adopted rather than duplicated — the bundle assigns a global and resolves chunks relative to itself, so loading it twice is never harmless. loadCreaditorBundle(url) is exported for hosts that would rather have the editor ready before the first lesson is opened.

The blank document

A lesson with no page gets an explicit document rather than [].

Handed an empty array the starterkit substitutes its own default root, and then — because the editor echoes back whatever it is holding — saves that substitute straight back out. You persist a page nobody authored, for every lesson anyone so much as opens. Passing an explicit document instead means the echo carries a value the component already knows it pushed, which its fingerprinting drops.

The shape matters and is not guessable: a flat array whose nodes carry both id and _id, children holding ids rather than nested objects, and elements present on every node — dropping elements orphans anything later dragged into a container, and that failure surfaces as content vanishing on reload, far from its cause. blankLessonDoc() is exported if you need the same shape elsewhere.

The fallback applies on every lesson swap, not only at mount, because that is when most lessons arrive.

Options

| Option | Default | | |---|---|---| | bundleUrl | none | Where to fetch the bundle, if it is not already on the page | | language | 'he' | Editor chrome language, matching the component's own default | | width | '100%' | Passed through to the starterkit | | starterProps | {} | Forwarded verbatim; target, components and onSave stay controlled |

The returned handle carries instance (the live starterkit, or null until loaded) and ready (resolves when the editor exists, rejects if the bundle fails). A failed load is reported on the console stating that structure editing is unaffected — otherwise it reads as the whole component being broken.


Echo suppression, and its two accepted residuals

Answering a read never echoes back as a write. Two mechanisms enforce that: a synchronous guard around every mutation the component performs on your behalf, and a fingerprint of every value the component pushes into the editor, which absorbs the deferred save real editors fire after ingesting content.

That fingerprint is sticky, not one-shot. A deferred save is not a well-behaved next-frame event: editors save inside requestAnimationFrame, which is throttled or paused entirely while the tab is not painting, so the queue flushes in a burst — the same echo can arrive several times, and an echo of an older value can arrive after newer content is already live. A fingerprint consumed on first match ships the rest of the burst as writes, which is how a save-lesson-data carrying content: [] can reach a host and destroy a lesson. The component therefore remembers every value it pushed for a lesson (the last eight) and suppresses all of them, indefinitely.

A save is also attributed to the lesson the editor was displaying when that content was put there — not to whichever lesson is selected when the save surfaces. Those are different lessons more often than they sound: the sidebar highlights a lesson the instant it is clicked, while the editor keeps showing the previous page until you answer the read. Attributing by selection is how a save ends up carrying the right id and title with the wrong lesson's page.

Residual 1 — undoing back to a page the component loaded does not save. An editor event carrying exactly the content the component pushed is indistinguishable from a late echo of that push; nothing in the event says which it is. The component drops it. That test spans every lesson, not just the one on screen, because a cross-lesson echo is precisely the case where the two disagree — so in the narrow case where two lessons hold byte-identical pages (realistically: both empty, since pages carry lesson-specific node ids), an edit returning one of them to that shared value produces no save. The failure modes are not symmetric: a dropped save costs one edit, recovered by typing anything else, while an escaped stale echo silently overwrites your stored page with an empty one — or with a different lesson's. Values you never sent us — the user's own edit history — are never suppressed, so undoing through edits saves normally.

Residual 2 — normalizing editors write once more than you expect. If your editor NORMALIZES content on ingest, its echo is not byte-identical to what was pushed, the fingerprint misses, and you get one extra save-lesson-data per lesson. Self-converging and harmless: you store the normalized form, the next load applies it, the editor normalizes to the same thing, and it matches from then on. One extra write per lesson per normalization change, no corruption and no loop.

Note that residual 2 is a different phenomenon from a save carrying content you never edited. A save-lesson-data whose content is [], or is another lesson's page, is not normalization — it is a bug. Report it rather than accepting it.


AI generation

With no course loaded, the component offers a brief form: describe the course in prose, press generate, and a full spine — sections and lessons — comes back in roughly 30–60 s. This is the one action in the component that reaches a server, and it is opt-in by construction: a host that never mounts the transport never has it.

The host page requirement

Registering the elements is not enough. They must be in the DOM.

npm install @creaditor/cdtr-auth @creaditor/cdtr-brain

Both are optional peer dependencies — declared so npm names the versions this was built against, never installed on your behalf. A host that does not want generation installs neither and pays nothing.

<!-- one transport per page, loaded once -->
<script type="module" src="/node_modules/@creaditor/cdtr-auth/dist/cdtr-auth.es.js"></script>
<script type="module" src="/node_modules/@creaditor/cdtr-brain/dist/cdtr-brain.es.js"></script>

<!-- and then actually mounted — this is the part that gets skipped -->
<cdtr-auth cdtr-studio-url="https://your-studio-origin"></cdtr-auth>
<cdtr-brain></cdtr-brain>

<cdtr-course-builder course-id="1"></cdtr-course-builder>

Importing those bundles runs customElements.define(). That makes the tags work if present; it does not put them on the page. The distinction is not academic: a shipping host imported both bundles for their side effect, never mounted the tags, and its AI feature silently did nothing in production while every locally-tested path looked fine — the outage business-ai/src/components/bussinessAILayout/SocialPostAuthBridge.tsx exists to fix, and its comment block is the primary source for this paragraph.

Because that failure is invisible by nature, the component preflights: the submit handler checks document.querySelector('cdtr-brain') and document.querySelector('cdtr-auth') before dispatching, and reports a missing transport immediately instead of waiting out its 120 s budget. A missing tag therefore reads as a missing tag, not as a slow server.

Mount exactly one of each per page. Two brains double-dispatch every request; two vaults double-respond. If your framework mounts the pair for you, reuse what is already there rather than adding your own — the querySelector guard above is the whole technique.

One bundled script per page

<cdtr-brain> ships as a single bundled cdtr-brain.js that the host loads with a <script> tag — the same way cdtr-agent.js already works — rather than as a peer dependency each drop-in bundles for itself. One transport serves every drop-in on the page regardless of how many are present, which is what brain-client's page-level singleton correlation registry already assumes.

This is a one-way decision: it is the published integration story, so once embedders have added the script tag, changing it means every embedder edits their page.

<cdtr-brain>'s dist/ is generated, not committed. A host loading a bundle built before this feature existed gets unknown_op back — emitted synchronously at dispatch, so it arrives instantly and reads like a client bug. If generation fails the moment you press the button, rebuild the brain before debugging anything else.

What generation does to the network claim

Nothing, and the specific reason matters:

| Layer | Reaches the network? | |---|---| | <cdtr-course-builder> bundle | No. Zero occurrences of fetch, XMLHttpRequest, EventSource, WebSocket, sendBeacon. Verified by grep on the built ES bundle. | | @cdtr/brain-client | No. signal.set / signal.on only. | | <cdtr-brain> | No. Pure signal routing; it holds the API paths, not a client. | | <cdtr-auth> | Yes — it creates an iframe on the studio origin and postMessages to it. The request runs inside that iframe, in the studio's own code, and <cdtr-auth> is not bundled into this drop-in. |

So the component gains a generation capability without gaining a request. The JWT lives in the vault iframe's closure on the studio origin and never crosses back to the host page — the component never sees it, and neither does your page after you hand it over.

What you get back, and what you own

Generation output is spine-only in v1. Every returned lesson arrives with doc: null; no lesson content is drafted. The first lesson is access: 'free' and every other lesson is access: 'locked'.

The result is loaded through the ordinary loadCourseData() path, so a save-course-structure fires and you persist it exactly as you persist any other structural change. Nothing is stored on your behalf. The course keeps the id it already had, so a generated course is a normal course from the second it arrives — including regeneration, which replaces the spine under the same id.

Generation is cancellable from the UI, but cancelling only stops the wait. The server-side job keeps running and keeps billing; the component says so on screen rather than implying a refund.

Errors are bucketed to fixed strings — an auth failure is worded differently from a server error so the fix is discoverable, and no server-supplied text is ever rendered. Retrying is pressing the button again; the brief is preserved across a failure and across a cancel.


Theming

The component is white-label. It defines a --cdtr-cb-* namespace resolving through the canonical --cdtr-* layer, so setting one token reskins everything — including the two upstream elements, which are themed rather than forked.

cdtr-course-builder {
  --cdtr-accent: #0057ff;
}

| --cdtr-cb-* | Resolves through | Covers | |---|---|---| | --cdtr-cb-accent | --cdtr-accent | Primary accent, active lesson, add buttons | | --cdtr-cb-accent-soft | --cdtr-accent-bg | Accent wash behind active rows | | --cdtr-cb-accent-deep | --cdtr-accent | Accent text on the wash | | --cdtr-cb-surface | --cdtr-bg | Panel surfaces and the sidebar background | | --cdtr-cb-canvas | --cdtr-bg-subtle | The canvas behind panels | | --cdtr-cb-text | --cdtr-text | Primary text | | --cdtr-cb-text-muted | --cdtr-text-muted | Secondary text | | --cdtr-cb-text-faint | --cdtr-text-muted | Tertiary text, counts, hints | | --cdtr-cb-border | --cdtr-border | Hairlines | | --cdtr-cb-border-soft | --cdtr-border | Interior dividers | | --cdtr-cb-radius | --cdtr-radius | Corner radius | | --cdtr-cb-font | --cdtr-font-family | UI font stack | | --cdtr-cb-font-serif | --cdtr-font-family | Display/serif stack | | --cdtr-cb-status-published | (no canonical equivalent) | Published status node and chips | | --cdtr-cb-status-locked | (no canonical equivalent) | Locked indicator | | --cdtr-cb-status-free | (no canonical equivalent) | Free-access chip |

--cdtr-bg is the surface, not the page background; the canvas behind panels is --cdtr-bg-subtle. Getting these backwards flattens every framed component to white.

Set nothing and the component renders exactly as the upstream starterkit demo does — the namespace carries a literal default only where the two upstream elements already agree, so an unthemed embed is pixel-identical and no upstream visual is silently changed.

Both upstream elements are RTL by design; the component does not fight it.


Development

npm install
npm run dev      # stand-in host on http://localhost:5181
npm run test     # contract tests (node) + element tests (happy-dom), one run
npm run build

index.html is a working host implementing all four listeners against in-memory fixture data, with a live event log and controls that demonstrate the debounce, a deliberate read timeout and the accent reskin. It issues no network request of its own.

It also mounts <cdtr-auth> and <cdtr-brain> as real tags — the reference for the host page requirement above — so the generation path can be exercised end to end. Both are inert until you fill in the header's studio URL and embed JWT and press connect; with those blank the harness stays entirely offline. The studio origin is read once when the vault iframe mounts, so pointing it somewhere else needs a page reload. The token is not persisted. An audit spine button reports, for the loaded course, whether every lesson has doc: null and whether the access ladder is free then locked.


Upstream dependencies

@creaditor/course-core and @creaditor/course-starterkit resolve from the npm registry at ^0.1.0. Both were published 2026-08-14; the vendor/ directory of file: tarballs that stood in for them until then is gone.

Both ship built, minified dist/ rather than source. Two consequences worth knowing before you touch the imports:

  • The starterkit's root entry is the headless binding class only. Its index.js does not re-export CourseSidebar or LessonSettings, so the two custom elements are imported for their side effect through the subpath entries its exports map declares:

    import '@creaditor/course-starterkit/CourseSidebar';
    import '@creaditor/course-starterkit/LessonSettings';

    Drop either line and that element never registers — the builder renders with no sidebar or no settings panel, with no error.

  • exports is an allowlist. The pre-publication /src/*.js paths no longer resolve. Any deep import beyond the three declared entries will fail.

_neutralizeUpstreamEditorLoad patches CourseBuilder._loadIntoEditor by name. That survives the upstream build because esbuild --minify renames local identifiers but leaves property names alone — verified against the published tarball, and asserted in the element suite. If upstream ever enables mangleProps, that patch breaks and lesson content starts getting wiped on selection; the guard in _neutralizeUpstreamEditorLoad logs rather than throws, so it would fail quietly.