@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,WebSocketorsendBeacon. 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
texton 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:selectedcontent: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-brainBoth 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 buildindex.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.jsdoes not re-exportCourseSidebarorLessonSettings, so the two custom elements are imported for their side effect through the subpath entries itsexportsmap 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.
exportsis an allowlist. The pre-publication/src/*.jspaths 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.
