Component gallery

Every component in src/components/, rendered in isolation with its source. Each file is self-contained markup with no script and no style tag. JavaScript binds to the data-* attributes only, never to the Tailwind classes, so classes can be rewritten freely.

bottom-bar.html

src/components/bottom-bar.html
Show source
<!--
  bottom-bar

  Previous / Notes / Next. There is deliberately no pause control: the timer measures
  active time and auto-pauses on idle, so a manual pause would only add a way to game it.

  Layout:
    base  fixed to the bottom edge, full width, safe-area padded for the iPhone home bar
    lg    becomes the floating centred pill from Fito's desktop build

  Data hooks (bound by js/nav.js):
    data-nav-prev   previous control; disabled state is server-rendered
    data-nav-next   next control
-->
<div
  class="fixed inset-x-0 bottom-0 z-20 border-t border-primary-800 bg-slate-950/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur lg:inset-x-auto lg:bottom-6 lg:left-1/2 lg:w-auto lg:-translate-x-1/2 lg:rounded-2xl lg:border lg:px-3 lg:pb-3 lg:shadow-2xl lg:shadow-black/50"
>
  <div class="mx-auto flex max-w-2xl items-center gap-2 lg:max-w-none">
    <a
      class="inline-flex h-12 w-12 shrink-0 items-center justify-center gap-2 rounded-xl border border-primary-700 bg-primary-800 text-slate-300 transition-colors hover:bg-primary-700 hover:text-white data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-40 lg:w-auto lg:px-4"
      data-nav-prev
      data-disabled="{{prevDisabled}}"
      href="{{prevHref}}"
      aria-label="Previous lesson"
    >
      <svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
        <path d="M10.83 12l4.58 4.59L14 18l-6-6 6-6 1.41 1.41L10.83 12Z"/>
      </svg>
      <span class="hidden text-sm font-medium lg:inline">Previous</span>
    </a>

    <a
      class="inline-flex h-12 w-12 shrink-0 items-center justify-center gap-2 rounded-xl border border-primary-700 bg-primary-800 text-slate-300 transition-colors hover:bg-primary-700 hover:text-white lg:w-auto lg:px-4"
      href="#"
      aria-label="My notes"
    >
      <svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
        <path d="M21 3v18H3V3h18Zm-2 2H5v14h14V5Zm-2 3v2H7V8h10Zm-4 4v2H7v-2h6Z"/>
      </svg>
      <span class="hidden text-sm font-medium lg:inline">Notes</span>
    </a>

    <a
      class="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-blue-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-blue-500 lg:flex-none"
      data-nav-next
      href="{{nextHref}}"
    >
      {{nextLabel}}
      <svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
        <path d="M13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41L13.17 12Z"/>
      </svg>
    </a>
  </div>
</div>

course-header.html

src/components/course-header.html
5%
Elapsed
1h 33m
Remain
28h 26m
1/4
Lesson
Show source
<!--
  course-header

  Sticky bar carrying the drawer trigger, the timer, the step counter, the breadcrumb and
  the step dots. Composes timer-ring, breadcrumb and step-dots.

  Layout:
    base  hamburger + timer + step counter, then dots underneath
    md    breadcrumb appears in place of the step counter
    lg    hamburger disappears (the drawer is a permanent sidebar at that width)

  Data hooks:
    data-drawer-open   the hamburger; js/drawer.js binds it
-->
<header class="sticky top-0 z-30 border-b border-primary-800 bg-slate-950/95 backdrop-blur">
  <div class="mx-auto flex max-w-[1600px] flex-col gap-2 px-4 py-2.5 sm:px-6">
    <div class="flex items-center gap-3">
      <button
        class="grid size-10 shrink-0 place-items-center rounded-xl border border-primary-700 bg-primary-800 text-slate-300 transition-colors hover:bg-primary-700 hover:text-white lg:hidden"
        type="button"
        data-drawer-open
        aria-label="Open course contents"
        aria-expanded="false"
        aria-controls="course-drawer"
      >
        <svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
          <path d="M3 4h18v2H3V4Zm0 7h12v2H3v-2Zm0 7h18v2H3v-2Z"/>
        </svg>
      </button>

      <!-- include: components/timer-ring.html -->

      <div class="ml-auto flex min-w-0 items-center gap-4">
        <!-- include: components/breadcrumb.html -->

        <div class="shrink-0 rounded-xl border border-primary-700 bg-primary-800 px-3 py-1.5 text-center md:hidden">
          <div class="text-sm font-bold leading-none text-white">
            {{stepIndex}}<span class="text-xs font-medium text-slate-500">/{{stepCount}}</span>
          </div>
          <div class="mt-0.5 text-[9px] font-semibold uppercase tracking-wide text-slate-500">Lesson</div>
        </div>
      </div>
    </div>

    <!-- include: components/step-dots.html -->
  </div>
