NS Design System
v3.0.0 · 257 tokens

93Course player

The screen a learner lives in: a 16:9 stage on brand-900 in both themes, the lesson header, the lesson's own detail tabs (chapters, transcript, resources, notes, Q&A), a docked prev/next carrying the position, and a FIXED curriculum rail ending in the course's standing action. One viewport, one scrollbar — only the content column moves. Two columns ≥ lg; below, a single column with the stage always first, the CTA above the list, and the rail replaced by the panel bar and its popover.

Use it for

  • Lesson pages in both products — Ghost via templates/course-player.html, Next.js via CoursePlayer/LessonRail
  • --article for a lesson that is mostly reading: no stage, prose at the measure with the lesson's outline in the space beside it
  • --scroll where the document scroll is genuinely the right one

Not for

  • Marketing pages with one embedded video — that is just a video in prose

Lesson rows

done replaces the index with a check — the number has done its job; current gets the accent line; locked dims but stays a link.

markup
<div style="max-inline-size:22rem;border:1px solid var(--color-border);border-radius:var(--radius-card);overflow:hidden">
  <a class="ns-lesson" href="#" data-state="done">
    <span class="ns-lesson__index" aria-hidden="true"><i class="ph ph-check-circle"></i></span>
    <span class="ns-lesson__title">SELECT and WHERE<span class="ns-visually-hidden"> (completed)</span></span>
    <span class="ns-lesson__time">14:02</span></a>
  <a class="ns-lesson" href="#" aria-current="true">
    <span class="ns-lesson__index" aria-hidden="true">07</span>
    <span class="ns-lesson__title">SOQL joins: relationships</span>
    <span class="ns-lesson__time">21:15</span></a>
  <a class="ns-lesson" href="#">
    <span class="ns-lesson__index" aria-hidden="true">08</span>
    <span class="ns-lesson__title">Aggregate queries</span>
    <span class="ns-lesson__time">18:40</span></a>
  <a class="ns-lesson" href="#" data-state="locked">
    <span class="ns-lesson__index" aria-hidden="true">09</span>
    <span class="ns-lesson__title">Bulk-safe triggers<span class="ns-visually-hidden"> (locked)</span></span>
    <span class="ns-lesson__time"><i class="ph ph-lock" aria-hidden="true"></i></span></a>
</div>

Lesson header + nav

section 2 · lesson 07

SOQL joins: relationships in queries

21:15updated Jan 2026

markup
<div style="max-inline-size:34rem">
  <header class="ns-player__head" style="padding-inline:0">
    <p class="ns-player__kicker">section 2 · lesson 07</p>
    <h2 class="ns-player__title">SOQL joins: relationships in queries</h2>
    <p class="ns-player__meta"><span>21:15</span><span>updated Jan 2026</span></p>
  </header>
  <div class="ns-player__nav" style="padding-inline:0">
    <a class="ns-btn ns-btn--outline ns-btn--sm" href="#"><i class="ph ph-caret-left" aria-hidden="true"></i> Previous</a>
    <div class="ns-progress-row">
      <progress class="ns-progress" value="6" max="12" aria-label="6 of 12 lessons"></progress>
      <span class="ns-progress-row__value">6/12</span>
    </div>
    <a class="ns-btn ns-btn--primary ns-btn--sm" href="#">Next lesson <i class="ph ph-caret-right" aria-hidden="true"></i></a>
  </div>
</div>

The rail as a timeline

Opt-in: add ns-player__list--timeline to the list and nothing else changes. A hairline runs down the index column and each lesson number becomes a node on it — the claim being that a course is a path, which a flat list of rows implies but never draws.

Only the current node is filled. An earlier version put every finished lesson in a green disc and ran the line green behind it, which meant a learner nine lessons in got a column of green coins and one blue one — the thing you were looking for was the quietest mark on the rail. Completion is already said by the check that replaced the number and by the muted title; the fill is reserved for where you ARE, because that is the one question the rail exists to answer. A members-only node goes dashed: the path continues, but it is not yours yet.

The node keeps the index cell's existing width. The first grid column is auto, so shrinking the node shrinks the column and the line no longer passes through the middle of anything — which is exactly how it went off-centre the first time. The line's position is derived from the row's own tokens (leading border + inline padding + half the index cell), so it follows if any of them change, and it stops half-way at the first and last rows so the path has a beginning and an end.

