Files
coursehub/assets/style.css
T
Harwinder Sekhon 76d8f259b2 Move the course advisor into a floating panel and link courses in its prose
The advisor was a card inside the home page, so reaching it meant navigating
back to #/ and it was rebuilt on every render of that view. It is now a bubble
in the bottom-right corner of every screen bar the player and a quiz, opening
a chat panel that stays put as you move around — ask what to take next, follow
a recommendation, keep asking. It is built once into #overlay-root, outside
the view that gets replaced, and syncAdvisor() called from renderChrome() is
the single path that decides whether it is on screen; that covers every route
change and every loadMeta(), and makes the Settings toggle work with no change
to its handler.

Being permanent removes the reason for the routeSeq guards in ask(). An answer
that arrived after the user navigated was written to sessionStorage and then
dropped unrendered; now it lands, and the bubble carries an unread dot if the
panel is closed. showAuth() wipes the transcript and hides the dock, both of
which are new requirements: the dock is a sibling of #app-shell, so hiding the
shell no longer hides the advisor, and the widget now outlives a sign-out.

Courses named in the answer itself are links. advisorResolveCourses() is
lifted out of advisorValidateRecommendations() so an id written inline goes
through the same existence check and the same redirect to whichever duplicate
copy the learner has progress on — a sentence and the card under it can never
point at different copies. The map is keyed by the id as the model wrote it,
since that is what the browser has to find in the text. The client splits the
raw line before escaping rather than linkifying escaped output: esc() writes
an apostrophe as ', and the course-id pattern matches the #39 inside it.

Under 900px the panel is a full-height sheet, and the mobile toast strip lifts
clear of the bubble only while the bubble is actually on screen. The z-index
sits on the dock rather than #overlay-root, which would otherwise become a
stacking context and trap the confirm dialog behind the panel.

README's advisor section is brought back in line, including the parts left
stale by 05f0abc about sections and the whole library going out per question.

Claude-Session: https://claude.ai/code/session_01LPZRrwVj6L9ckoRtK1yHAN
2026-09-10 06:56:01 -04:00

1386 lines
43 KiB
CSS