</header>

course-tree.html

src/components/course-tree.html
Show source
<!--
  course-tree

  The full Module / Unit / Lesson navigator. Used inside nav-drawer and again on the
  overview page.

  This is the ONLY component that iterates. It declares three nested
  <template data-repeat="..."> blocks which build.mjs expands from src/data/course.json.
  Row markup deliberately lives here rather than in JavaScript, so each row is itself a
  liftable fragment: what you see between the template tags is exactly what Sasha needs.

  Available placeholders:
    module scope   {{number}} {{title}} {{lessonCount}} {{doneCount}} {{pct}}
    unit scope     {{number}} {{title}} {{lessonCount}} {{doneCount}} {{pct}} {{parent.number}}
    lesson scope   {{number}} {{title}} {{minutes}} {{href}} {{state}} {{parent.number}}

  {{state}} is done | available | locked and drives every visual difference between rows.
  {{href}} is empty for lessons that have no built screen in this review.

  Data hooks:
    data-tree           root
    data-tree-lesson    a lesson row; carries data-lesson-number for js/nav.js
-->
<nav class="text-sm" data-tree aria-label="Course contents">
  <template data-repeat="module">
    <section class="border-b border-primary-800 last:border-b-0">
      <h3 class="flex items-baseline gap-2 px-4 pt-5 pb-1">
        <span class="text-[10px] font-bold uppercase tracking-widest text-slate-500">Module {{number}}</span>
      </h3>
      <p class="px-4 pb-3 text-sm font-semibold text-white">{{title}}</p>

      <template data-repeat="unit">
        <div class="px-4 pb-4">
          <div class="flex items-baseline gap-2 pt-2">
            <span class="text-[10px] font-bold uppercase tracking-wide text-primary-300">Unit {{number}}</span>
            <span class="ml-auto text-[10px] font-medium tabular-nums text-slate-500">{{doneCount}}/{{lessonCount}}</span>
          </div>
          <p class="pb-2 text-xs font-medium text-slate-300">{{title}}</p>

          <ul class="space-y-0.5">
            <template data-repeat="lesson">
              <li>
                <a
                  class="group flex items-start gap-2.5 rounded-lg px-2 py-2 transition-colors data-[state=locked]:pointer-events-none data-[state=locked]:opacity-45 hover:bg-primary-800"
                  data-tree-lesson
                  data-state="{{state}}"
                  data-lesson-number="{{number}}"
                  href="{{href}}"
                >
                  <span class="mt-0.5 grid size-4 shrink-0 place-items-center rounded-full border border-primary-600 text-[9px] font-bold group-data-[state=done]:border-emerald-500 group-data-[state=done]:bg-emerald-500 group-data-[state=done]:text-slate-950">
                    <svg class="size-2.5 opacity-0 group-data-[state=done]:opacity-100" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                      <path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17Z"/>
                    </svg>
                  </span>
                  <span class="min-w-0 flex-1">
                    <span class="block text-[11px] font-semibold tabular-nums text-slate-500">{{number}}</span>
                    <span class="block text-xs leading-snug text-slate-300 group-hover:text-white">{{title}}</span>
                  </span>
                  <span class="mt-0.5 shrink-0 text-[10px] tabular-nums text-slate-600">{{minutes}}m</span>
                </a>
              </li>
            </template>
          </ul>
        </div>
      </template>
    </section>
  </template>
</nav>

figure.html

src/components/figure.html
Drive the open road
Drive the open road
Show source
<!--
  figure

  Standalone image plus caption, for use outside a .lesson-prose body (the prose scope
  already styles bare <figure> elements from injected content).

  width and height are injected at build time from tools/used-images.json so the browser
  reserves space before the lazy image loads. Without them, a phone scrolling through a
  lesson with 12 images jumps on every load.

  No JS.
-->
<figure class="my-6">
  <img
    class="w-full rounded-2xl bg-primary-900"
    src="{{figureSrc}}"
    alt="{{figureAlt}}"
    loading="lazy"
    decoding="async"
  >
  <figcaption class="mt-2 text-sm leading-relaxed text-slate-400">{{figureCaption}}</figcaption>
</figure>

idle-modal.html