markup
<div style="max-inline-size:17rem;border:1px solid var(--color-border);border-radius:var(--radius-card);overflow:hidden">
  <div class="ns-player__list ns-player__list--timeline">
    <a class="ns-lesson" href="#0" data-state="done">
      <span class="ns-lesson__index" aria-hidden="true"><i class="ph ph-check-circle"></i></span>
      <span class="ns-lesson__title">What is an org?<span class="ns-visually-hidden"> (completed)</span></span>
      <span class="ns-tooltip">What is an org?</span>
      <span class="ns-ltype ns-ltype--icon ns-ltype--video ns-tooltip-host" tabindex="0"><i class="ph ph-video" aria-hidden="true"></i><span class="ns-tooltip ns-tooltip--below">Video</span></span>
      <span class="ns-lesson__time">08:12</span></a>
    <a class="ns-lesson" href="#0" data-state="done">
      <span class="ns-lesson__index" aria-hidden="true"><i class="ph ph-check-circle"></i></span>
      <span class="ns-lesson__title">Objects &amp; fields<span class="ns-visually-hidden"> (completed)</span></span>
      <span class="ns-ltype ns-ltype--icon ns-ltype--article ns-tooltip-host" tabindex="0"><i class="ph ph-article" aria-hidden="true"></i><span class="ns-tooltip">Article</span></span>
      <span class="ns-lesson__time">12:40</span></a>
    <a class="ns-lesson" href="#0" aria-current="true">
      <span class="ns-lesson__index" aria-hidden="true">03</span>
      <span class="ns-lesson__title">SOQL joins: relationships</span>
      <span class="ns-ltype ns-ltype--icon ns-ltype--video ns-tooltip-host" tabindex="0"><i class="ph ph-video" aria-hidden="true"></i><span class="ns-tooltip">Video</span></span>
      <span class="ns-lesson__time">21:15</span></a>
    <a class="ns-lesson" href="#0">
      <span class="ns-lesson__index" aria-hidden="true">04</span>
      <span class="ns-lesson__title">Aggregate queries</span>
      <span class="ns-ltype ns-ltype--icon ns-ltype--quiz ns-tooltip-host" tabindex="0"><i class="ph ph-exam" aria-hidden="true"></i><span class="ns-tooltip">Quiz</span></span>
      <span class="ns-lesson__time">05:00</span></a>
    <a class="ns-lesson" href="#0" data-access="members">
      <span class="ns-lesson__index" aria-hidden="true">05</span>
      <span class="ns-lesson__title">Bulk-safe triggers<span class="ns-visually-hidden"> (locked — members only)</span></span>
      <span class="ns-ltype ns-ltype--icon ns-ltype--lab ns-tooltip-host" tabindex="0"><i class="ph ph-flask" aria-hidden="true"></i><span class="ns-tooltip">Hands-on lab</span></span>
      <span class="ns-lesson__time"><i class="ph ph-lock-simple" aria-hidden="true"></i></span></a>
  </div>
</div>

The rail's foot — an action

For the surfaces that still have a standing action: a locked course (enrol), a finished one (download the certificate). The LESSON player's rail does not — its progress is the full-width line on the course bar and its next action is the docked bar, so its foot is share and Ask AI instead (below). One primary and one line of fine print: a rail that ends in three buttons is an upsell.

Your progress 8 / 24 Certificate at 100%
markup
<div style="max-inline-size:20rem;border:1px solid var(--color-border);border-radius:var(--radius-card);overflow:hidden">
  <div class="ns-player__side-cta">
    <span class="ns-player__side-cta-meta">Your progress <b>8 / 24</b></span>
    <progress class="ns-progress" value="8" max="24" aria-label="8 of 24 lessons complete"></progress>
    <button type="button" class="ns-btn ns-btn--primary ns-btn--block ns-btn--sm"><i class="ph ph-play" aria-hidden="true"></i> Resume lesson 09</button>
    <span class="ns-player__side-cta-meta">Certificate at 100%</span>
  </div>
</div>

Locked-course foot

The same slot doing the other job: this course is not bought yet, so the action is enrol and the fine print is what that buys.

