Revisione/app/pages/course/[id].vue

1102 lines
37 KiB
Vue

<script setup lang="ts">
const route = useRoute();
const courseId = route.params.id as string;
const { data: course, error, refresh } = await useAsyncData(
`course-${courseId}`,
() => $fetch<any>(`/api/courses/${courseId}`)
);
let pollTimer: ReturnType<typeof setInterval> | null = null;
function hasUnreadyTopics(): boolean {
return course.value?.topics?.some((t: any) => t.status === "generating") ?? false;
}
onMounted(() => {
if (course.value?.status === "processing" || hasUnreadyTopics()) startPolling();
});
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(async () => {
await refresh();
if (course.value?.status !== "processing" && !hasUnreadyTopics()) stopPolling();
}, 3000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
onUnmounted(stopPolling);
type StageKey = "parsing_pdfs" | "analysing_sources" | "building_curriculum" | "finalising";
const STEPS: { key: StageKey; title: string; subtitle: string }[] = [
{ key: "parsing_pdfs", title: "Reading your documents", subtitle: "Extracting content from uploaded PDFs" },
{ key: "analysing_sources", title: "Identifying key topics", subtitle: "Scanning past papers & lab worksheets" },
{ key: "building_curriculum", title: "Building your curriculum", subtitle: "Ordering topics from fundamentals up" },
{ key: "finalising", title: "Putting it all together", subtitle: "Saving your personalised course" },
];
const STAGE_ORDER: Record<string, number> = {
parsing_pdfs: 0,
analysing_sources: 1,
building_curriculum: 2,
finalising: 3,
ready: 4,
error: 4,
};
function stageIndex(stage: string | null | undefined): number {
return STAGE_ORDER[stage ?? "parsing_pdfs"] ?? 0;
}
function stepState(i: number, s: string | null | undefined): "done" | "active" | "pending" {
const cur = stageIndex(s);
if (i < cur) return "done";
if (i === cur) return "active";
return "pending";
}
const TIPS = [
"Topics from past papers are weighted more heavily",
"We build from the ground up — starting with the fundamentals",
"Lab worksheets inform the practical skills you'll be tested on",
"Your course is tailored to your exact exam style",
"The more past papers you upload, the better the course",
];
const tipIndex = ref(0);
const tipVisible = ref(true);
let tipTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
tipTimer = setInterval(() => {
tipVisible.value = false;
setTimeout(() => { tipIndex.value = (tipIndex.value + 1) % TIPS.length; tipVisible.value = true; }, 350);
}, 4000);
});
onUnmounted(() => { if (tipTimer) clearInterval(tipTimer); });
function getTopicProgress(topicId: string): any {
if (!import.meta.client) return null;
try {
const p = localStorage.getItem(`progress:${topicId}`);
return p ? JSON.parse(p) : null;
} catch { return null; }
}
function completedCount(): number {
return course.value?.topics?.filter((t: any) => getTopicProgress(t.id)?.lessonComplete).length ?? 0;
}
const auditReport = computed(() => {
if (!course.value?.auditReport) return null;
try { return JSON.parse(course.value.auditReport); } catch { return null; }
});
const auditHidden = ref(false);
const lessonQualityExpanded = ref(false);
if (import.meta.client) {
auditHidden.value = localStorage.getItem("revisione-audit-hidden") === "true";
}
function toggleAudit() {
auditHidden.value = !auditHidden.value;
if (import.meta.client) localStorage.setItem("revisione-audit-hidden", String(auditHidden.value));
}
function scoreColor(score: number): string {
if (score >= 80) return "var(--green)";
if (score >= 50) return "oklch(65% 0.14 65)";
return "oklch(52% 0.14 15)";
}
const editingTitle = ref(false);
const titleDraft = ref("");
// ── dev dropdown ──────────────────────────────────────────────────────────
const devOpenId = ref<string | null>(null);
const devKey = ref("");
const devPromptTopicId = ref<string | null>(null);
const devPendingAction = ref<"regenerate-lesson" | "regenerate-audio" | null>(null);
const devLoading = ref(false);
const devMsg = ref<{ text: string; ok: boolean } | null>(null);
function openDevPrompt(topicId: string, action: "regenerate-lesson" | "regenerate-audio") {
devOpenId.value = null;
devPromptTopicId.value = topicId;
devPendingAction.value = action;
devMsg.value = null;
}
async function runDevAction() {
if (!devPromptTopicId.value || !devPendingAction.value || devLoading.value) return;
devLoading.value = true;
devMsg.value = null;
try {
const result = await $fetch<{ status: string }>(
`/api/topics/${devPromptTopicId.value}/dev/${devPendingAction.value}`,
{ method: "POST", headers: { "x-license-key": devKey.value } }
);
if (result.status === "ready") {
devMsg.value = { text: "Done.", ok: true };
await refresh();
setTimeout(() => {
devPromptTopicId.value = null;
devKey.value = "";
devPendingAction.value = null;
devMsg.value = null;
}, 900);
} else {
devMsg.value = { text: "Returned an error — check server logs.", ok: false };
}
} catch (err: any) {
devMsg.value = {
text: err?.statusCode === 401 ? "Invalid license key." : "Request failed — check server logs.",
ok: false,
};
} finally {
devLoading.value = false;
}
}
function startEditTitle() {
titleDraft.value = course.value?.title ?? "";
editingTitle.value = true;
nextTick(() => (document.querySelector(".title-input") as HTMLInputElement)?.focus());
}
async function saveTitle() {
editingTitle.value = false;
const val = titleDraft.value.trim();
if (!val || val === course.value?.title) return;
await $fetch(`/api/courses/${courseId}`, {
method: "PATCH",
body: { title: val },
});
if (course.value) course.value.title = val;
}
</script>
<template>
<main class="course-page">
<!-- header -->
<header class="course-header">
<div class="header-inner">
<NuxtLink to="/" class="back-link">
<svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M15 19l-7-7 7-7"/>
</svg>
Home
</NuxtLink>
<span class="header-logo">Revisi<span style="color: var(--accent);">.one</span></span>
</div>
</header>
<div class="page-body">
<!-- dev audit panel -->
<div v-if="auditReport && !auditHidden" class="audit-panel">
<div class="audit-header">
<span class="audit-badge">DEV AUDIT</span>
<div class="audit-score" :style="{ color: scoreColor(auditReport.overallScore) }">
{{ auditReport.overallScore }}<span class="audit-score-denom"> / 100</span>
</div>
<button class="audit-hide-btn" @click="toggleAudit">Hide audit</button>
</div>
<div class="audit-standard-banner" :class="auditReport.passesStandard ? 'audit-standard-banner--pass' : 'audit-standard-banner--fail'">
{{ auditReport.passesStandard ? 'EXAM READY ✓' : 'NOT EXAM READY ✗' }}
</div>
<p class="audit-readiness">{{ auditReport.examReadiness }}</p>
<div class="audit-coverage">
Coverage: <strong>{{ auditReport.coverageAnalysis?.coveredTopics }}</strong> of
<strong>{{ auditReport.coverageAnalysis?.totalExaminedTopics }}</strong> examined topics
({{ auditReport.coverageAnalysis?.coveragePercent }}%)
</div>
<div v-if="auditReport.unansweredPaperQuestions?.length" class="audit-section">
<p class="audit-section-title">Unanswered Past Paper Questions</p>
<table class="audit-table">
<thead><tr><th>Source</th><th>Question</th><th>What's missing</th></tr></thead>
<tbody>
<tr v-for="(q, i) in auditReport.unansweredPaperQuestions" :key="i">
<td class="unanswered-source">{{ q.source }}</td>
<td>{{ q.question }}</td>
<td class="unanswered-gap">{{ q.gap }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="auditReport.gaps?.length" class="audit-section">
<p class="audit-section-title">Gaps</p>
<table class="audit-table">
<thead><tr><th>Topic</th><th>Severity</th><th>Appears in</th><th>Course coverage</th></tr></thead>
<tbody>
<tr v-for="(gap, i) in auditReport.gaps" :key="i">
<td>{{ gap.topic }}</td>
<td><span class="severity-badge" :class="`severity-badge--${gap.severity}`">{{ gap.severity }}</span></td>
<td>{{ gap.appearsInSources }}</td>
<td>{{ gap.courseCoverage }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="auditReport.recommendations?.length" class="audit-section">
<p class="audit-section-title">Recommendations</p>
<ol class="audit-recs">
<li v-for="(rec, i) in auditReport.recommendations" :key="i">{{ rec }}</li>
</ol>
</div>
<div v-if="auditReport.lessonQuality?.length" class="audit-section">
<button class="audit-collapse-btn" @click="lessonQualityExpanded = !lessonQualityExpanded">
Lesson Quality
<span class="collapse-arrow" :class="{ 'collapse-arrow--open': lessonQualityExpanded }"></span>
</button>
<div v-if="lessonQualityExpanded" class="lesson-quality-list">
<div v-for="(lq, i) in auditReport.lessonQuality" :key="i" class="lq-row">
<div class="lq-title">{{ lq.topicTitle }}</div>
<div class="lq-score" :style="{ color: scoreColor(lq.score) }">{{ lq.score }}/100</div>
<div class="lq-notes">{{ lq.notes }}</div>
</div>
</div>
</div>
</div>
<div v-if="auditReport && auditHidden" class="audit-show-row">
<button class="audit-show-btn" @click="toggleAudit">Show audit report</button>
</div>
<!-- error -->
<div v-if="error || course?.status === 'error'" class="error-view">
<div class="error-card">
<p class="error-label">Error</p>
<p class="error-sub">We couldn't generate your course. Please try again.</p>
<NuxtLink to="/" class="error-back">← Start over</NuxtLink>
</div>
</div>
<template v-else-if="course">
<!-- processing stepper (shown while generating) -->
<div v-if="course.status === 'processing'" class="processing-view">
<div class="processing-header">
<p class="processing-label">Generating</p>
<h2 class="processing-title">{{ course.title }}</h2>
</div>
<div class="stepper">
<div
v-for="(step, i) in STEPS"
:key="step.key"
class="stepper-item"
:class="`stepper-item--${stepState(i, course.stage)}`"
>
<div class="stepper-node">
<div v-if="stepState(i, course.stage) === 'done'" class="node node--done">
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div v-else-if="stepState(i, course.stage) === 'active'" class="node node--active">
<div class="node-pulse" />
<div class="node-dot" />
</div>
<div v-else class="node node--pending">
<div class="node-dot-sm" />
</div>
</div>
<div v-if="i < STEPS.length - 1" class="stepper-line"
:class="stepState(i, course.stage) === 'done' ? 'stepper-line--done' : 'stepper-line--pending'" />
<div class="stepper-text">
<p class="stepper-title">{{ step.title }}</p>
<p class="stepper-sub">{{ step.subtitle }}</p>
</div>
</div>
</div>
<div class="tip-area">
<Transition name="tip">
<p v-if="tipVisible" :key="tipIndex" class="tip-text">"{{ TIPS[tipIndex] }}"</p>
</Transition>
</div>
</div>
<!-- course view (always shown once we have topics, or when ready) -->
<div v-if="course.status === 'ready' || course.topics?.length" class="course-view">
<div class="course-meta">
<div class="course-subject">{{ course.subject }}</div>
<div class="title-edit-wrap" @click="!editingTitle && startEditTitle()">
<input
v-if="editingTitle"
v-model="titleDraft"
class="title-input course-title"
@blur="saveTitle"
@keydown.enter="saveTitle"
@keydown.escape="editingTitle = false"
/>
<h1 v-else class="course-title">
{{ course.title }}
<svg class="title-pencil" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
</svg>
</h1>
</div>
<!-- org chip -->
<div v-if="course.organisation" class="org-chips" style="margin-bottom: 16px;">
<span class="org-chip">{{ course.organisation }}</span>
</div>
<div class="course-stats">
<span class="stat">
<span class="stat-num">{{ course.topics?.length ?? 0 }}</span>
topics
</span>
<span class="stat-divider">·</span>
<span class="stat">
<span class="stat-num stat-num--green">{{ completedCount() }}</span>
completed
</span>
</div>
<div class="progress-track" v-if="course.topics?.length">
<div
class="progress-fill"
:style="{ width: `${(completedCount() / course.topics.length) * 100}%` }"
/>
</div>
</div>
<!-- topic list -->
<div class="topic-list">
<div class="topic-list-inner">
<div
v-for="(topic, idx) in course.topics"
:key="topic.id"
class="topic-item"
>
<NuxtLink
:to="`/learn/${topic.id}`"
class="topic-row topic-row--unlocked"
:class="{
'topic-row--complete': getTopicProgress(topic.id)?.lessonComplete,
'topic-row--available': !getTopicProgress(topic.id)?.lessonComplete && topic.status === 'ready',
'topic-row--pending': topic.status === 'pending' || topic.status === 'generating',
}"
>
<span class="topic-index" :style="topic.status === 'pending' ? 'opacity:0.4' : ''">
{{ String(idx + 1).padStart(2, "0") }}
</span>
<div class="topic-info">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<span class="topic-name" :style="topic.status === 'pending' ? 'opacity:0.55' : ''">{{ topic.title }}</span>
<span v-if="getTopicProgress(topic.id)?.lessonComplete" class="done-badge">done</span>
<span
v-if="getTopicProgress(topic.id)?.lessonComplete && getTopicProgress(topic.id)?.tookBranches"
class="branch-dot"
title="You needed a little extra help here — that's completely normal."
/>
<span v-if="topic.status === 'pending'" class="topic-status-label topic-status-label--pending">Not yet generated</span>
<span v-else-if="topic.status === 'generating'" class="topic-status-label topic-status-label--generating">
<span class="generating-dot" />Generating...
</span>
<span v-else-if="topic.status === 'error'" class="topic-status-label topic-status-label--error">Error</span>
</div>
<div class="topic-meta">
<div class="diff-dots">
<span
v-for="d in 5"
:key="d"
class="diff-dot"
:class="d <= topic.difficulty ? 'diff-dot--filled' : 'diff-dot--empty'"
/>
</div>
<span class="diff-label">
{{ ['', 'intro', 'basic', 'intermediate', 'advanced', 'expert'][topic.difficulty] }}
</span>
<span v-if="getTopicProgress(topic.id)?.quizScore != null" class="diff-label">
· quiz {{ getTopicProgress(topic.id).quizScore }}/4
</span>
<span
v-if="topic.lessonCost != null"
class="lesson-cost-badge"
title="AI generation cost for this lesson"
>
~${{ topic.lessonCost.toFixed(2) }}
</span>
</div>
</div>
<svg v-if="topic.status !== 'error'" class="topic-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5l7 7-7 7"/>
</svg>
<button
v-else
class="topic-retry-btn"
@click.prevent="$fetch(`/api/topics/${topic.id}/generate`, { method: 'POST' }).then(() => refresh())"
>
Retry
</button>
</NuxtLink>
<!-- dev dropdown — sibling of NuxtLink so clicks don't trigger navigation -->
<div class="dev-wrap">
<button
class="dev-cog"
:class="{ 'dev-cog--open': devOpenId === topic.id }"
@click="devOpenId = devOpenId === topic.id ? null : topic.id"
title="Dev tools"
>⚙</button>
<div v-if="devOpenId === topic.id" class="dev-drop">
<button class="dev-item" @click="openDevPrompt(topic.id, 'regenerate-lesson')">Regenerate lesson</button>
<button class="dev-item" @click="openDevPrompt(topic.id, 'regenerate-audio')">Regenerate audio</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- dev key prompt modal -->
<Transition name="dev-fade">
<div v-if="devPromptTopicId" class="dev-overlay" @click.self="devPromptTopicId = null">
<div class="dev-modal">
<p class="dev-modal-title">{{ devPendingAction === 'regenerate-lesson' ? 'Regenerate Lesson' : 'Regenerate Audio' }}</p>
<p class="dev-modal-sub">Enter your license key to continue.</p>
<input
v-model="devKey"
class="dev-modal-input"
type="password"
placeholder="License key"
@keydown.enter="runDevAction"
@keydown.esc="devPromptTopicId = null"
/>
<p v-if="devMsg" class="dev-modal-msg" :class="devMsg.ok ? 'dev-modal-msg--ok' : 'dev-modal-msg--err'">{{ devMsg.text }}</p>
<div class="dev-modal-actions">
<button class="dev-modal-cancel" :disabled="devLoading" @click="devPromptTopicId = null">Cancel</button>
<button class="dev-modal-run" :disabled="devLoading || !devKey" @click="runDevAction">
{{ devLoading ? 'Running…' : 'Run' }}
</button>
</div>
</div>
</div>
</Transition>
</main>
</template>
<style scoped>
.course-page {
min-height: 100dvh;
background: var(--bg);
}
/* header */
.course-header {
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 20;
background: var(--surface);
}
.header-inner {
max-width: 720px;
margin: 0 auto;
padding: 0 1.5rem;
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
}
.back-link {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-3);
font-size: 13px;
text-decoration: none;
transition: color 0.15s;
}
.back-link:hover { color: var(--text); }
.header-logo {
font-family: "Lora", serif;
font-size: 15px;
font-weight: 600;
color: var(--text);
letter-spacing: -0.3px;
}
/* page body */
.page-body {
max-width: 720px;
margin: 0 auto;
padding: 3.5rem 1.5rem 5rem;
}
/* processing */
.processing-view { max-width: 400px; margin: 0 auto; margin-bottom: 3rem; }
.processing-header { margin-bottom: 3rem; }
.processing-label {
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
font-weight: 500;
margin-bottom: 6px;
}
.processing-title {
font-family: "Lora", serif;
font-size: 1.5rem;
color: var(--text);
font-weight: 600;
}
/* stepper */
.stepper { display: flex; flex-direction: column; }
.stepper-item {
display: grid;
grid-template-columns: 32px 1px 1fr;
grid-template-rows: auto auto;
gap: 0 1.25rem;
}
.stepper-node { grid-column: 1; grid-row: 1; display: flex; justify-content: center; }
.stepper-line {
grid-column: 2; grid-row: 2;
width: 1px; height: 2.5rem;
margin: 0 auto; align-self: stretch;
transition: background 0.4s;
}
.stepper-line--done { background: var(--green); }
.stepper-line--pending { background: var(--border); }
.stepper-text {
grid-column: 3; grid-row: 1 / 3;
padding-top: 5px; padding-bottom: 2.5rem;
}
.stepper-item:last-child .stepper-text { padding-bottom: 0; }
.node {
width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
position: relative;
}
.node--done { background: var(--green); color: white; }
.node--active { background: transparent; border: 1px solid var(--accent); }
.node--pending { background: transparent; border: 1px solid var(--border); }
.node-pulse {
position: absolute; inset: -4px; border-radius: 50%;
border: 1px solid var(--accent);
animation: pulse-ring 2s ease-out infinite; opacity: 0;
}
.node-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent);
animation: dot-breathe 1.5s ease-in-out infinite;
}
.node-dot-sm { width: 6px; height: 6px; border-radius: 50%; background: var(--border); }
@keyframes pulse-ring {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(1.6); opacity: 0; }
}
@keyframes dot-breathe {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.stepper-title {
font-size: 14px; font-weight: 500; color: var(--text); line-height: 1.3; transition: color 0.3s;
}
.stepper-item--pending .stepper-title { color: var(--border-2); }
.stepper-item--active .stepper-title { color: var(--accent); }
.stepper-sub {
font-size: 12px; color: var(--text-3); margin-top: 3px; line-height: 1.4;
}
.stepper-item--pending .stepper-sub { color: var(--border); }
.tip-area { margin-top: 3.5rem; min-height: 36px; text-align: center; }
.tip-text { font-size: 13px; font-style: italic; color: var(--text-3); line-height: 1.6; padding: 0 1rem; }
/* error */
.error-view { text-align: center; padding: 6rem 0; }
.error-card {
display: inline-block;
border: 1px solid oklch(80% 0.06 15);
background: oklch(95% 0.03 15);
border-radius: var(--r-surface);
padding: 2rem 2.5rem;
}
.error-label {
font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
color: oklch(42% 0.12 15); font-weight: 600; margin-bottom: 8px;
}
.error-sub { font-size: 14px; color: var(--text-2); margin-bottom: 16px; }
.error-back { font-size: 13px; color: var(--accent); text-decoration: none; }
.error-back:hover { text-decoration: underline; }
/* course ready */
.course-meta { margin-bottom: 2.5rem; }
.course-subject {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
font-weight: 600;
margin-bottom: 6px;
}
.title-edit-wrap {
cursor: text;
display: inline-block;
margin-bottom: 10px;
}
.title-edit-wrap:hover .title-pencil { opacity: 1; }
.course-title {
font-family: "Lora", serif;
font-size: 2.25rem;
line-height: 1.15;
color: var(--text);
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
}
.title-pencil {
width: 16px; height: 16px; color: var(--text-3);
opacity: 0; transition: opacity 0.15s; flex-shrink: 0; margin-top: 4px;
}
.title-input {
background: transparent;
border: none;
border-bottom: 2px solid var(--accent-dim);
outline: none;
width: 100%;
padding: 0; margin: 0; display: block;
}
.org-chips { display: flex; flex-wrap: wrap; gap: 5px; }
.org-chip {
font-size: 11px; font-weight: 500; color: var(--text-2);
border: 1px solid var(--border-2); border-radius: 20px;
padding: 2px 9px; background: var(--surface-2);
white-space: nowrap; letter-spacing: 0.01em;
}
.course-stats {
display: flex; align-items: center; gap: 8px; margin-bottom: 16px;
}
.stat { font-size: 13px; color: var(--text-2); display: flex; gap: 5px; align-items: center; }
.stat-num { font-family: "Lora", serif; font-size: 17px; font-weight: 600; color: var(--text); }
.stat-num--green { color: var(--green); }
.stat-divider { color: var(--border-2); }
.progress-track {
height: 6px; background: var(--border); border-radius: 99px;
overflow: hidden; max-width: 320px;
}
.progress-fill {
height: 100%; background: var(--accent); border-radius: 99px; transition: width 0.6s ease;
}
/* topic list */
.topic-list {}
.topic-item {
position: relative;
}
.topic-row {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-item);
border-left-width: 4px;
text-decoration: none;
transition: box-shadow 0.15s, border-color 0.15s;
}
.topic-row--complete { border-left-color: var(--green); }
.topic-row--available { border-left-color: var(--accent); }
.topic-row--generating { border-left-color: var(--border); cursor: default; animation: gen-pulse 2.2s ease-in-out infinite; }
.topic-row--unlocked:hover {
box-shadow: 0 4px 16px oklch(0% 0 0 / 0.07);
}
.topic-row--unlocked:hover .topic-name { color: var(--accent); }
.topic-row--unlocked:hover .topic-arrow { color: var(--accent); }
.topic-index {
font-size: 11px; color: var(--text-3); min-width: 1.75rem;
letter-spacing: 0.05em; flex-shrink: 0;
}
.topic-info { flex: 1; min-width: 0; }
.topic-name {
font-size: 15px; font-weight: 500; color: var(--text);
transition: color 0.2s; display: block;
}
.topic-meta {
display: flex; align-items: center; gap: 8px; margin-top: 5px;
}
.diff-dots { display: flex; gap: 3px; align-items: center; }
.diff-dot { width: 6px; height: 6px; border-radius: 50%; }
.diff-dot--filled { background: var(--accent); }
.diff-dot--empty { background: var(--border); }
.diff-label { font-size: 11px; color: var(--text-3); letter-spacing: 0.04em; }
.done-badge {
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--green); background: var(--green-light);
padding: 1px 7px; border-radius: 20px; font-weight: 500;
}
.branch-dot {
width: 7px; height: 7px; border-radius: 50%;
background: oklch(65% 0.14 65); flex-shrink: 0; cursor: help;
}
.topic-arrow {
width: 14px; height: 14px; color: var(--border-2);
flex-shrink: 0; transition: color 0.2s;
}
/* generating label */
.generating-label {
display: flex; align-items: center; gap: 5px;
font-size: 11px; letter-spacing: 0.06em; color: var(--text-3); flex-shrink: 0;
}
.generating-dot {
width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); opacity: 0.6;
animation: gen-dot-blink 1.4s ease-in-out infinite;
}
@keyframes gen-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes gen-dot-blink {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
/* topic appear transition */
.topic-list-inner { display: flex; flex-direction: column; gap: 8px; }
.topic-appear-enter-active { transition: opacity 0.4s ease, transform 0.4s ease; }
.topic-appear-enter-from { opacity: 0; transform: scale(0.97); }
/* tip transition */
.tip-enter-active, .tip-leave-active { transition: opacity 0.35s ease, transform 0.35s ease; }
.tip-enter-from { opacity: 0; transform: translateY(5px); }
.tip-leave-to { opacity: 0; transform: translateY(-5px); }
/* audit panel */
.audit-panel {
background: var(--text);
border-radius: var(--r-surface);
padding: 1.5rem;
margin-bottom: 2.5rem;
color: var(--bg);
}
.audit-header {
display: flex; align-items: center; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap;
}
.audit-badge {
font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;
background: oklch(65% 0.14 65); color: var(--text);
padding: 2px 8px; border-radius: 4px; font-weight: 600; flex-shrink: 0;
}
.audit-score {
font-family: "Lora", serif; font-size: 2rem; line-height: 1; flex-shrink: 0;
}
.audit-score-denom { font-size: 14px; color: var(--text-3); }
.audit-hide-btn {
margin-left: auto; background: none; border: 1px solid oklch(35% 0.02 55);
color: var(--text-3); font-size: 11px; letter-spacing: 0.08em;
padding: 4px 10px; border-radius: 4px; cursor: pointer; transition: border-color 0.15s, color 0.15s;
}
.audit-hide-btn:hover { border-color: var(--text-3); color: var(--bg); }
.audit-standard-banner {
font-size: 14px; font-weight: 700; letter-spacing: 0.1em;
text-align: center; padding: 12px; border-radius: var(--r-sm); margin-bottom: 1.25rem;
}
.audit-standard-banner--pass {
background: rgba(34, 197, 94, 0.15); color: oklch(75% 0.12 155);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.audit-standard-banner--fail {
background: rgba(239, 68, 68, 0.15); color: oklch(75% 0.12 15);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.audit-readiness { font-size: 15px; line-height: 1.65; color: oklch(75% 0.01 75); margin-bottom: 1rem; }
.unanswered-source { font-size: 12px; color: var(--text-3); white-space: nowrap; }
.unanswered-gap { color: oklch(75% 0.12 15); }
.audit-coverage { font-size: 12px; color: var(--text-3); margin-bottom: 1.5rem; }
.audit-coverage strong { color: oklch(85% 0.01 75); }
.audit-section { border-top: 1px solid oklch(35% 0.02 55); padding-top: 1.25rem; margin-top: 1.25rem; }
.audit-section-title {
font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase;
color: var(--text-3); margin-bottom: 14px;
}
.audit-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.audit-table th {
font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--text-3); padding: 0 8px 8px 0; text-align: left; font-weight: 400; white-space: nowrap;
}
.audit-table td {
padding: 6px 8px 6px 0; color: oklch(80% 0.01 75);
border-top: 1px solid oklch(35% 0.02 55); vertical-align: top; line-height: 1.5;
}
.audit-table tr:first-child td { border-top: none; }
.severity-badge { font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; padding: 2px 6px; border-radius: 3px; }
.severity-badge--high { background: rgba(239,68,68,0.15); color: oklch(75% 0.12 15); }
.severity-badge--medium { background: rgba(245,158,11,0.15); color: oklch(75% 0.14 65); }
.severity-badge--low { background: rgba(156,163,175,0.12); color: var(--text-3); }
.audit-recs { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 8px; }
.audit-recs li { font-size: 14px; line-height: 1.6; color: oklch(80% 0.01 75); }
.audit-collapse-btn {
display: flex; align-items: center; gap: 8px; background: none; border: none;
cursor: pointer; font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase;
color: var(--text-3); padding: 0; transition: color 0.15s;
}
.audit-collapse-btn:hover { color: oklch(85% 0.01 75); }
.collapse-arrow { font-size: 12px; transition: transform 0.2s; display: inline-block; }
.collapse-arrow--open { transform: rotate(90deg); }
.lesson-quality-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; }
.lq-row {
display: grid; grid-template-columns: 1fr 64px 2fr;
gap: 12px; padding: 10px 0; border-top: 1px solid oklch(35% 0.02 55); font-size: 13px; align-items: start;
}
.lq-row:first-child { border-top: none; }
.lq-title { color: oklch(85% 0.01 75); font-weight: 500; }
.lq-score { font-size: 12px; font-weight: 500; text-align: right; }
.lq-notes { color: var(--text-3); line-height: 1.5; }
.audit-show-row { margin-bottom: 2rem; }
.audit-show-btn {
background: none; border: 1px solid var(--border);
color: var(--text-3); font-size: 11px; letter-spacing: 0.1em;
text-transform: uppercase; padding: 5px 12px; border-radius: var(--r-sm);
cursor: pointer; transition: border-color 0.15s, color 0.15s;
}
.audit-show-btn:hover { border-color: var(--border-2); color: var(--text); }
/* topic status labels */
.topic-status-label {
font-size: 10px; letter-spacing: 0.08em;
padding: 1px 7px; border-radius: 20px; font-weight: 500;
}
.topic-status-label--pending {
color: var(--text-3); background: var(--surface-2);
border: 1px solid var(--border);
}
.topic-status-label--generating {
color: var(--accent); background: var(--surface-2);
border: 1px solid var(--accent-dim);
display: inline-flex; align-items: center; gap: 4px;
}
.topic-status-label--error {
color: oklch(50% 0.18 25); background: oklch(95% 0.03 15);
border: 1px solid oklch(80% 0.06 15);
}
/* topic row states */
.topic-row--pending { border-left-color: var(--border); }
/* lesson cost badge */
.lesson-cost-badge {
font-family: "IBM Plex Mono", monospace;
font-size: 10px; color: var(--text-3); letter-spacing: 0.04em;
}
/* dev dropdown */
.dev-wrap {
position: absolute;
top: 50%;
right: 2.5rem;
transform: translateY(-50%);
z-index: 10;
}
.dev-cog {
background: none;
border: 1px solid var(--border);
border-radius: 5px;
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
font-size: 13px;
cursor: pointer;
color: var(--text-3);
transition: border-color 0.15s, color 0.15s;
opacity: 0;
}
.topic-item:hover .dev-cog,
.dev-cog--open { opacity: 1; }
.dev-cog:hover,
.dev-cog--open { border-color: var(--border-2); color: var(--text); }
.dev-drop {
position: absolute;
top: calc(100% + 5px);
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px oklch(0% 0 0 / 0.1);
min-width: 170px;
z-index: 50;
overflow: hidden;
}
.dev-item {
display: block; width: 100%; text-align: left;
background: none; border: none;
padding: 9px 13px;
font-size: 12px; color: var(--text-2);
cursor: pointer;
transition: background 0.1s;
}
.dev-item:hover { background: var(--surface-2); }
.dev-item + .dev-item { border-top: 1px solid var(--border); }
/* dev modal */
.dev-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.35);
display: flex; align-items: center; justify-content: center;
padding: 2rem;
}
.dev-modal {
background: var(--surface);
border-radius: 12px;
padding: 1.5rem;
width: 100%; max-width: 340px;
display: flex; flex-direction: column; gap: 0.875rem;
box-shadow: 0 8px 32px oklch(0% 0 0 / 0.18);
}
.dev-modal-title {
font-size: 14px; font-weight: 600; color: var(--text); margin: 0;
}
.dev-modal-sub {
font-size: 13px; color: var(--text-3); margin: 0;
}
.dev-modal-input {
width: 100%; padding: 9px 11px;
border: 1.5px solid var(--border);
border-radius: 7px;
font-size: 13px; color: var(--text);
background: var(--bg);
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.dev-modal-input:focus { border-color: var(--accent); }
.dev-modal-msg { font-size: 12px; margin: 0; }
.dev-modal-msg--ok { color: var(--green); }
.dev-modal-msg--err { color: oklch(50% 0.18 25); }
.dev-modal-actions { display: flex; gap: 0.625rem; justify-content: flex-end; }
.dev-modal-cancel {
background: none; border: 1px solid var(--border);
border-radius: 7px; padding: 7px 14px;
font-size: 12px; color: var(--text-3); cursor: pointer;
transition: border-color 0.15s;
}
.dev-modal-cancel:hover:not(:disabled) { border-color: var(--border-2); }
.dev-modal-cancel:disabled { opacity: 0.5; cursor: not-allowed; }
.dev-modal-run {
background: var(--accent); color: #fff;
border: none; border-radius: 7px;
padding: 7px 18px;
font-size: 12px; font-weight: 500;
cursor: pointer; transition: opacity 0.15s;
}
.dev-modal-run:hover:not(:disabled) { opacity: 0.85; }
.dev-modal-run:disabled { opacity: 0.45; cursor: not-allowed; }
.dev-fade-enter-active { transition: opacity 0.15s ease; }
.dev-fade-leave-active { transition: opacity 0.1s ease; }
.dev-fade-enter-from, .dev-fade-leave-to { opacity: 0; }
/* retry button on error topics */
.topic-retry-btn {
font-size: 11px; letter-spacing: 0.06em;
background: none; border: 1px solid oklch(80% 0.06 15);
color: oklch(50% 0.18 25); border-radius: 20px;
padding: 3px 10px; cursor: pointer; flex-shrink: 0;
transition: background 0.15s;
}
.topic-retry-btn:hover { background: oklch(95% 0.03 15); }
</style>