src/components/idle-modal.html
Show source
<!--
  idle-modal

  Shown when the timer auto-pauses, either because the tab was hidden or because there was
  no interaction for the idle window. Course time only counts while the student is
  actually present, which is what a state audit expects, so this dialog is the moment the
  policy becomes visible to them.

  Data hooks (bound by js/timer.js):
    data-idle-modal   root; data-open toggles visibility
    data-idle-reason  text swapped between the idle and hidden-tab wording
    data-idle-resume  the resume button
-->
<div
  class="fixed inset-0 z-[60] hidden place-items-center bg-slate-950/85 p-6 backdrop-blur-sm data-[open=true]:grid"
  data-idle-modal
  data-open="false"
  role="dialog"
  aria-modal="true"
  aria-labelledby="idle-modal-title"
>
  <div class="w-full max-w-sm rounded-2xl border border-primary-700 bg-primary-900 p-6 text-center shadow-2xl">
    <div class="mx-auto grid size-12 place-items-center rounded-full bg-amber-500/15 text-amber-400">
      <svg class="size-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
        <path d="M12 22a10 10 0 1 1 0-20 10 10 0 0 1 0 20Zm1-10V7h-2v7h6v-2h-4Z"/>
      </svg>
    </div>

    <h2 class="mt-4 text-lg font-bold text-white" id="idle-modal-title">Your time is paused</h2>
    <p class="mt-2 text-sm leading-relaxed text-slate-400" data-idle-reason>
      We stopped the clock because there was no activity on this page. Course time only
      counts while you are actively working through the material.
    </p>

    <button
      class="mt-5 w-full rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-blue-500"
      type="button"
      data-idle-resume
    >
      Resume course
    </button>
  </div>
</div>

key-points.html

src/components/key-points.html
Show source
<!--
  key-points

  Callout box summarising what a lesson covers. Adapted from the mobile artifact, restyled
  into the DMVEdu palette.

  Content is authored per lesson in the page data block and injected as list items, so this
  component is a shell rather than a template with fixed copy.

  No JS.
-->
<aside class="my-6 rounded-2xl border border-primary-700 bg-primary-900/60 p-5">
  <h3 class="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-amber-400">
    <svg class="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M12 2a7 7 0 0 1 4 12.74V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.26A7 7 0 0 1 12 2Zm2 18v1a2 2 0 0 1-4 0v-1h4Z"/>
    </svg>
    Key points
  </h3>
  <ul class="mt-3 space-y-2 text-sm leading-relaxed text-slate-300">
    {{keyPoints}}
  </ul>
</aside>

lesson-content.html

src/components/lesson-content.html

Lesson 1 of 4 in Unit 1.1

The common sense of driving

About 15 minutes

Sample lesson section

A blue sub-heading, as used throughout the course content

This short fragment stands in for a real lesson body when lesson-content is previewed on its own in the component gallery. Real bodies are injected from src/content/lesson-1-1-*.html and are plain semantic HTML with no classes at all.

A smaller emphasis heading
  • List items inherit their spacing and weight from the prose scope.
  • Nothing here carries a Tailwind class.
Drive the open road
Figures and captions are styled by the same scope.
Show source
<!--
  lesson-content

  Wrapper for a lesson body. The body itself is class-free semantic HTML injected at build
  time; every visual decision lives in the .lesson-prose rules in src/css/app.css.

  This split is the point of the component: Sasha replaces the injected fragment with
  whatever his course system emits, and it inherits the typography without carrying any
  Tailwind utility classes into his templates.

  No JS.
-->
<article class="lesson-prose">
  <header class="mb-8 border-b border-primary-800 pb-6">
    <p class="text-xs font-semibold uppercase tracking-wide text-primary-300">
      Lesson {{stepIndex}} of {{stepCount}} in Unit {{unitNumber}}
    </p>
    <h2 class="mt-2 text-2xl font-bold leading-tight text-white sm:text-3xl">{{lessonTitle}}</h2>
    <p class="mt-3 flex items-center gap-2 text-xs text-slate-500">
      <svg class="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
        <path d="M12 22a10 10 0 1 1 0-20 10 10 0 0 1 0 20Zm1-10V7h-2v7h6v-2h-4Z"/>
      </svg>
      About {{lessonMinutes}} minutes
    </p>
  </header>

  <!-- content: {{contentPath}} -->
</article>

progress-bar.html

src/components/progress-bar.html
45%
Show source
<!--
  progress-bar

  Generic horizontal progress indicator, used by the drawer header and the overview page.

  Data hooks:
    data-progress        root; carries data-value as a 0-100 integer
    data-progress-fill   the filled element whose width is set

  Width is server-rendered as an inline style so the bar is correct before any script runs.
  js/timer.js updates only the total-time instance.