Free preview 2 / 24 Lifetime access · certificate
markup
<div style="max-inline-size:20rem;border:1px solid var(--color-border);border-radius:var(--radius-card);overflow:hidden">
  <div class="ns-player__side-cta">
    <span class="ns-player__side-cta-meta">Free preview <b>2 / 24</b></span>
    <button type="button" class="ns-btn ns-btn--primary ns-btn--block ns-btn--sm">Enrol — $49</button>
    <span class="ns-player__side-cta-meta">Lifetime access · certificate</span>
  </div>
</div>

Detail tabs

Everything the lesson carries besides the media, in one strip rather than stacked down the column. Stacked, the chapter list, the transcript, the files and the discussion push the next-lesson control below three screens of scroll; as tabs the column stays one screen deep and each is one click from the video. .ns-player__body--wide lets the strip take the column's full width — a chapter list squeezed to the reading measure wastes the half of the column the video already established — while prose inside a panel keeps its own measure.

00:12
A lookup field does not store the record. It stores an id.
08:03
Going down is a subquery, and it is a different shape.
markup
<div class="ns-player__body ns-player__body--wide" style="padding-inline:0">
  <div class="ns-tabs" role="tablist" aria-label="Lesson details">
    <button class="ns-tab" type="button" role="tab" id="pd-t1" aria-controls="pd-p1" aria-selected="true"><i class="ph ph-list ns-tab__icon" aria-hidden="true"></i> Chapters <span class="ns-tab__count">5</span></button>
    <button class="ns-tab" type="button" role="tab" id="pd-t2" aria-controls="pd-p2" aria-selected="false"><i class="ph ph-article ns-tab__icon" aria-hidden="true"></i> Transcript</button>
    <button class="ns-tab" type="button" role="tab" id="pd-t3" aria-controls="pd-p3" aria-selected="false"><i class="ph ph-folder-open ns-tab__icon" aria-hidden="true"></i> Resources <span class="ns-tab__count">3</span></button>
  </div>
  <div class="ns-tabpanel" role="tabpanel" id="pd-p1" aria-labelledby="pd-t1" tabindex="0">
    <ol class="ns-vchapters">
      <li class="ns-vchapters__item" data-start="0" data-state="done"><button type="button" class="ns-vchapters__btn"><span class="ns-vchapters__title">What a relationship field stores</span><span class="ns-vchapters__time">00:00</span><span class="ns-vchapters__meta">3 min · watched</span></button></li>
      <li class="ns-vchapters__item" data-start="483" aria-current="true"><button type="button" class="ns-vchapters__btn"><span class="ns-vchapters__title">Subqueries: down to the children</span><span class="ns-vchapters__time">08:03</span><span class="ns-vchapters__meta">6 min · playing</span></button></li>
      <li class="ns-vchapters__item" data-start="845"><button type="button" class="ns-vchapters__btn"><span class="ns-vchapters__title">Where the two count against the limits</span><span class="ns-vchapters__time">14:05</span><span class="ns-vchapters__meta">4 min</span></button></li>
    </ol>
  </div>
  <div class="ns-tabpanel" role="tabpanel" id="pd-p2" aria-labelledby="pd-t2" tabindex="0">
    <dl class="ns-deflist">
      <dt>00:12</dt><dd>A lookup field does not store the record. It stores an id.</dd>
      <dt>08:03</dt><dd>Going down is a subquery, and it is a different shape.</dd>
    </dl>
  </div>
  <div class="ns-tabpanel" role="tabpanel" id="pd-p3" aria-labelledby="pd-t3" tabindex="0">
    <a class="ns-resource" href="#0"><span class="ns-resource__icon"><i class="ph ph-file-text" aria-hidden="true"></i></span><span class="ns-resource__body"><span class="ns-resource__title">Relationship queries cheat sheet</span><span class="ns-resource__type">PDF · 2 pages</span></span><i class="ph ph-arrow-down ns-resource__cue" aria-hidden="true"></i></a>
  </div>
</div>

The wired stage

