diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 59d85de..0684ab3 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -701,6 +701,68 @@ def get_attempt( ) +@router.get("/quiz/{quiz_id}/analysis") +def quiz_analysis( + quiz_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """The analysis for a session addressed by quiz rather than by attempt. + + A session nobody has sat still has an analysis: it is all zeroes and every + question is skipped, and that is the honest picture — the same page the + learner will see filled in, showing what is missing. Answering it with + "nothing to see" made a session that had never been opened look like a + different kind of object from one that had. + + Where an attempt exists, its analysis is returned instead, so the page is + the same whichever way it was reached. + """ + quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() + require_quiz_access(db, quiz, current_user) + + attempt = db.query(QuizAttempt).filter( + QuizAttempt.quiz_id == quiz_id, QuizAttempt.user_id == current_user.id, + ).order_by(QuizAttempt.completed_at.isnot(None).desc(), + QuizAttempt.started_at.desc()).first() + if attempt: + return attempt_analysis(attempt.id, db, current_user) + + questions = get_quiz_questions(db, quiz_id) + categories = {c.id: c.name for c in db.query(QuestionCategory).all()} + detail = [{ + "position": index, + "question_id": question.id, + "excerpt": (question.question_text or "")[:120], + "status": "skipped", + "difficulty": getattr(question, "difficulty", None), + "category": categories.get(getattr(question, "question_category_id", None)), + "seconds_spent": None, + "peer_percent": None, + "correct_answer": None, + } for index, question in enumerate(questions, start=1)] + + return { + "attempt_id": None, + "quiz_id": quiz_id, + "title": quiz.title, + "mode": quiz.mode, + "completed_at": None, + "total": len(detail), + "answered": 0, + "score": 0, + "percent": 0, + "seconds_total": None, + "seconds_per_question": None, + "questions": detail, + # Nothing answered is nothing to recommend from. An empty list says that + # more plainly than a list of every topic at zero per cent would. + "recommendations": [], + "plan": plan_context_for_quizzes(db, current_user.id, [quiz_id]).get(quiz_id), + "not_started": True, + } + + @router.get("/{attempt_id}/analysis") def attempt_analysis( attempt_id: int, @@ -797,4 +859,5 @@ def attempt_analysis( # Present when a study-plan block produced this session: the way back # to the plan and on to the next block. "plan": plan_context_for_quizzes(db, current_user.id, [attempt.quiz_id]).get(attempt.quiz_id), + "not_started": False, } diff --git a/backend/scripts/link_articles.py b/backend/scripts/link_articles.py index 19b25fa..07bf8b3 100644 --- a/backend/scripts/link_articles.py +++ b/backend/scripts/link_articles.py @@ -1,104 +1,255 @@ -"""Cross-reference the articles to each other. +"""Cross-reference the articles to each other, by id. The marker system exists — `[[7|Febrile seizures]]` resolves by id, survives a -rename, and shows a preview on hover — and not one article used it. Every article -was written in isolation, so a piece on croup mentions stridor and epiglottitis -and offers no way to reach either. +rename and shows a preview on hover. This reads what is written and applies it. -This reads what is already written and links it: where an article's prose names -another article, the first mention becomes a link. By id, so a later rename -cannot break it. +The first version linked the first mention in every *section*, which produced +3,560 links dominated by a handful of hub terms: Seizures 155 times, Sepsis +104, Meningitis 82. A reader does not need "seizures may occur" to be a link in +every article that mentions seizures; that is noise, and noise trains people to +stop clicking. The rule now: -Deliberately conservative, because a wrong link is worse than a missing one: + 1. **First mention per view, not per section.** Short, Long and Clinical are + read separately so each earns its own first link, but a term is linked + once within a view rather than once per heading. + 2. **Lists are jump lists; prose is not.** In a differential, causes or + complications list every distinct condition keeps its link — that is the + one place a reader wants ten links in a row. In running prose, only the + first mention. + 3. **Hub terms link from lists only.** A title mentioned across more than + HUB_SHARE of all articles is too general to be worth a jump from prose. + It still links as a list item, where it is something you might pick. + 4. **Specific beats general.** Longest title first, and a marker once made is + protected, so "Otitis media with effusion" cannot be re-cut into + "Otitis media". + 5. **Never** inside a heading, table, code span, fenced block, existing link + or marker; never an article to itself; never a title under MIN_TITLE + characters, which collide. + 6. **The educator wins.** A link whose label differs from the target's title + was written by hand and is left alone — stripping and re-linking only ever + touches links this script could have made. NEVER_LINK holds terms that + should not auto-link at all. - * only the first mention in a section, so prose is not peppered with the same - link five times; - * whole words only, case-insensitively, longest title first — "Otitis media - with effusion" wins over "Otitis media" where both would match; - * never inside an existing link, a marker, a heading, a code span or a table; - * never an article linking to itself; - * titles under four characters are skipped, since short ones collide. +Re-runnable: --apply strips the links it owns and reapplies the rule, so +changing the rule or the prose does not leave the old pass behind. docker compose exec backend python -m scripts.link_articles docker compose exec backend python -m scripts.link_articles --apply + docker compose exec backend python -m scripts.link_articles --strip --apply """ import re import sys +from collections import Counter, defaultdict + +from sqlalchemy.orm.attributes import flag_modified from app.database import SessionLocal from app.models.article import Article MIN_TITLE = 4 -# Only the body of a section, never a heading, a link, a marker or code. -SKIP = re.compile(r"(\[\[[^\]]*\]\]|\[[^\]]*\]\([^)]*\)|`[^`]*`|^\s{0,3}#{1,6}.*$|^\s*\|.*$)", re.M) +#: A title mentioned in more than this share of articles links from lists only. +HUB_SHARE = 0.25 +#: Terms that are never worth a jump, however specific the match looks. +NEVER_LINK = {"history", "examination", "management", "treatment", "prognosis"} + +MARKER = re.compile(r"\[\[(\d+)\|([^\]]+)\]\]") +#: Spans within a line that must not be linked into. +PROTECTED = re.compile(r"(\[\[[^\]]*\]\]|\[[^\]]*\]\([^)]*\)|`[^`]*`)") +HEADING = re.compile(r"^\s{0,3}#{1,6}\s") +TABLE = re.compile(r"^\s*\|") +FENCE = re.compile(r"^\s*(```|~~~)") +LIST_ITEM = re.compile(r"^\s*([-*+]|\d+[.)])\s") + +LEAD = "__lead__" # summary and the whole-article introduction -def link_text(text: str, titles: list[tuple[str, int, str]], self_id: int) -> tuple[str, int]: - """Link the first mention of each other article. Returns (text, count).""" +def word_pattern(title: str) -> re.Pattern: + """Whole-word, case-insensitive, and never biting into an existing marker.""" + return re.compile(rf"(? tuple[str, int]: + """Remove the links this script owns, leaving the words behind. + + A link is ours when its label is the target's title. Anything else — a link + an educator wrote as `[[7|this condition]]` — is left exactly as it is. + """ + if not text: + return text, 0 + removed = 0 + + def drop(match: re.Match) -> str: + nonlocal removed + title = titles_by_id.get(int(match.group(1))) + label = match.group(2) + if title and label.strip().lower() == title.strip().lower(): + removed += 1 + return label + return match.group(0) + + return MARKER.sub(drop, text), removed + + +def link_text(text, targets, self_id, seen, hubs): + """Link `text` under the rule. `seen` is shared across one view and mutated. + + `targets` is (lowered title, id, is_hub), longest first. + """ if not text: return text, 0 - # Carve out everything that must not be touched, link the rest, put it back. - holes: list[str] = [] + out, linked, in_fence = [], 0, False + list_scope: set[int] | None = None - def stash(match: re.Match) -> str: - holes.append(match.group(0)) - return f"\x00{len(holes) - 1}\x00" - - working = SKIP.sub(stash, text) - - linked = 0 - for lowered, article_id, display in titles: - if article_id == self_id: + for line in text.split("\n"): + if FENCE.match(line): + in_fence = not in_fence + out.append(line) continue - pattern = re.compile(rf"(? str: + holes.append(match.group(0)) + return f"\x00{len(holes) - 1}\x00" + + working = PROTECTED.sub(stash, line) + + for lowered, article_id, is_hub in targets: + if article_id == self_id or lowered in NEVER_LINK: + continue + if is_item: + # Rule 2: a jump list links each distinct target once per list. + if article_id in list_scope: + continue + else: + # Rule 1 and 3: prose links a target once per view, and never + # links a hub term at all. + if is_hub or article_id in seen: + continue + + match = word_pattern(lowered).search(working) + if not match: + continue + # Keep the author's casing; only the target is decided here. The + # finished marker is stashed so a shorter title cannot re-cut it. + holes.append(f"[[{article_id}|{match.group(0)}]]") + working = f"{working[:match.start()]}\x00{len(holes) - 1}\x00{working[match.end():]}" + linked += 1 + seen.add(article_id) + if is_item: + list_scope.add(article_id) + + out.append(re.sub(r"\x00(\d+)\x00", lambda m: holes[int(m.group(1))], working)) + + return "\n".join(out), linked -def main(): +def scopes(article) -> dict[str, list[dict]]: + """Sections grouped by the view they belong to; each view links independently.""" + grouped: dict[str, list[dict]] = defaultdict(list) + for section in article.sections or []: + grouped[section.get("variant") or "long"].append(section) + return grouped + + +def main() -> int: apply_changes = "--apply" in sys.argv + strip_only = "--strip" in sys.argv db = SessionLocal() try: articles = db.query(Article).all() - # Longest first: "Otitis media with effusion" must win over "Otitis media". - titles = sorted( - ((a.title.lower(), a.id, a.title) for a in articles if len(a.title or "") >= MIN_TITLE), + titles_by_id = {a.id: a.title for a in articles} + + # Strip first, always: the rule is applied to clean prose so a re-run + # cannot layer a new pass on top of an old one. + stripped = 0 + for article in articles: + for section in article.sections or []: + section["content"], n = strip_owned(section.get("content"), titles_by_id) + stripped += n + article.summary, n = strip_owned(article.summary, titles_by_id) + stripped += n + article.content, n = strip_owned(article.content, titles_by_id) + stripped += n + print(f" existing auto-links stripped: {stripped}") + + if strip_only: + if apply_changes: + for article in articles: + flag_modified(article, "sections") + db.commit() + print(" stripped and committed; prose is clean.") + else: + print("\n Re-run with --apply.") + return 0 + + candidates = sorted( + ((a.title.lower(), a.id) for a in articles if len(a.title or "") >= MIN_TITLE), key=lambda row: -len(row[0]), ) - print(f" articles: {len(articles)} linkable titles: {len(titles)}") + + # How widely each title is mentioned decides whether it is a hub. Counted + # over the corpus as written, not over the links the last run happened + # to make, so the threshold does not drift with its own output. + bodies = {} + for article in articles: + parts = [article.summary or "", article.content or ""] + parts += [s.get("content") or "" for s in (article.sections or [])] + bodies[article.id] = "\n".join(parts).lower() + + mentions: Counter[int] = Counter() + for lowered, article_id in candidates: + pattern = word_pattern(lowered) + for other_id, body in bodies.items(): + if other_id != article_id and lowered in body and pattern.search(body): + mentions[article_id] += 1 + + cutoff = max(2, int(HUB_SHARE * len(articles))) + hubs = {aid for aid, n in mentions.items() if n > cutoff} + targets = [(lowered, aid, aid in hubs) for lowered, aid in candidates] + print(f" articles: {len(articles)} linkable titles: {len(candidates)}") + print(f" hub cutoff: mentioned in more than {cutoff} articles -> {len(hubs)} hubs, list-only") + for aid in sorted(hubs, key=lambda a: -mentions[a])[:12]: + print(f" {mentions[aid]:4d} {titles_by_id[aid]}") touched = links = 0 for article in articles: - sections = article.sections or [] changed = False - for section in sections: - body, count = link_text(section.get("content"), titles, article.id) - if count: - section["content"] = body - links += count - changed = True - summary, count = link_text(article.summary, titles, article.id) - if count: - article.summary = summary - links += count - changed = True + # The lead is its own scope: it is shown above every view. + seen: set[int] = set() + article.summary, n = link_text(article.summary, targets, article.id, seen, hubs) + links += n + changed |= bool(n) + article.content, n = link_text(article.content, targets, article.id, seen, hubs) + links += n + changed |= bool(n) + + for _variant, sections in scopes(article).items(): + seen = set() + for section in sections: + section["content"], n = link_text( + section.get("content"), targets, article.id, seen, hubs) + links += n + changed |= bool(n) if changed: touched += 1 - if apply_changes: - from sqlalchemy.orm.attributes import flag_modified - - article.sections = sections - flag_modified(article, "sections") + if apply_changes: + flag_modified(article, "sections") print(f" articles gaining links: {touched}") print(f" links added : {links}") diff --git a/backend/tests/test_session_lifecycle.py b/backend/tests/test_session_lifecycle.py index c5340e2..8ec08a1 100644 --- a/backend/tests/test_session_lifecycle.py +++ b/backend/tests/test_session_lifecycle.py @@ -188,3 +188,48 @@ class SessionLifecycleTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class QuizAnalysisTests(unittest.TestCase): + """A session addressed by quiz: the same page at zero.""" + + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.db = self.bank.db + self.client.app.include_router(attempts.router, prefix="/attempts") + self.bank.user = self.bank.owner + + def tearDown(self): + self.bank.tearDown() + + def test_a_quiz_nobody_has_sat_answers_with_zeroes_and_every_question_skipped(self): + quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"] + body = self.client.get(f"/attempts/quiz/{quiz_id}/analysis").json() + self.assertTrue(body["not_started"]) + self.assertIsNone(body["attempt_id"]) + self.assertEqual((body["answered"], body["score"], body["percent"]), (0, 0, 0)) + self.assertEqual(body["total"], len(body["questions"])) + self.assertTrue(body["questions"]) + self.assertEqual({q["status"] for q in body["questions"]}, {"skipped"}) + self.assertEqual([q["position"] for q in body["questions"]], + list(range(1, len(body["questions"]) + 1))) + self.assertEqual(body["recommendations"], []) + + def test_once_sat_the_quiz_address_answers_with_the_real_analysis(self): + quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"] + self.db.add(QuizAttempt(id=910, quiz_id=quiz_id, user_id=self.bank.owner.id, mode="learning", + total_questions=1, score=1, started_at=datetime(2026, 5, 1), + completed_at=datetime(2026, 5, 1, 1))) + self.db.add(AttemptAnswer(attempt_id=910, question_id=1, user_answer="A", is_correct=True)) + self.db.commit() + body = self.client.get(f"/attempts/quiz/{quiz_id}/analysis").json() + self.assertFalse(body["not_started"]) + self.assertEqual(body["attempt_id"], 910) + self.assertEqual(body["percent"], 100) + + def test_a_quiz_the_learner_cannot_see_is_refused(self): + quiz_id = self.bank.generate(is_shared=False, category_ids=[1], count=2).json()["id"] + self.bank.user = self.bank.peer + self.assertEqual(self.client.get(f"/attempts/quiz/{quiz_id}/analysis").status_code, 403) diff --git a/frontend/src/components/AnalysisShell.css b/frontend/src/components/AnalysisShell.css new file mode 100644 index 0000000..066b749 --- /dev/null +++ b/frontend/src/components/AnalysisShell.css @@ -0,0 +1,117 @@ +/* The frame shared by the overall analysis and a single session's. + * + * It exists because both pages had their own `.an-page` and `.an-rail` rules — + * one a 280px grid, the other a 260px grid — and once the two pages shared a + * rail both stylesheets loaded together and the later one won. The content + * column collapsed to rail width and headings wrapped one letter per line. + * One owner for the layout, and the page stylesheets style only their content. + */ + +.ax-layout { + display: grid; + grid-template-columns: 288px minmax(0, 1fr); + gap: 0 28px; + align-items: start; + /* The rail bleeds to the window edge; the content keeps a readable measure. */ + margin-inline: calc(50% - 50vw); + padding-inline: max(16px, calc(50vw - 660px)); +} +.ax-layout.is-collapsed { grid-template-columns: 0 minmax(0, 1fr); gap: 0; } + +.ax-main { min-width: 0; padding: 8px 0 48px; } + +/* ── Rail ─────────────────────────────────────────────────────────── */ +.ax-rail { + position: sticky; top: 0; height: 100vh; + display: flex; flex-direction: column; min-width: 0; + background: var(--card-bg); border-right: 1px solid var(--border); + /* Reach the left edge of the window without leaving the grid. */ + margin-left: calc(0px - max(16px, calc(50vw - 660px))); + padding-left: max(16px, calc(50vw - 660px)); + box-sizing: content-box; +} +.ax-layout.is-collapsed .ax-rail { display: none; } + +.ax-rail-head { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 14px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.ax-rail-head h2 { margin: 0; font-size: 0.95rem; font-weight: 650; white-space: nowrap; } +.ax-rail-head button { + flex-shrink: 0; width: 30px; height: 30px; min-width: 30px; + display: inline-flex; align-items: center; justify-content: center; + background: none; border: 1px solid var(--border); border-radius: 50%; + cursor: pointer; color: var(--text-muted); font-size: 1rem; line-height: 1; +} +.ax-rail-head button:hover { border-color: var(--primary); color: var(--primary); } + +/* Reopening when collapsed: a tab against the left edge, not a lost control. */ +.ax-rail-show { + position: fixed; left: 0; top: 96px; z-index: 20; + padding: 10px 12px; font-size: 0.78rem; font-weight: 650; + background: var(--card-bg); color: var(--text-muted); + border: 1px solid var(--border); border-left: 0; + border-radius: 0 10px 10px 0; cursor: pointer; +} +.ax-rail-show:hover { color: var(--primary); border-color: var(--primary); } + +.ax-rail-all { + display: flex; flex-direction: column; gap: 3px; padding: 13px 16px; + text-decoration: none; color: var(--text); border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.ax-rail-all:hover { background: var(--bg); } +.ax-rail-all strong { font-size: 0.88rem; font-weight: 650; } +.ax-rail-all span { font-size: 0.76rem; color: var(--text-muted); } + +.ax-rail-search { + flex-shrink: 0; margin: 10px 16px; padding: 8px 11px; + /* 16px on touch: iOS zooms the page in on any smaller font when a field + takes focus, and never zooms back out. */ + font-size: 16px; font-family: inherit; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +@media (min-width: 900px) { .ax-rail-search { font-size: 0.84rem; } } + +.ax-rail-list { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; } +.ax-rail-list > li { border-bottom: 1px solid var(--border); } +.ax-rail-list > li:last-child { border-bottom: 0; } +.ax-rail-list a { + display: flex; flex-direction: column; gap: 7px; padding: 13px 16px; + text-decoration: none; color: var(--text); min-height: 44px; /* touch target */ +} +.ax-rail-list a:hover { background: var(--bg); } +.ax-rail-title { font-size: 0.84rem; line-height: 1.45; overflow-wrap: anywhere; } +.ax-rail-title strong { font-weight: 650; } +.ax-rail-empty { margin: 0; padding: 14px 16px; font-size: 0.83rem; color: var(--text-muted); } + +/* Which one you are reading. */ +.ax-rail-all.active, .ax-rail-list a.active { + background: var(--option-sel-bg); box-shadow: inset 3px 0 0 var(--primary); +} +.ax-rail-all.active strong, .ax-rail-list a.active .ax-rail-title { color: var(--primary); } + +/* ── Narrow ───────────────────────────────────────────────────────── */ +/* Below 1000px the rail stops being a column. It becomes a band above the + content that starts closed, because on a phone the first thing on screen + should be the analysis asked for, not a list of every other session. */ +@media (max-width: 1000px) { + .ax-layout, .ax-layout.is-collapsed { + grid-template-columns: minmax(0, 1fr); + margin-inline: 0; padding-inline: 0; gap: 0; + } + .ax-rail { + position: static; height: auto; max-height: 58vh; + margin-left: 0; padding-left: 0; + border-right: 0; border-bottom: 1px solid var(--border); + margin-bottom: 16px; + } + .ax-layout.is-collapsed .ax-rail { display: flex; max-height: none; } + .ax-layout.is-collapsed .ax-rail-list, + .ax-layout.is-collapsed .ax-rail-all, + .ax-layout.is-collapsed .ax-rail-search { display: none; } + .ax-rail-head { border-bottom: 0; } + .ax-rail-show { display: none; } + .ax-main { padding-top: 0; } +} diff --git a/frontend/src/components/AnalysisShell.jsx b/frontend/src/components/AnalysisShell.jsx new file mode 100644 index 0000000..0e3db46 --- /dev/null +++ b/frontend/src/components/AnalysisShell.jsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react' +import SessionRail from './SessionRail' +import api from '../api/client' +import './AnalysisShell.css' + +/** + * The frame both analysis views sit in: the session rail, then the content. + * + * It owns the session list as well as the layout, because both pages showed + * the same list and each fetched and sliced it differently — one took the + * latest twelve, the other all of them. + * + * The rail starts closed on a narrow screen: the first thing on a phone should + * be the analysis that was asked for, not a list of every other session. + */ +export default function AnalysisShell({ children }) { + const [sessions, setSessions] = useState([]) + const [loading, setLoading] = useState(true) + const [open, setOpen] = useState(() => ( + typeof window === 'undefined' || !window.matchMedia + ? true + : window.matchMedia('(min-width: 1001px)').matches + )) + + useEffect(() => { + let live = true + api.get('/quizzes/sessions') + .then(res => { if (live) setSessions(Array.isArray(res.data) ? res.data : []) }) + .catch(() => { if (live) setSessions([]) }) + .finally(() => { if (live) setLoading(false) }) + return () => { live = false } + }, []) + + return ( +
+ setOpen(v => !v)} /> + {!open && ( + + )} +
{children}
+
+ ) +} diff --git a/frontend/src/components/SessionRail.css b/frontend/src/components/SessionRail.css deleted file mode 100644 index 6a16ff1..0000000 --- a/frontend/src/components/SessionRail.css +++ /dev/null @@ -1,36 +0,0 @@ -/* Additions to the rail shared by the overall analysis and a single session. - The frame itself (.an-rail, .an-rail-head, .an-rail-list) is defined in - AnalysisPage.css, which both views already load. */ - -/* The overall picture, pinned above the individual sessions and separated from - them — it is the parent of the list, not the first row of it. */ -.an-rail-all { - display: flex; flex-direction: column; gap: 3px; - padding: 12px 14px; text-decoration: none; color: var(--text); - border-bottom: 1px solid var(--border); -} -.an-rail-all:hover { background: var(--bg); } -.an-rail-all strong { font-size: .85rem; font-weight: 650; } -.an-rail-all span { font-size: .75rem; color: var(--text-muted); } - -/* Which session you are reading. Without this the rail gives no clue, and on a - list of fifteen that matters more than the hover state does. */ -.an-rail-all.active, -.an-rail-list a.active { - background: var(--option-sel-bg); - box-shadow: inset 3px 0 0 var(--primary); -} -.an-rail-all.active strong, -.an-rail-list a.active .an-rail-title { color: var(--primary); } - -.an-rail-search { - width: calc(100% - 20px); margin: 10px; padding: 7px 10px; - /* 16px on touch: iOS zooms the whole page in on any smaller font when a - field takes focus, and never zooms back out. */ - font-size: .82rem; font-family: inherit; - border: 1px solid var(--border); border-radius: 7px; - background: var(--input-bg); color: var(--text); -} -@media (max-width: 720px) { - .an-rail-search { font-size: 16px; } -} diff --git a/frontend/src/components/SessionRail.jsx b/frontend/src/components/SessionRail.jsx index 15de512..51ef2ca 100644 --- a/frontend/src/components/SessionRail.jsx +++ b/frontend/src/components/SessionRail.jsx @@ -1,7 +1,6 @@ import { useMemo, useState } from 'react' import { NavLink } from 'react-router-dom' import SessionProgress from './SessionProgress' -import './SessionRail.css' /** * The list of sessions, alongside whatever session you are reading. @@ -12,7 +11,8 @@ import './SessionRail.css' * which listed the same rows a second time under a different heading. * * Nothing is truncated. A learner asking "what have I done" wants the whole - * answer, and the search box is what narrows it. + * answer, and the search box is what narrows it. Styling lives in + * AnalysisShell.css, which owns the frame this sits in. */ export default function SessionRail({ sessions, open, onToggle, loading }) { const [query, setQuery] = useState('') @@ -24,9 +24,9 @@ export default function SessionRail({ sessions, open, onToggle, loading }) { }, [sessions, query]) return ( -