-->
<div class="flex items-center gap-3" data-progress data-value="{{pct}}">
  <div
    class="h-1.5 flex-1 overflow-hidden rounded-full bg-primary-800"
    role="progressbar"
    aria-valuenow="{{pct}}"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-label="{{progressLabel}}"
  >
    <div
      class="h-full rounded-full bg-emerald-500 transition-[width] duration-500"
      style="width: {{pct}}%"
      data-progress-fill
    ></div>
  </div>
  <span class="shrink-0 text-xs font-semibold tabular-nums text-slate-400">{{pct}}%</span>
</div>

quiz-option.html

src/components/quiz-option.html
  • Show source
    <!--
      quiz-option
    
      A single answer button inside quiz-question. Kept as its own file because it is the row
      Sasha will loop over server-side.
    
      Data hooks (bound by js/quiz.js):
        data-quiz-option   carries data-index; gains data-state = correct | incorrect | revealed
    
      All three answered states are expressed as data-[state=...] variants, so the button needs
      no class manipulation from JavaScript at all.
    -->
    <li>
      <button
        class="flex w-full items-center gap-3 rounded-xl border border-primary-700 bg-primary-800/60 px-4 py-3 text-left text-sm text-slate-300 transition-colors hover:border-primary-500 hover:bg-primary-800 disabled:cursor-default data-[state=correct]:border-emerald-500 data-[state=correct]:bg-emerald-500/15 data-[state=correct]:text-white data-[state=incorrect]:border-red-500 data-[state=incorrect]:bg-red-500/15 data-[state=incorrect]:text-white data-[state=revealed]:border-emerald-500/60"
        type="button"
        data-quiz-option
        data-index="{{optionIndex}}"
        data-state=""
      >
        <span class="grid size-6 shrink-0 place-items-center rounded-full border border-primary-600 text-[11px] font-bold">{{optionLetter}}</span>
        <span class="flex-1">{{optionText}}</span>
      </button>
    </li>

    quiz-progress.html

    src/components/quiz-progress.html

    Answered 0 /5

    Correct 0

    Show source
    <!--
      quiz-progress
    
      Answered-count readout for the quiz page.
    
      Data hooks (bound by js/quiz.js):
        data-quiz-progress   root; carries data-total
        data-quiz-index      answered count
        data-quiz-score      correct count
    -->
    <div
      class="flex items-center justify-between gap-4 rounded-xl border border-primary-700 bg-primary-900/60 px-4 py-3"
      data-quiz-progress
      data-total="{{questionCount}}"
    >
      <p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
        Answered
        <span class="ml-1 text-sm font-bold tabular-nums text-white" data-quiz-index>0</span>
        <span class="text-sm font-bold tabular-nums text-slate-500">/{{questionCount}}</span>
      </p>
      <p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
        Correct
        <span class="ml-1 text-sm font-bold tabular-nums text-emerald-400" data-quiz-score>0</span>
      </p>
    </div>

    quiz-question.html

    src/components/quiz-question.html
  • 1 In the United States, holding a driver’s license is best described as:

  • Show source
    <!--
      quiz-question
    
      One multiple-choice question with its options and a hidden explanation.
    
      Every option button and the explanation are present in the markup from the start. The
      script only toggles data-state and unhides the explanation; it never builds the option
      list. That is what lets Sasha render these server-side from his own question bank.
    
      Data hooks (bound by js/quiz.js):
        data-quiz-question     root; carries data-answer-index
        data-quiz-option       an option button; carries data-index, gains
                               data-state = correct | incorrect | revealed
        data-quiz-explanation  the explanation block; unhidden after answering
    
      This component is rendered per question by the page, not by a data-repeat template,
      because quiz content lives in src/content/quiz-1-1.json rather than course.json.
    -->
    <li
      class="rounded-2xl border border-primary-700 bg-primary-900/40 p-5"
      data-quiz-question
      data-answer-index="{{answerIndex}}"
    >
      <p class="flex gap-3 text-base font-semibold leading-snug text-white">
        <span class="grid size-6 shrink-0 place-items-center rounded-full bg-primary-700 text-xs tabular-nums text-slate-300">{{questionNumber}}</span>
        <span>{{stem}}</span>
      </p>
    
      <ul class="mt-4 space-y-2">
        {{options}}
      </ul>
    
      <div
        class="mt-4 hidden rounded-xl border border-primary-700 bg-slate-950/60 p-4 text-sm leading-relaxed text-slate-300 data-[revealed=true]:block"
        data-quiz-explanation
        data-revealed="false"
      >
        {{explanation}}
      </div>
    </li>

    step-dots.html

    src/components/step-dots.html
    Show source
    <!--
      step-dots
    
      Position within the current unit, as a row of segments. Adapted from the mobile artifact.
    
      Every visual difference comes from data-state, expressed with data-[state=...] variants,
      so the same markup serves every page and JavaScript never touches a class. States:
    
        done     filled, emerald
        current  filled, sky
        locked   empty
    
      Data hooks:
        data-step-dots   root
        data-step-dot    one segment; data-state is done | current | locked
    
      Values are server-rendered from the page data block ({{dot1}} .. {{dot4}}). Sasha would
      loop this row over the unit's lessons.
    -->
    <ol class="flex items-center gap-1" data-step-dots aria-label="Lesson progress in this unit">
      <li class="h-1 flex-1 overflow-hidden rounded-full bg-primary-800">
        <span
          class="block h-full w-0 rounded-full transition-[width] duration-300 data-[state=done]:w-full data-[state=done]:bg-emerald-500 data-[state=current]:w-full data-[state=current]:bg-sky-400"
          data-step-dot data-state="{{dot1}}"
        ></span>
      </li>
      <li class="h-1 flex-1 overflow-hidden rounded-full bg-primary-800">
        <span
          class="block h-full w-0 rounded-full transition-[width] duration-300 data-[state=done]:w-full data-[state=done]:bg-emerald-500 data-[state=current]:w-full data-[state=current]:bg-sky-400"
          data-step-dot data-state="{{dot2}}"
        ></span>
      </li>
      <li class="h-1 flex-1 overflow-hidden rounded-full bg-primary-800">
        <span
          class="block h-full w-0 rounded-full transition-[width] duration-300 data-[state=done]:w-full data-[state=done]:bg-emerald-500 data-[state=current]:w-full data-[state=current]:bg-sky-400"
          data-step-dot data-state="{{dot3}}"
        ></span>
      </li>
      <li class="h-1 flex-1 overflow-hidden rounded-full bg-primary-800">
        <span
          class="block h-full w-0 rounded-full transition-[width] duration-300 data-[state=done]:w-full data-[state=done]:bg-emerald-500 data-[state=current]:w-full data-[state=current]:bg-sky-400"
          data-step-dot data-state="{{dot4}}"
        ></span>
      </li>
    </ol>

    timer-ring.html

    src/components/timer-ring.html
    5%
    Elapsed
    1h 33m
    Remain
    28h 26m
    Show source
    <!--
      timer-ring
    
      Course time indicator. Shows progress toward the required total as a ring, plus elapsed
      and remaining readouts.
    
      Data hooks (bound by js/timer.js, never by class):
        data-timer          root; carries data-required-seconds and data-idle-limit
        data-timer-ring     the SVG circle whose stroke-dashoffset is animated
        data-timer-pct      percentage label inside the ring
        data-timer-elapsed  elapsed readout
        data-timer-remaining  remaining readout; gains a low-time colour via data-low
        data-timer-state    reflects RUNNING | PAUSED_IDLE | PAUSED_HIDDEN for styling
    
      Server-rendered values: set data-required-seconds and the initial text content. The
      script only updates them; it does not create them.
    -->
    <div
      class="flex items-center gap-3"
      data-timer
      data-required-seconds="{{requiredSeconds}}"
      data-idle-limit="120000"
    >
      <div class="relative size-11 shrink-0" data-timer-state="RUNNING">
        <svg class="size-11 -rotate-90" viewBox="0 0 44 44" aria-hidden="true">
          <circle
            class="fill-none stroke-primary-800"
            cx="22" cy="22" r="19.5" stroke-width="3"
          />
          <circle
            class="fill-none stroke-emerald-400 transition-[stroke-dashoffset] duration-500"
            cx="22" cy="22" r="19.5" stroke-width="3" stroke-linecap="round"
            stroke-dasharray="122.522" stroke-dashoffset="112"
            data-timer-ring
          />
        </svg>
        <span
          class="absolute inset-0 grid place-items-center text-[10px] font-semibold tabular-nums text-emerald-400"
          data-timer-pct
        >5%</span>
      </div>
    
      <dl class="grid grid-cols-[auto_auto] gap-x-2 text-[11px] leading-tight">
        <dt class="font-semibold uppercase tracking-wide text-slate-500">Elapsed</dt>
        <dd class="font-semibold tabular-nums text-white" data-timer-elapsed>1h 33m</dd>
        <dt class="font-semibold uppercase tracking-wide text-slate-500">Remain</dt>
        <dd
          class="font-semibold tabular-nums text-slate-300 data-[low=true]:text-amber-400"
          data-timer-remaining
          data-low="false"
        >28h 26m</dd>
      </dl>
    </div>