The bare .ns-player__stage is a slot you drop an <iframe> into and leave the vendor's controls on. --player is the slot holding the system's OWN player: the 16:9 box moves down to .ns-vplayer__stage and the stage becomes a plain container, because a control bar nailed inside a box that is already exactly 16:9 either covers the last inch of the picture or overflows. data-chapters points the player at the chapter list in the tab panel below — the list is content, so it lives in the page where it can be read without pressing play, and one player still drives it.

<div class="ns-player__stage ns-player__stage--player">
  <div class="ns-vplayer" data-ns-video data-youtube="VIDEO_ID"
       data-chapters="#lesson-chapters" data-state="paused">
    <div class="ns-vplayer__stage">
      <button class="ns-vplayer__big" type="button" aria-label="Play">…</button>
    </div>
    <div class="ns-vplayer__bar">…</div>
  </div>
</div>
markup
<pre class="ns-code__pre"><code>&lt;div class="ns-player__stage ns-player__stage--player"&gt;
  &lt;div class="ns-vplayer" data-ns-video data-youtube="VIDEO_ID"
       data-chapters="#lesson-chapters" data-state="paused"&gt;
    &lt;div class="ns-vplayer__stage"&gt;
      &lt;button class="ns-vplayer__big" type="button" aria-label="Play"&gt;…&lt;/button&gt;
    &lt;/div&gt;
    &lt;div class="ns-vplayer__bar"&gt;…&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;</code></pre>

The rail's foot — the lesson player

What you do WITH a lesson rather than where you go next: pass it on, or get help with it. Both secondary, so both quiet and neither ever the solid blue.

The AI rows are ordinary links with target="_blank" rel="noopener" — no key, no proxy, no request from us. assets/js/lms.js composes each href from ONE prompt written on the container (data-ns-ask-prompt) rather than repeating the same sentence in three hrefs that then drift apart, and appends the video's current timestamp when there is one, because "explain what is happening at 08:03" is a better question than "explain this lesson". The reader can see exactly what is being sent before they click: it is in the URL, and Copy puts it on the clipboard verbatim.

Share
markup
<div style="max-inline-size:17rem;border:1px solid var(--color-border);border-radius:var(--radius-card);overflow:hidden">
  <div class="ns-player__side-foot">
    <div class="ns-share">
      <span class="ns-share__label">Share</span>
      <button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Copy link"><i class="ph ph-link-simple" aria-hidden="true"></i></button>
      <button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Share on LinkedIn"><i class="ph ph-linkedin-logo" aria-hidden="true"></i></button>
      <button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Share on X"><i class="ph ph-x-logo" aria-hidden="true"></i></button>
    </div>
    <div class="ns-usermenu">
      <button type="button" class="ns-btn ns-btn--outline ns-btn--sm" data-ns-menu aria-expanded="false" aria-controls="ask-doc"><i class="ph ph-sparkle" aria-hidden="true"></i> Ask AI</button>
      <div class="ns-usermenu__panel" id="ask-doc" role="menu">
        <span class="ns-menu__label">Open this lesson in</span>
        <a class="ns-menu__item" role="menuitem" href="#0"><i class="ph ph-sparkle" aria-hidden="true"></i> Claude</a>
        <a class="ns-menu__item" role="menuitem" href="#0"><i class="ph ph-chat-circle-text" aria-hidden="true"></i> ChatGPT</a>
        <a class="ns-menu__item" role="menuitem" href="#0"><i class="ph ph-magnifying-glass" aria-hidden="true"></i> Perplexity</a>
        <hr class="ns-menu__sep">
        <button type="button" class="ns-menu__item" role="menuitem"><i class="ph ph-file-text" aria-hidden="true"></i> Copy the prompt</button>
      </div>
    </div>
  </div>
</div>

Article lesson

The same player, the other kind of lesson: no 16:9 stage at all, and the content column becomes a two-part reading block — prose at the measure, with the lesson's OUTLINE in the space beside it. The pair is sized together, so the header and the docked nav line up on the same left edge as the text; below lg the rail becomes the horizontal .ns-toc--inline strip, which has somewhere to go where a sticky rail does not. Progress is a 2px hairline driven by scroll rather than a time code. A written lesson that keeps a black video box at the top is a video page with the video missing — and one that leaves a third of the screen empty on each side, with its own contents list nowhere on the page, is a document you navigate by scrolling and hoping.

