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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPZRrwVj6L9ckoRtK1yHAN
This commit is contained in:
Harwinder Sekhon
2026-09-10 06:56:01 -04:00
parent 05f0abca31
commit bb39771d8b
5 changed files with 496 additions and 160 deletions
+121 -15
View File
@@ -1211,19 +1211,88 @@ button, input { font-family: inherit; font-size: inherit; }
}
/* ===========================================================================
Course blurbs (from the catalogue) and the AI course advisor
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; }
.advisor { padding: 20px; margin-bottom: 32px; }
.advisor-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 4px; }
.advisor-head h2 { margin: 0; font-size: 22px; }
.advisor-sub { font: 500 15px/1.5 var(--font-ui); color: var(--ink-3); margin: 0 0 16px; }
.advisor-thread { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
/* 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: 760px; }
.advisor-msg { max-width: 100%; }
.advisor-msg.is-user {
align-self: flex-end;
background: var(--accent-soft);
@@ -1234,11 +1303,13 @@ button, input { font-family: inherit; font-size: inherit; }
white-space: pre-wrap;
word-break: break-word;
}
.advisor-msg.is-bot { align-self: stretch; max-width: none; }
.advisor-answer { font: 500 16px/1.6 var(--font-ui); color: var(--ink-2); max-width: 760px; }
.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;
@@ -1248,7 +1319,9 @@ button, input { font-family: inherit; font-size: inherit; }
}
.advisor-list { display: flex; flex-direction: column; gap: 8px; }
.advisor-list .row-card { align-items: flex-start; }
.advisor-list .row-bar { align-self: center; max-width: 220px; }
/* 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); }
@@ -1257,7 +1330,7 @@ button, input { font-family: inherit; font-size: inherit; }
.advisor-form textarea {
flex: 1;
min-height: 44px;
max-height: 180px;
max-height: 120px;
padding: 10px 14px;
border: 1px solid var(--line-strong);
border-radius: 8px;
@@ -1272,8 +1345,41 @@ button, input { font-family: inherit; font-size: inherit; }
.advisor-form .btn { height: 44px; }
.advisor-note { font: 500 13px/1.4 var(--font-ui); color: var(--ink-4); margin-top: 8px; }
@media (max-width: 900px) {
.advisor-form { flex-direction: column; align-items: stretch; }
.advisor-msg { max-width: 100%; }
.advisor-list .row-bar { display: none; }
.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; }
}