/* CourseHub — "Course Library" design system.
* Light surface, near-black ink and chrome, orange primary accent,
* green success, red danger — all tuned to clear WCAG AA (4.5:1) as both
* text-on-white and white-text-on-fill, since each is used both ways.
* Tokens first, then layout, then components. No CSS framework underneath.
*/
:root {
/* Ground and surfaces — light and clean */
--paper: #F2F0EA;
--surface: #FFFFFF;
--card: #FFFFFF;
/* Dark chrome — sidebar + mobile tab bar only; the rest of the app stays light */
--chrome-bg: #14110D;
--chrome-bg-2: #201A13;
--chrome-line: #322B21;
--chrome-ink: #F7F5F1;
--chrome-ink-dim: #A79E8F;
--chrome-hover: #221C15;
/* Brighter orange for accent TEXT directly on dark chrome (mobile active
tab) — --accent itself is tuned for use on white and is too dark there. */
--chrome-accent: #FB923C;
/* Lines, from strongest to faintest */
--line-strong: #C7C0B5;
--line: #E1DCD2;
--line-soft: #EEEAE3;
/* Ink — near-black, each step still clears 4.5:1 on white */
--ink: #100F0D;
--ink-2: #3A3630;
--ink-3: #5C564D;
--ink-4: #756D5D;
/* Accent — orange, primary actions/links/selection/in-progress */
--accent: #C2410C;
--accent-hover: #9A3209;
--accent-soft: #FEF0E4;
--accent-tint: #FDF0E3;
--accent-line: #EFAE79;
/* Success — green, completed/correct/passed */
--success: #15803D;
--success-hover: #116430;
--success-soft: #E9F7EC;
--success-tint: #EBF7EE;
--success-line: #9AD6AC;
/* Danger — red, errors/reset/wrong */
--danger: #C7222A;
--danger-hover: #A11C22;
--danger-soft: #FCE6E5;
--danger-line: #F0AAAA;
/* Hovers */
--hover: #E9E4DA;
--row-hover: #F5F2EC;
/* Course colours — one is picked per course from its id. Darkened from the
original mutes so white overlay text on a thumbnail clears 4.5:1. */
--clay: #B85A2E;
--sage: #3F7A50;
--slate: #374B5C;
--sand: #96691C;
--plum: #7A4560;
--sky: #2E6A8C;
--shadow-card: 0 1px 2px rgba(16, 15, 13, .08);
--shadow-frame: 0 4px 16px rgba(16, 15, 13, .12);
--shadow-pop: 0 8px 24px rgba(16, 15, 13, .28);
/* One typeface everywhere — headings, body and numerals/paths all render
in Inter. --font-display/--font-mono are kept as separate variables (not
collapsed into --font-ui) so weight/size rules that reference them stay
meaningful call sites, even though all three resolve to the same family. */
--font-ui: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-display: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--plyr-color-main: var(--accent);
--plyr-video-background: #100F0D;
}
*, *::before, *::after { box-sizing: border-box; }
/* Layout classes below set `display`, which would otherwise beat the UA's
`[hidden] { display: none }`. */
[hidden] { display: none !important; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--font-ui);
font-size: 16px;
}
h1, h2, h3 { font-family: var(--font-display); font-weight: 700; letter-spacing: -0.015em; margin: 0; }
h1 { font-size: 36px; line-height: 1.1; }
h2 { font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; }
h3 { font-family: var(--font-ui); font-size: 17px; line-height: 1.3; letter-spacing: 0; }
p { margin: 0; }
a { color: var(--accent); text-decoration-color: var(--accent-line); text-underline-offset: 2px; }
a:hover { color: var(--accent-hover); }
button, input { font-family: inherit; font-size: inherit; }
.mono { font-family: var(--font-mono); font-weight: 500; }
/* Small text utilities, so views don't hand-roll font sizes inline and drift
out of the scale above. */
.mono-sm { font: 500 14px/1 var(--font-mono); color: var(--ink-3); }
.hint-inline { font: 500 15px/1.4 var(--font-ui); color: var(--ink-3); }
/* --font-mono no longer points at an actual monospace face (everything is
Inter now), so durations/counts/paths would drift out of alignment as
digits change width — tabular-nums keeps every digit the same width
without needing a separate font. */
.mono, .mono-sm, .shelf-count, .thumb-dur, .row-num, .q-num, .path-row .path {
font-variant-numeric: tabular-nums;
}
.h-section { font-size: 22px; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 999px; border: 3px solid var(--surface); }
::-webkit-scrollbar-track { background: transparent; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
/* ===========================================================================
Shell: sidebar + main column (+ mobile tab bar)
========================================================================= */
.app {
display: flex;
align-items: stretch;
min-height: 100vh;
background: var(--surface);
}
.sidebar {
width: 256px;
flex: none;
background: var(--chrome-bg);
border-right: 1px solid var(--chrome-line);
display: flex;
flex-direction: column;
padding: 16px 12px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px 18px;
text-decoration: none;
color: var(--chrome-ink);
}
.brand-mark {
width: 32px; height: 32px;
border-radius: 8px;
background: var(--accent);
color: var(--chrome-ink);
display: flex; align-items: center; justify-content: center;
flex: none;
}
.brand-name { font: 700 20px/1 var(--font-display); letter-spacing: -0.01em; }
.nav { display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--chrome-ink-dim);
text-align: left;
font: 600 16px/1 var(--font-ui);
cursor: pointer;
text-decoration: none;
}
.nav-item:hover { background: var(--chrome-hover); color: var(--chrome-ink); }
.nav-item.is-active { background: var(--accent); color: var(--chrome-ink); }
.nav-item.is-active:hover { background: var(--accent-hover); color: var(--chrome-ink); }
.sidebar-rule { height: 1px; background: var(--chrome-line); margin: 16px 4px; }
.sidebar-label {
font: 600 13px/1 var(--font-ui);
letter-spacing: .08em;
text-transform: uppercase;
color: var(--chrome-ink-dim);
padding: 4px 10px 10px;
}
.shelf-list { display: flex; flex-direction: column; gap: 2px; }
.shelf-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border: 0;
border-radius: 8px;
background: transparent;
text-align: left;
font: 500 16px/1 var(--font-ui);
color: var(--chrome-ink-dim);
cursor: pointer;
text-decoration: none;
}
.shelf-item:hover { background: var(--chrome-hover); color: var(--chrome-ink); }
.shelf-dot { width: 9px; height: 9px; border-radius: 3px; flex: none; background: var(--c, var(--chrome-ink-dim)); }
.shelf-item span:nth-child(2) { flex: 1; }
.shelf-count { font: 500 14px/1 var(--font-mono); color: var(--chrome-ink-dim); }
.sidebar-spacer { flex: 1; }
.scan-card {
border: 1px solid var(--chrome-line);
background: var(--chrome-bg-2);
border-radius: 12px;
padding: 12px;
}
.scan-card-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
font: 600 14px/1 var(--font-ui);
color: var(--chrome-ink);
}
.scan-card-head .mono { font-size: 13px; color: var(--chrome-ink-dim); }
.scan-spin { display: flex; }
.is-scanning .scan-spin { animation: spin 1.4s linear infinite; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
height: 72px;
flex: none;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
gap: 16px;
padding: 0 32px;
background: rgba(255, 255, 255, .85);
backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 20;
}
.search {
flex: 1;
max-width: 520px;
display: flex;
align-items: center;
gap: 10px;
height: 44px;
padding: 0 14px;
background: var(--card);
border: 1px solid var(--line-strong);
border-radius: 8px;
}
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: 500 16px/1 var(--font-ui);
color: var(--ink);
}
.search input::-webkit-search-cancel-button { display: none; }
.topbar-end { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.topbar-totals { font-size: 14px; color: var(--ink-3); }
.avatar {
width: 34px; height: 34px;
border: 0;
border-radius: 999px;
background: var(--plum);
color: var(--surface);
font: 700 14px/1 var(--font-ui);
cursor: pointer;
flex: none;
}
.avatar:hover { filter: brightness(1.06); }
.view { flex: 1; padding: 32px; }
.loading { display: flex; justify-content: center; padding: 64px 0; }
.spinner {
width: 22px; height: 22px;
border: 2px solid var(--line-strong);
border-top-color: var(--accent);
border-radius: 999px;
display: block;
animation: spin .8s linear infinite;
}
.tabbar { display: none; }
.scan-mobile { display: none; }
/* ===========================================================================
Buttons, chips, segmented controls
========================================================================= */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 14px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--card);
color: var(--ink);
font: 600 15px/1 var(--font-ui);
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.btn:hover { background: var(--row-hover); color: var(--ink); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn-accent { background: var(--accent); border-color: var(--accent); color: var(--surface); }
.btn-accent:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--surface); }
.btn-danger { background: var(--card); border-color: var(--danger); color: var(--danger); }
.btn-danger:hover { background: var(--danger-soft); color: var(--danger); }
.btn-solid-danger { background: var(--danger); border-color: var(--danger); color: var(--surface); }
.btn-solid-danger:hover { background: var(--danger-hover); border-color: var(--danger-hover); color: var(--surface); }
.btn-light { background: var(--surface); border-color: transparent; color: var(--ink); }
.btn-light:hover { background: var(--line); }
.btn-ghost { background: transparent; border-color: rgba(250, 248, 245, .3); color: var(--surface); }
.btn-ghost:hover { background: rgba(250, 248, 245, .1); color: var(--surface); }
/* Text-only action (used on its own, not on top of .btn). */
.btn-quiet {
border: 0;
background: transparent;
padding: 0;
font: 600 15px/1 var(--font-ui);
color: var(--accent);
cursor: pointer;
text-decoration: none;
}
.btn-quiet:hover { color: var(--accent-hover); }
.btn-block { width: 100%; }
.btn-lg { padding: 12px 20px; font-size: 16px; }
.btn:disabled { opacity: .6; cursor: default; }
.icon-btn {
width: 34px; height: 34px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--ink-3);
cursor: pointer;
padding: 0;
}
.icon-btn:hover { background: var(--line); color: var(--ink); }
.seg {
display: flex;
padding: 3px;
background: var(--paper);
border-radius: 9px;
gap: 2px;
}
.seg button {
padding: 7px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--ink-3);
font: 600 15px/1 var(--font-ui);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.seg button.is-on { background: var(--card); color: var(--ink); box-shadow: var(--shadow-card); }
.seg .seg-icon { width: 38px; height: 34px; padding: 0; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 22px; }
.chip {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 13px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--card);
color: var(--ink-2);
font: 600 15px/1 var(--font-ui);
cursor: pointer;
text-decoration: none;
}
.chip:hover { background: var(--row-hover); color: var(--ink); }
.chip.is-on:hover { background: var(--ink); color: var(--surface); }
.chip .dot { width: 7px; height: 7px; border-radius: 999px; background: var(--c, var(--accent)); }
.chip.is-on { background: var(--ink); border-color: var(--ink); color: var(--surface); }
.pill {
font: 600 13px/1 var(--font-ui);
padding: 5px 9px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
white-space: nowrap;
}
.pill-quiet { background: var(--line-soft); color: var(--ink-3); }
/* ===========================================================================
Page furniture
========================================================================= */
.page { max-width: 1120px; }
.page-narrow { max-width: 760px; }
.page-mid { max-width: 900px; }
.eyebrow {
font: 600 14px/1 var(--font-ui);
letter-spacing: .08em;
text-transform: uppercase;
color: var(--ink-3);
}
.eyebrow-sm { font-size: 13px; letter-spacing: .09em; }
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.page-sub { font: 500 16px/1.5 var(--font-ui); color: var(--ink-2); margin-top: 6px; }
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.shelf-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.shelf-head .swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--c, var(--accent)); }
.shelf-head h2 { margin: 0; font-size: 22px; }
.shelf-head .rule { flex: 1; height: 1px; background: var(--line); }
.shelf-head .mono { font-size: 14px; color: var(--ink-3); }
.crumbs {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font: 500 15px/1 var(--font-ui);
color: var(--ink-3);
margin-bottom: 14px;
}
.crumbs a { color: var(--ink-2); text-decoration: none; }
.crumbs a:hover { color: var(--accent); }
.crumbs .sep { color: var(--ink-4); }
.crumbs .here { color: var(--ink); font-weight: 600; }
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
padding: 0;
margin-bottom: 16px;
font: 600 15px/1 var(--font-ui);
color: var(--ink-2);
cursor: pointer;
text-decoration: none;
}
.back-link:hover { color: var(--accent); }
.empty {
border: 1px dashed var(--line-strong);
border-radius: 12px;
padding: 56px 24px;
text-align: center;
}
.empty h2 { font-size: 22px; margin-bottom: 8px; }
.empty p { font: 500 16px/1.5 var(--font-ui); color: var(--ink-2); margin: 0 0 16px; }
/* ===========================================================================
Course cards, thumbnails, progress
========================================================================= */
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.grid-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 32px; }
.stack { display: flex; flex-direction: column; gap: 8px; }
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px;
}
.stat-label { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.stat-label .swatch { width: 8px; height: 8px; border-radius: 3px; background: var(--c, var(--accent)); }
.stat-label span { font: 600 14px/1 var(--font-ui); color: var(--ink-2); }
.stat-value { font: 700 32px/1 var(--font-display); letter-spacing: -0.01em; }
.stat-sub { font: 500 14px/1.4 var(--font-ui); color: var(--ink-3); margin-top: 6px; }
.course-card {
display: block;
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
cursor: pointer;
text-decoration: none;
color: inherit;
}
.course-card:hover { box-shadow: var(--shadow-card); border-color: var(--line-strong); color: inherit; }
.course-card-body { padding: 10px 4px 4px; }
.course-title { font: 700 16px/1.3 var(--font-ui); }
.course-meta { font: 500 14px/1.4 var(--font-ui); color: var(--ink-3); margin-top: 3px; }
/* Colour block standing in for cover art: two soft blobs over a course hue. */
.thumb {
position: relative;
aspect-ratio: 16 / 10;
border-radius: 8px;
overflow: hidden;
background: var(--c, var(--slate));
}
.thumb::before, .thumb::after {
content: '';
position: absolute;
aspect-ratio: 1;
border-radius: 999px;
z-index: 0;
}
.thumb::before { top: -30%; right: -12%; width: 62%; background: rgba(255, 255, 255, .2); }
.thumb::after { left: -8%; bottom: -32%; width: 48%; background: rgba(16, 15, 13, .14); }
.thumb-cat {
position: absolute;
left: 10px; top: 8px;
font: 700 13px/1 var(--font-ui);
letter-spacing: .06em;
text-transform: uppercase;
color: rgba(255, 255, 255, .85);
text-shadow: 0 1px 3px rgba(16, 15, 13, .55);
z-index: 2;
}
.thumb-dur {
position: absolute;
right: 8px; bottom: 8px;
font: 500 13px/1 var(--font-mono);
color: var(--surface);
background: rgba(16, 15, 13, .55);
padding: 4px 7px;
border-radius: 999px;
z-index: 2;
}
.thumb-play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.thumb-play span {
width: 58px; height: 58px;
border-radius: 999px;
background: rgba(250, 248, 245, .92);
color: var(--ink);
display: flex;
align-items: center;
justify-content: center;
}
.thumb-bar {
position: absolute;
inset: auto 0 0 0;
height: 4px;
background: rgba(255, 255, 255, .28);
z-index: 2;
}
.thumb-bar span { display: block; height: 100%; background: var(--surface); }
.initial {
width: 42px; height: 42px;
flex: none;
border-radius: 8px;
background: var(--c, var(--slate));
color: var(--surface);
display: flex;
align-items: center;
justify-content: center;
font: 700 17px/1 var(--font-display);
}
.initial-sm { width: 38px; height: 38px; font-size: 16px; }
.initial-lg { width: 50px; height: 50px; font-size: 20px; }
.bar { flex: 1; height: 5px; border-radius: 999px; background: var(--line); overflow: hidden; }
.bar span { display: block; height: 100%; border-radius: 999px; background: var(--c, var(--accent)); }
.bar-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.bar-row .mono { font-size: 13px; color: var(--ink-3); }
.bar-lg { height: 6px; }
/* ===========================================================================
Rows and lists
========================================================================= */
.rows {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 0 16px;
min-height: 62px;
border-bottom: 1px solid var(--line-soft);
text-decoration: none;
color: inherit;
cursor: pointer;
}
.row:last-child { border-bottom: 0; }
.row:hover { background: var(--row-hover); color: inherit; }
.row-main { flex: 1; min-width: 0; }
.row-title {
font: 600 16px/1.3 var(--font-ui);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-sub {
font: 500 14px/1.3 var(--font-ui);
color: var(--ink-3);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-fixed { width: 320px; flex: none; min-width: 0; }
.row-col { font: 500 14px/1 var(--font-ui); color: var(--ink-2); }
.row-num { font: 500 14px/1 var(--font-mono); color: var(--ink-3); }
.row-bar { flex: 1; display: flex; align-items: center; gap: 10px; min-width: 120px; }
.row-bar .mono { width: 38px; text-align: right; font-size: 13px; color: var(--ink-3); }
/* A row whose trailing control (reset) sits outside the link target. */
.row-link {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
min-width: 0;
padding: 9px 0;
text-decoration: none;
color: inherit;
}
.row-link:hover { color: inherit; }
/* Free-standing rows (search hits, recent additions) sit apart from each other. */
.row-card {
display: flex;
align-items: center;
gap: 14px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px 14px;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.row-card:hover { background: var(--row-hover); color: inherit; }
.play-dot {
width: 30px; height: 30px;
flex: none;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: var(--line-soft);
color: var(--ink-3);
}
.play-dot.is-done { background: var(--success-soft); color: var(--success); }
.play-dot.is-partial { background: var(--accent-tint); color: var(--accent); }
/* ===========================================================================
Home hero
========================================================================= */
.hero {
display: flex;
gap: 20px;
background: var(--ink);
border-radius: 16px;
padding: 20px;
color: var(--surface);
margin-bottom: 14px;
text-decoration: none;
}
.hero:hover { color: var(--surface); }
.hero-art {
display: block;
width: 280px; flex: none;
position: relative;
border-radius: 10px;
overflow: hidden;
}
/* The hero art is a link straight into the player, so its play badge has to
read as a button — the rest of the thumb art is decoration. */
.hero-art .thumb-play span { transition: transform .12s ease, background-color .12s ease; }
.hero-art:hover .thumb-play span { background: var(--surface); transform: scale(1.06); }
.hero-art:focus-visible { outline: 2px solid var(--surface); outline-offset: 2px; }
.hero-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
}
.hero-eyebrow {
font: 600 13px/1 var(--font-ui);
letter-spacing: .1em;
text-transform: uppercase;
/* The hero card has a dark (var(--ink)) background, so its dim text needs
the light-on-dark token, not --ink-4 (tuned for dark-on-light). */
color: var(--chrome-ink-dim);
}
.hero-title { font: 700 28px/1.15 var(--font-display); letter-spacing: -0.01em; }
.hero-sub { font: 500 16px/1.5 var(--font-ui); color: var(--line-strong); }
.hero-progress { display: flex; align-items: center; gap: 12px; margin-top: 4px; }
.hero-progress .bar { max-width: 320px; height: 6px; background: rgba(250, 248, 245, .18); }
.hero-progress .bar span { background: var(--sage); }
.hero-progress .mono { font-size: 14px; color: var(--line-strong); }
.hero-actions { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
/* ===========================================================================
Watch screen
========================================================================= */
.watch { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 24px; align-items: start; }
.player-frame { background: var(--ink); border-radius: 16px; padding: 12px; }
.player-frame .plyr { border-radius: 10px; overflow: hidden; }
.watch-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
flex-wrap: wrap;
}
.watch-head h1 { font-size: 26px; }
.watch-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.watch-note { font: 500 15px/1 var(--font-ui); color: var(--ink-3); margin-top: 10px; display: flex; align-items: center; gap: 6px; }
.playlist { background: var(--card); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.playlist-head {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
font: 600 15px/1.3 var(--font-ui);
display: flex;
align-items: center;
gap: 8px;
}
.playlist-scroll { max-height: 60vh; overflow-y: auto; }
.playlist .row { min-height: 58px; padding: 0 14px; }
.playlist .row.is-active { background: var(--accent-soft); }
.playlist .row.is-active .row-title { color: var(--accent); font-weight: 700; }
/* ===========================================================================
Quiz
========================================================================= */
.quiz-score {
display: flex;
align-items: center;
gap: 18px;
border-radius: 12px;
padding: 18px 20px;
margin-bottom: 22px;
background: var(--success-tint);
border: 1px solid var(--success-line);
flex-wrap: wrap;
}
.quiz-score.is-fail { background: var(--danger-soft); border-color: var(--danger-line); }
.quiz-score-num { font: 700 44px/1 var(--font-display); color: var(--success); }
.quiz-score.is-fail .quiz-score-num { color: var(--danger); }
.quiz-score-body { flex: 1; min-width: 180px; }
.quiz-score-head { font: 700 17px/1.3 var(--font-ui); color: var(--success); }
.quiz-score.is-fail .quiz-score-head { color: var(--danger); }
.quiz-score-sub { font: 500 15px/1.4 var(--font-ui); color: var(--ink-2); margin-top: 3px; }
.q-card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px 20px;
}
.q-card.is-right { border-color: var(--success-line); }
.q-card.is-wrong { border-color: var(--danger-line); }
.q-head { display: flex; gap: 12px; margin-bottom: 14px; }
.q-num {
width: 28px; height: 28px;
flex: none;
border-radius: 999px;
background: var(--line-soft);
color: var(--ink-2);
display: flex;
align-items: center;
justify-content: center;
font: 600 14px/1 var(--font-mono);
}
.q-card.is-right .q-num { background: var(--success-soft); color: var(--success); }
.q-card.is-wrong .q-num { background: var(--danger-soft); color: var(--danger); }
.q-text { font: 600 17px/1.4 var(--font-ui); text-wrap: pretty; }
.q-note { font: 500 15px/1.4 var(--font-ui); margin-top: 6px; color: var(--success); }
.q-note.is-wrong { color: var(--danger); }
/* The diagram a question refers to. It lives on the course provider's server,
so it may not load on an offline install — then the caption's link remains. */
.q-exhibit { margin: 12px 0 0; }
.q-exhibit img {
display: block; max-width: 100%; height: auto;
border: 1px solid var(--line); border-radius: 8px; background: var(--paper);
}
.q-exhibit figcaption { margin-top: 6px; font: 500 13px/1.4 var(--font-ui); color: var(--ink-4); }
.q-exhibit figcaption a { color: var(--ink-3); }
.q-exhibit.is-broken img { display: none; }
.q-exhibit.is-broken figcaption::before { content: 'Exhibit could not be loaded — '; color: var(--danger); }
.q-options { display: flex; flex-direction: column; gap: 8px; padding-left: 40px; }
.q-option {
display: flex;
align-items: center;
gap: 11px;
padding: 11px 13px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--card);
color: var(--ink);
text-align: left;
font: 500 16px/1.4 var(--font-ui);
cursor: pointer;
width: 100%;
}
.q-option:hover { background: var(--row-hover); }
.q-option .mark {
width: 18px; height: 18px;
flex: none;
border-radius: 999px;
border: 1.5px solid var(--ink-4);
background: transparent;
}
.q-option.multi .mark { border-radius: 5px; }
.q-option .label { flex: 1; }
.q-option .tag { font: 600 14px/1 var(--font-ui); color: var(--ink-3); }
.q-option.is-picked { background: var(--accent-soft); border-color: var(--accent); }
.q-option.is-picked .mark { border-color: var(--accent); background: var(--accent); }
.q-option.is-correct { background: var(--success-tint); border-color: var(--success); }
.q-option.is-correct .mark { border-color: var(--success); background: var(--success); }
.q-option.is-correct .tag { color: var(--success); }
.q-option.is-missed { background: var(--danger-soft); border-color: var(--danger); }
.q-option.is-missed .mark { border-color: var(--danger); background: var(--danger); }
.q-option.is-missed .tag { color: var(--danger); }
.q-option:disabled { cursor: default; }
.quiz-actions { display: flex; align-items: center; gap: 14px; margin: 22px 0 8px; flex-wrap: wrap; }
.quiz-actions .hint { font: 500 15px/1 var(--font-ui); color: var(--ink-3); }
/* ===========================================================================
Settings
========================================================================= */
.settings-section { padding: 20px; margin-bottom: 16px; }
.settings-section h3 { margin-bottom: 4px; }
.settings-section > p { font: 500 15px/1.5 var(--font-ui); color: var(--ink-3); margin-bottom: 14px; }
.settings-section.is-danger { border-color: var(--danger-line); }
.settings-section.is-danger h3 { color: var(--danger); }
.settings-section.is-danger > p { color: var(--ink-2); }
.path-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
}
.path-row .path { flex: 1; min-width: 0; font: 500 15px/1.4 var(--font-mono); color: var(--ink-2); word-break: break-all; }
.path-row .count { font: 500 14px/1 var(--font-ui); color: var(--ink-3); white-space: nowrap; }
.toggle-row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 0;
border-top: 1px solid var(--line-soft);
}
.toggle-row .label { font: 600 16px/1.3 var(--font-ui); }
.toggle-row .sub { font: 500 15px/1.4 var(--font-ui); color: var(--ink-3); margin-top: 2px; }
.switch {
width: 42px; height: 24px;
flex: none;
border: 0;
border-radius: 999px;
padding: 3px;
cursor: pointer;
display: flex;
justify-content: flex-start;
background: var(--line-strong);
transition: background 180ms cubic-bezier(.2, 0, 0, 1);
}
.switch span {
width: 18px; height: 18px;
border-radius: 999px;
background: var(--card);
box-shadow: 0 1px 2px rgba(16, 15, 13, .2);
transition: transform 180ms cubic-bezier(.2, 0, 0, 1);
}
.switch.is-on { background: var(--accent); }
.switch.is-on span { transform: translateX(18px); }
.fact-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--line-soft);
font: 500 15px/1.4 var(--font-ui);
color: var(--ink-2);
}
.fact-row .mono { color: var(--ink-3); font-size: 15px; }
/* ===========================================================================
Toast, modal, account menu
========================================================================= */
.toasts {
position: fixed;
left: 24px;
bottom: 24px;
z-index: 60;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
display: flex;
align-items: center;
gap: 12px;
background: var(--ink);
color: var(--surface);
padding: 13px 16px;
border-radius: 10px;
box-shadow: var(--shadow-pop);
animation: rise 180ms cubic-bezier(.2, 0, 0, 1);
font: 500 16px/1.35 var(--font-ui);
max-width: 380px;
}
.toast .glyph { flex: none; display: flex; color: #4ADE80; }
.toast.is-error .glyph { color: #F87171; }
.overlay {
position: fixed;
inset: 0;
background: rgba(16, 15, 13, .4);
display: flex;
align-items: center;
justify-content: center;
z-index: 70;
padding: 20px;
}
.dialog {
width: 420px;
max-width: 100%;
background: var(--card);
border-radius: 16px;
padding: 24px;
box-shadow: var(--shadow-pop);
animation: rise 160ms cubic-bezier(.2, 0, 0, 1);
}
.dialog h3 { font-family: var(--font-display); font-size: 22px; line-height: 1.2; margin-bottom: 8px; }
.dialog p { font: 500 16px/1.5 var(--font-ui); color: var(--ink-2); margin-bottom: 20px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; }
.menu {
position: fixed;
z-index: 65;
min-width: 190px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: var(--shadow-pop);
padding: 6px;
}
.menu-user {
padding: 8px 10px 10px;
border-bottom: 1px solid var(--line-soft);
margin-bottom: 6px;
}
.menu-user .who { font: 700 15px/1.3 var(--font-ui); }
.menu-user .what { font: 500 14px/1.3 var(--font-ui); color: var(--ink-3); margin-top: 2px; }
.menu button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--ink-2);
font: 600 15px/1 var(--font-ui);
text-align: left;
cursor: pointer;
}
.menu button:hover { background: var(--row-hover); color: var(--ink); }
.menu button.is-danger { color: var(--danger); }
.menu button.is-danger:hover { background: var(--danger-soft); }
/* ===========================================================================
Auth
========================================================================= */
.auth {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.auth-card {
width: 380px;
max-width: 100%;
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow-frame);
padding: 28px;
}
.auth-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }
.auth-card h1 { font-size: 26px; margin-bottom: 6px; }
.auth-card .sub { font: 500 16px/1.5 var(--font-ui); color: var(--ink-3); margin-bottom: 20px; }
.field { margin-bottom: 14px; }
.field label {
display: block;
font: 600 14px/1 var(--font-ui);
color: var(--ink-2);
margin-bottom: 6px;
}
.field input {
width: 100%;
height: 44px;
padding: 0 14px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
outline: none;
}
.field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.auth-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 18px; }
.alert {
background: var(--danger-soft);
border: 1px solid var(--danger-line);
color: var(--danger);
border-radius: 8px;
padding: 10px 12px;
font: 500 15px/1.4 var(--font-ui);
margin-bottom: 14px;
}
/* ===========================================================================
Responsive — the design's 390px frame informs everything below 900px
========================================================================= */
/* Larger type needs more room in the library list row: drop the state column
first, then narrow the title column, before the mobile rules take over. */
@media (max-width: 1280px) {
.row-col { display: none; }
}
@media (max-width: 1100px) {
.grid, .grid-stats { grid-template-columns: repeat(3, 1fr); }
.watch { grid-template-columns: minmax(0, 1fr); }
.playlist-scroll { max-height: 42vh; }
.row-fixed { width: 240px; }
}
@media (max-width: 900px) {
.app { flex-direction: column; }
.sidebar { display: none; }
.view { padding: 16px 20px 96px; }
.topbar { height: 64px; padding: 0 20px; gap: 10px; }
.search { max-width: none; border-radius: 999px; height: 44px; }
.topbar-totals { display: none; }
.scan-mobile {
display: flex;
width: 42px; height: 42px;
background: var(--accent);
color: var(--surface);
}
.scan-mobile:hover { background: var(--accent-hover); color: var(--surface); }
.is-scanning .scan-mobile svg { animation: spin 1.4s linear infinite; }
.grid, .grid-stats { grid-template-columns: 1fr 1fr; gap: 12px; }
h1 { font-size: 28px; }
.hero { flex-direction: column; padding: 12px; border-radius: 14px; }
.hero-art { width: 100%; }
.hero-title { font-size: 22px; }
.row-fixed, .row-col { display: none; }
.q-options { padding-left: 0; }
.toasts { left: 16px; right: 16px; bottom: 84px; }
.toast { max-width: none; }
.tabbar {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 40;
display: flex;
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--chrome-line);
background: rgba(20, 17, 13, .94);
backdrop-filter: blur(12px);
}
.tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
border: 0;
background: transparent;
padding: 6px 0;
cursor: pointer;
font: 600 13px/1 var(--font-ui);
color: var(--chrome-ink-dim);
text-decoration: none;
}
.tab.is-active { color: var(--chrome-accent); }
}
@media (max-width: 560px) {
.grid-stats { grid-template-columns: 1fr 1fr; }
.page-head { align-items: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
.is-scanning .scan-spin, .is-scanning .scan-mobile svg { animation: spin 1.4s linear infinite !important; }
}
/* Boot failure report (index.php installs the handler that creates this).
Deliberately self-contained — no custom properties — because the whole
point is to stay readable when the app never started. */
#boot-error {
position: fixed;
inset: 16px;
z-index: 9999;
overflow: auto;
margin: 0;
padding: 20px;
border: 1px solid #B84A3C;
border-radius: 10px;
background: #FFF6F4;
color: #5A1E16;
font: 500 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
}
/* ===========================================================================
Course blurbs (from the catalogue) and the floating AI course advisor
========================================================================= */
.course-about { margin: 0 0 22px; max-width: 820px; }
.course-about p { font: 500 16px/1.55 var(--font-ui); color: var(--ink-2); margin: 0 0 10px; }
.chips-inline { display: flex; flex-wrap: wrap; gap: 6px; }
/* The dock carries the z-index, not #overlay-root: giving the root a z-index
would make it a stacking context and trap .overlay (70) inside it at 50,
putting a confirm dialog behind the panel. 50 sits above the mobile tabbar
(40) and below the toasts (60), the account menu (65) and .overlay (70). */
.advisor-dock {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 50;
display: flex;
flex-direction: column-reverse; /* the bubble is first in the DOM, for tab order */
align-items: flex-end;
gap: 12px;
}
.advisor-scrim { display: none; } /* the sheet backdrop, mobile only */
.advisor-fab {
position: relative;
flex: none;
width: 56px;
height: 56px;
padding: 0;
border: 0;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: var(--surface);
box-shadow: var(--shadow-pop);
cursor: pointer;
}
.advisor-fab:hover { background: var(--accent-hover); }
.advisor-fab:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.advisor-dot {
position: absolute;
top: 10px;
right: 10px;
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--surface);
border: 2px solid var(--accent);
}
.advisor-panel {
width: 380px;
max-width: calc(100vw - 48px);
max-height: min(70vh, 640px);
display: flex;
flex-direction: column;
padding: 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--card);
box-shadow: var(--shadow-pop);
animation: rise 160ms cubic-bezier(.2, 0, 0, 1);
}
.advisor-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.advisor-head h2 { flex: 1; margin: 0; font-size: 18px; }
.advisor-sub { font: 500 14px/1.5 var(--font-ui); color: var(--ink-3); margin: 0 0 12px; }
/* Once there is a conversation to read, the standing hint is just wasted height. */
.advisor-dock.has-turns .advisor-sub { display: none; }
.advisor-thread {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
gap: 14px;
margin: 0 -4px 14px;
padding: 0 4px;
}
.advisor-thread:empty { display: none; }
.advisor-msg { max-width: 100%; }
.advisor-msg.is-user {
align-self: flex-end;
background: var(--accent-soft);
color: var(--ink);
border-radius: 12px 12px 4px 12px;
padding: 10px 14px;
font: 500 15px/1.5 var(--font-ui);
white-space: pre-wrap;
word-break: break-word;
}
.advisor-msg.is-bot { align-self: stretch; }
.advisor-answer { font: 500 15px/1.55 var(--font-ui); color: var(--ink-2); }
.advisor-answer p { margin: 0 0 10px; }
.advisor-answer ul { margin: 0 0 10px 22px; padding: 0; }
.advisor-answer li { margin: 3px 0; }
.advisor-answer a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
.advisor-answer a:hover { color: var(--accent-hover); }
.advisor-label {
font: 600 13px/1 var(--font-ui);
letter-spacing: .06em;
text-transform: uppercase;
color: var(--ink-3);
margin: 14px 0 8px;
}
.advisor-list { display: flex; flex-direction: column; gap: 8px; }
.advisor-list .row-card { align-items: flex-start; }
/* The panel is too narrow for a progress bar; the percentage carries it. */
.advisor-list .row-bar { flex: none; min-width: 0; align-self: center; }
.advisor-list .row-bar .bar { display: none; }
.advisor-reason { font: 500 14px/1.45 var(--font-ui); color: var(--ink-3); margin-top: 3px; }
.advisor-prereqs { display: flex; flex-wrap: wrap; gap: 6px; }
.advisor-pending { display: flex; align-items: center; gap: 10px; font: 500 15px/1 var(--font-ui); color: var(--ink-3); }
.advisor-msg .alert { margin-bottom: 0; }
.advisor-form { display: flex; gap: 10px; align-items: flex-end; }
.advisor-form textarea {
flex: 1;
min-height: 44px;
max-height: 120px;
padding: 10px 14px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
font: 500 15px/1.5 var(--font-ui);
resize: vertical;
outline: none;
}
.advisor-form textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.advisor-form textarea:disabled { opacity: .6; }
.advisor-form .btn { height: 44px; }
.advisor-note { font: 500 13px/1.4 var(--font-ui); color: var(--ink-4); margin-top: 8px; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 900px) {
.advisor-dock { right: 16px; bottom: calc(76px + env(safe-area-inset-bottom)); }
/* The mobile toast strip runs the full width at bottom 84px, which is exactly
where the bubble is. Move it only while the bubble is actually on screen. */
.has-advisor .toasts { bottom: calc(148px + env(safe-area-inset-bottom)); }
/* A panel anchored to a corner has nowhere to go on a phone, so it takes the
screen instead and the bubble gets out of its way. */
.advisor-dock.is-open .advisor-fab { display: none; }
.advisor-scrim { display: block; position: fixed; inset: 0; background: rgba(16, 15, 13, .4); }
/* Only the sheet needs this: the corner panel does not cover the page. */
body.advisor-sheet-open { overflow: hidden; }
.advisor-panel {
position: fixed;
inset: 0;
width: auto;
max-width: none;
height: 100dvh;
max-height: none;
border: 0;
border-radius: 0;
padding: 16px 16px calc(16px + env(safe-area-inset-bottom));
}
.advisor-thread:empty { display: block; }
.advisor-form { flex-direction: column; align-items: stretch; }
}