markup
<p><a class="ns-btn ns-btn--outline ns-btn--sm" href="./demo-player-article.html" target="_blank" rel="noopener">Open the article-lesson demo <i class="ph ph-arrow-up-right" aria-hidden="true"></i></a></p>

Full layout

The complete three-column screen — curriculum, lesson, chapters — rendered from the framework-agnostic template with the real stylesheet. The two lesson kinds link to each other, so prev/next walks between them with the cross-document view transition from motion.css: a cross-fade, no slide, because the rail and the docked bar did not move and should not look like they did.

markup
<p><a class="ns-btn ns-btn--outline ns-btn--sm" href="./demo-player.html" target="_blank" rel="noopener">Video lesson <i class="ph ph-arrow-up-right" aria-hidden="true"></i></a>
<a class="ns-btn ns-btn--outline ns-btn--sm" href="./demo-player-article.html" target="_blank" rel="noopener">Written lesson <i class="ph ph-arrow-up-right" aria-hidden="true"></i></a></p>

At three widths

The real pages in real iframes at 390, 768 and 1180 — because media queries answer to the VIEWPORT, and a narrow div would keep rendering the desktop layout at phone width and prove the opposite of what it looked like it was proving.

markup
<p><a class="ns-btn ns-btn--outline ns-btn--sm" href="./demo-player-responsive.html" target="_blank" rel="noopener">Open the responsive proof <i class="ph ph-arrow-up-right" aria-hidden="true"></i></a></p>

Per-type progress

"How far through this lesson am I" has three different honest answers, so it has three different controls. One bar for all three is how a quiz ends up claiming you are 60% correct when you are 60% finished. Video counts time and marks chapters; article counts scroll and is a hairline, never a widget; a quiz has TWO numbers — answered and score — and pips can say what a length cannot.

Video 04:38 / 12:10

Article — the hairline above is the whole control. A reading-progress widget with a percentage label is a distraction from the thing it measures.

Quiz 3/4 correct
markup
<div style="display:grid;gap:var(--space-6);inline-size:100%;max-inline-size:34rem">
  <div class="ns-lprogress ns-lprogress--video" style="--fx-progress:38%">
    <span class="ns-lprogress__label">Video</span>
    <span class="ns-lprogress__track">
      <span class="ns-lprogress__fill"></span>
      <span class="ns-lprogress__chapter" style="--fx-at:22%"></span>
      <span class="ns-lprogress__chapter" style="--fx-at:55%"></span>
      <span class="ns-lprogress__chapter" style="--fx-at:78%"></span>
    </span>
    <span class="ns-lprogress__time">04:38 / 12:10</span>
  </div>
  <div>
    <span class="ns-lprogress ns-lprogress--article" style="--fx-progress:64%" role="progressbar" aria-label="Reading progress" aria-valuenow="64" aria-valuemin="0" aria-valuemax="100"></span>
    <p style="margin-block-start:var(--space-2);font-size:var(--size-fine);color:var(--color-muted)">Article — the hairline above is the whole control. A reading-progress widget with a percentage label is a distraction from the thing it measures.</p>
  </div>
  <div class="ns-lprogress ns-lprogress--quiz">
    <span class="ns-lprogress__label">Quiz</span>
    <span class="ns-lprogress__pips">
      <span class="ns-lprogress__pip" data-state="correct"></span>
      <span class="ns-lprogress__pip" data-state="correct"></span>
      <span class="ns-lprogress__pip" data-state="wrong"></span>
      <span class="ns-lprogress__pip" data-state="correct"></span>
      <span class="ns-lprogress__pip" data-state="current"></span>
      <span class="ns-lprogress__pip"></span>
      <span class="ns-lprogress__pip"></span>
      <span class="ns-lprogress__pip"></span>
    </span>
    <span class="ns-lprogress__score">3/4 correct</span>
  </div>
</div>

Accessibility contract

  • ←/→ move lessons; space/k is left to the media element — stealing it breaks the player's own controls
  • Row state is spelled for assistive tech ("completed", "locked — members only"), not only drawn
  • Locked rows stay links (to the upgrade page) — a dead row explains nothing
  • The rail toggle carries aria-expanded and lives in the lesson header, so it is still reachable once the rail is gone