initialize project with basic structure and dependencies

This commit is contained in:
ImBenji
2026-04-27 23:59:15 +01:00
parent 83f2837ce6
commit f6f45500f8
13 changed files with 1443 additions and 579 deletions
+100 -49
View File
@@ -10,7 +10,7 @@ const { data: course, error, refresh } = await useAsyncData(
let pollTimer: ReturnType<typeof setInterval> | null = null;
function hasUnreadyTopics(): boolean {
return course.value?.topics?.some((t: any) => !t.hasLesson) ?? false;
return course.value?.topics?.some((t: any) => t.status === "generating") ?? false;
}
onMounted(() => {
@@ -333,60 +333,73 @@ async function saveTitle() {
<!-- topic list -->
<div class="topic-list">
<TransitionGroup name="topic-appear" tag="div" class="topic-list-inner">
<template v-for="(topic, idx) in course.topics" :key="topic.id">
<NuxtLink
v-if="topic.hasLesson"
:to="`/learn/${topic.id}`"
class="topic-row topic-row--unlocked"
:class="topic.progress?.lessonComplete ? 'topic-row--complete' : 'topic-row--available'"
>
<span class="topic-index">{{ String(idx + 1).padStart(2, "0") }}</span>
<NuxtLink
v-for="(topic, idx) in course.topics"
:key="topic.id"
:to="`/learn/${topic.id}`"
class="topic-row topic-row--unlocked"
:class="{
'topic-row--complete': topic.progress?.lessonComplete,
'topic-row--available': !topic.progress?.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">{{ topic.title }}</span>
<span v-if="topic.progress?.lessonComplete" class="done-badge">done</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="topic.progress?.lessonComplete" class="done-badge">done</span>
<span
v-if="topic.progress?.lessonComplete && topic.progress?.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-if="topic.progress?.lessonComplete && topic.progress?.tookBranches"
class="branch-dot"
title="You needed a little extra help here — that's completely normal."
v-for="d in 5"
:key="d"
class="diff-dot"
:class="d <= topic.difficulty ? 'diff-dot--filled' : 'diff-dot--empty'"
/>
</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="topic.progress?.quizScore != null" class="diff-label">
· quiz {{ topic.progress.quizScore }}/4
</span>
</div>
<span class="diff-label">
{{ ['', 'intro', 'basic', 'intermediate', 'advanced', 'expert'][topic.difficulty] }}
</span>
<span v-if="topic.progress?.quizScore != null" class="diff-label">
· quiz {{ topic.progress.quizScore }}/4
</span>
<span
v-if="topic.progress?.lessonComplete && topic.lessonCost != null"
class="lesson-cost-badge"
title="AI generation cost for this lesson"
>
~${{ topic.lessonCost.toFixed(2) }}
</span>
</div>
<svg 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>
</NuxtLink>
<div v-else class="topic-row topic-row--generating">
<span class="topic-index" style="opacity: 0.4;">{{ String(idx + 1).padStart(2, "0") }}</span>
<div class="topic-info">
<span class="topic-name" style="opacity: 0.5;">{{ topic.title }}</span>
</div>
<span class="generating-label">
<span class="generating-dot" />
Generating...
</span>
</div>
</template>
<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>
</TransitionGroup>
</div>
@@ -827,4 +840,42 @@ async function saveTitle() {
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;
}
/* 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>
+189 -4
View File
@@ -77,6 +77,40 @@ async function retry(courseId: string) {
}
}
const showDeleteModal = ref(false);
const deleteLicenseKey = ref("");
const deleteLicenseError = ref<string | null>(null);
const pendingDeleteId = ref<string | null>(null);
const deleting = ref(false);
function requestDelete(courseId: string) {
pendingDeleteId.value = courseId;
deleteLicenseKey.value = "";
deleteLicenseError.value = null;
showDeleteModal.value = true;
}
async function confirmDelete() {
if (!deleteLicenseKey.value.trim()) {
deleteLicenseError.value = "Enter a license key.";
return;
}
deleting.value = true;
try {
await $fetch(`/api/courses/${pendingDeleteId.value}`, {
method: "DELETE",
headers: { "x-license-key": deleteLicenseKey.value.trim() },
});
showDeleteModal.value = false;
pendingDeleteId.value = null;
await refresh();
} catch (err: any) {
deleteLicenseError.value = err?.data?.message ?? "Delete failed";
} finally {
deleting.value = false;
}
}
function progressPct(course: CourseSummary): number {
if (!course.topicCount) return 0;
return Math.round((course.completedCount / course.topicCount) * 100);
@@ -150,10 +184,17 @@ function subjectColor(subject: string): string {
</div>
</div>
<span class="status-badge" :class="`status-badge--${course.status}`">
<span v-if="course.status === 'processing'" class="badge-pulse" />
{{ course.status === 'ready' ? 'Ready' : course.status === 'error' ? 'Error' : 'Processing' }}
</span>
<div class="card-top-right">
<span class="status-badge" :class="`status-badge--${course.status}`">
<span v-if="course.status === 'processing'" class="badge-pulse" />
{{ course.status === 'ready' ? 'Ready' : course.status === 'error' ? 'Error' : 'Processing' }}
</span>
<button class="delete-btn" title="Delete course" @click.stop.prevent="requestDelete(course.id)">
<svg width="13" height="13" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 5h14M8 5V3h4v2M6 5l1 12h6l1-12"/>
</svg>
</button>
</div>
</div>
<!-- bottom -->
@@ -212,6 +253,36 @@ function subjectColor(subject: string): string {
</div>
</div>
<Teleport to="body">
<Transition name="modal">
<div v-if="showDeleteModal" class="modal-backdrop" @click.self="showDeleteModal = false">
<div class="modal-box">
<p class="modal-title">Delete course?</p>
<p class="modal-sub">This is permanent. Enter your license key to confirm.</p>
<input
v-model="deleteLicenseKey"
class="modal-input"
type="text"
placeholder="paste key here…"
autofocus
@keydown.enter="confirmDelete"
@keydown.esc="showDeleteModal = false"
/>
<p v-if="deleteLicenseError" class="modal-error">{{ deleteLicenseError }}</p>
<div class="modal-actions">
<button class="modal-cancel" @click="showDeleteModal = false">Cancel</button>
<button class="modal-confirm modal-confirm--danger" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Deleting' : 'Delete' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
@@ -570,4 +641,118 @@ function subjectColor(subject: string): string {
font-size: 14px;
font-weight: 500;
}
.card-top-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.delete-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-3);
padding: 2px;
opacity: 0;
transition: color 0.15s, opacity 0.15s;
line-height: 0;
}
.course-card:hover .delete-btn { opacity: 1; }
.delete-btn:hover { color: oklch(42% 0.12 15); }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-box {
background: var(--bg);
border: 1px solid var(--border-2);
border-radius: var(--r-surface);
padding: 28px 28px 24px;
width: 100%;
max-width: 420px;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.modal-sub {
font-size: 13px;
color: var(--text-3);
margin-bottom: 18px;
}
.modal-input {
width: 100%;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: var(--r-sm);
color: var(--text);
font-size: 13px;
font-family: monospace;
padding: 10px 12px;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.modal-input:focus { border-color: var(--accent); }
.modal-error {
font-size: 12px;
color: oklch(42% 0.12 15);
margin-top: 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.modal-cancel {
font-size: 14px;
color: var(--text-3);
background: none;
border: 1px solid var(--border-2);
border-radius: var(--r-btn);
padding: 8px 16px;
cursor: pointer;
transition: color 0.15s;
}
.modal-cancel:hover { color: var(--text); }
.modal-confirm {
font-size: 14px;
font-weight: 500;
background: var(--accent);
color: white;
border: none;
border-radius: var(--r-btn);
padding: 8px 20px;
cursor: pointer;
transition: opacity 0.15s;
}
.modal-confirm:hover:not(:disabled) { opacity: 0.88; }
.modal-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
.modal-confirm--danger {
background: oklch(48% 0.14 15);
}
.modal-enter-active, .modal-leave-active { transition: opacity 0.18s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
+479 -4
View File
@@ -5,10 +5,124 @@ import confetti from "canvas-confetti";
const route = useRoute();
const topicId = route.params.id as string;
const { data: lesson, pending: lessonPending, error: lessonError } = useAsyncData(
`lesson-${topicId}`,
() => $fetch<any>(`/api/topics/${topicId}/lesson`)
);
// ── JIT generation state ───────────────────────────────────────────────────
type GenState = "loading" | "ready" | "error";
const lesson = ref<any>(null);
const lessonPending = ref(false);
const lessonError = ref<any>(null);
const genState = ref<GenState | null>(null);
const GEN_TIPS = [
"Reading through your past papers...",
"Crafting analogies just for this topic...",
"Writing your lesson from scratch...",
"Recording your audio narration...",
"Almost there...",
];
const GEN_STAGES = [
"Understanding the topic",
"Writing your lesson",
"Recording narration",
"Finishing up",
];
const genTipIndex = ref(0);
const genTipVisible = ref(true);
const genStageIndex = ref(0);
let genTipTimer: ReturnType<typeof setInterval> | null = null;
let genStageTimer: ReturnType<typeof setInterval> | null = null;
function startGenAnimations() {
genTipTimer = setInterval(() => {
genTipVisible.value = false;
setTimeout(() => {
genTipIndex.value = (genTipIndex.value + 1) % GEN_TIPS.length;
genTipVisible.value = true;
}, 300);
}, 3000);
genStageTimer = setInterval(() => {
genStageIndex.value = (genStageIndex.value + 1) % GEN_STAGES.length;
}, 4500);
}
function stopGenAnimations() {
if (genTipTimer) { clearInterval(genTipTimer); genTipTimer = null; }
if (genStageTimer) { clearInterval(genStageTimer); genStageTimer = null; }
}
async function loadLesson() {
lessonPending.value = true;
try {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
lesson.value = data;
genState.value = "ready";
} catch (err: any) {
if (err?.statusCode === 404 || err?.status === 404) {
// lesson not generated yet — trigger JIT generation
await triggerGeneration();
} else {
lessonError.value = err;
}
} finally {
lessonPending.value = false;
}
}
async function triggerGeneration() {
genState.value = "loading";
startGenAnimations();
try {
const result = await $fetch<{ status: string }>(`/api/topics/${topicId}/generate`, { method: "POST" });
stopGenAnimations();
if (result.status === "ready") {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
lesson.value = data;
genState.value = "ready";
} else {
genState.value = "error";
}
} catch {
stopGenAnimations();
genState.value = "error";
}
}
onMounted(loadLesson);
onUnmounted(() => {
stopGenAnimations();
});
// ── branch loading overlay ─────────────────────────────────────────────────
const branchOverlayVisible = ref(false);
const branchOverlayError = ref(false);
let branchPollTimer: ReturnType<typeof setInterval> | null = null;
function startBranchPoll() {
branchPollTimer = setInterval(async () => {
try {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
if (data.branchStatus === "ready") {
lesson.value = data;
stopBranchPoll();
branchOverlayVisible.value = false;
} else if (data.branchStatus === "error") {
stopBranchPoll();
branchOverlayError.value = true;
}
} catch { /* ignore poll errors */ }
}, 2000);
}
function stopBranchPoll() {
if (branchPollTimer) { clearInterval(branchPollTimer); branchPollTimer = null; }
}
// ── lesson state machine ───────────────────────────────────────────────────
@@ -203,11 +317,36 @@ const LABELS = ["A", "B", "C", "D"];
// ── branch tracking ────────────────────────────────────────────────────────
const pendingBranchStep = ref<number | null>(null);
const pendingBranchOption = ref<string>("");
// when branch overlay dismisses after poll completes, fire the deferred branch
watch(branchOverlayVisible, (visible) => {
if (!visible && pendingBranchStep.value !== null && !branchOverlayError.value) {
const step = pendingBranchStep.value;
const opt = pendingBranchOption.value;
pendingBranchStep.value = null;
pendingBranchOption.value = "";
enterBranchMode(step, opt);
}
});
const branchesEntered = ref(0);
let transitionTimeout: ReturnType<typeof setTimeout> | null = null;
let confirmAdvanceTimeout: ReturnType<typeof setTimeout> | null = null;
function enterBranchMode(stepIndex: number, wrongOption: string) {
// if branches arent ready yet, show overlay and wait
if (lesson.value?.branchStatus !== "ready") {
branchOverlayVisible.value = true;
branchOverlayError.value = false;
startBranchPoll();
// store where we want to go when branches become available
pendingBranchStep.value = stepIndex;
pendingBranchOption.value = wrongOption;
return;
}
branchesEntered.value++;
lessonState.mode = "transition";
lessonState.stepIndex = stepIndex;
@@ -619,13 +758,72 @@ function continueAfterQuestion() {
onBeforeUnmount(() => {
stopAllAudio();
stopBranchPoll();
if (transitionTimeout) clearTimeout(transitionTimeout);
if (confirmAdvanceTimeout) clearTimeout(confirmAdvanceTimeout);
});
</script>
<template>
<!-- JIT GENERATION LOADING SCREEN -->
<Transition name="fullscreen-fade">
<div v-if="genState === 'loading'" class="gen-loading-screen">
<div class="gen-inner">
<div class="gen-logo">Revisi<span>.one</span></div>
<div class="gen-pulse-ring">
<div class="gen-pulse-circle" />
<div class="gen-pulse-dot">
<svg width="28" height="28" fill="none" viewBox="0 0 24 24" stroke="oklch(54% 0.140 44)">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
</div>
<Transition name="tip">
<p v-if="genTipVisible" :key="genTipIndex" class="gen-tip">{{ GEN_TIPS[genTipIndex] }}</p>
</Transition>
<div class="gen-stepper">
<div
v-for="(stage, i) in GEN_STAGES"
:key="i"
class="gen-step"
:class="i === genStageIndex ? 'gen-step--active' : i < genStageIndex ? 'gen-step--done' : 'gen-step--pending'"
>
<div class="gen-step-node">
<div v-if="i < genStageIndex" class="gen-node-done">
<svg width="10" height="10" 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="i === genStageIndex" class="gen-node-active">
<div class="gen-node-ring" />
<div class="gen-node-dot" />
</div>
<div v-else class="gen-node-pending" />
</div>
<span class="gen-step-label">{{ stage }}</span>
</div>
</div>
</div>
</div>
</Transition>
<!-- JIT GENERATION ERROR -->
<Transition name="fullscreen-fade">
<div v-if="genState === 'error'" class="gen-loading-screen">
<div class="gen-inner">
<div class="gen-logo">Revisi<span>.one</span></div>
<p class="gen-error-msg">We couldn't generate this lesson. Please try again.</p>
<button class="gen-retry-btn" @click="triggerGeneration">Try again</button>
</div>
</div>
</Transition>
<div
v-if="genState === 'ready' || genState === null"
class="lesson-shell"
:style="{ '--step-bg': stepBg, '--accent': accentColor }"
>
@@ -899,6 +1097,32 @@ onBeforeUnmount(() => {
</template>
<!-- ── BRANCH LOADING OVERLAY ────────────────────────────────────────── -->
<Transition name="fullscreen-fade">
<div v-if="branchOverlayVisible" class="branch-loading-overlay">
<div class="branch-loading-inner">
<template v-if="!branchOverlayError">
<div class="branch-loading-pulse">
<div class="branch-loading-ring" />
<div class="branch-loading-icon">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="#92400E">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
</div>
<p class="branch-loading-text">Preparing personalised feedback…</p>
</template>
<template v-else>
<p class="branch-loading-error">We couldn't prepare feedback for this answer.</p>
<button class="branch-loading-skip" @click="() => { branchOverlayVisible = false; pendingBranchStep = null; }">
Continue
</button>
</template>
</div>
</div>
</Transition>
</div>
</template>
@@ -1505,4 +1729,255 @@ onBeforeUnmount(() => {
.step-body { font-size: 1rem; }
.focus-label { display: none; }
}
/* ── JIT generation loading screen ──────────────────────────────────────── */
.gen-loading-screen {
position: fixed;
inset: 0;
z-index: 100;
background: #FAFAF8;
display: flex;
align-items: center;
justify-content: center;
}
.gen-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
padding: 2rem;
max-width: 380px;
width: 100%;
}
.gen-logo {
font-family: "DM Serif Display", Georgia, serif;
font-size: 1.5rem;
color: oklch(35% 0.018 58);
letter-spacing: -0.02em;
}
.gen-logo span {
color: oklch(54% 0.140 44);
}
.gen-pulse-ring {
position: relative;
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
}
.gen-pulse-circle {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid oklch(82% 0.055 76);
animation: gen-pulse 2s ease-in-out infinite;
}
@keyframes gen-pulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.15); opacity: 0.3; }
}
.gen-pulse-dot {
width: 52px;
height: 52px;
border-radius: 50%;
background: oklch(95% 0.030 76);
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid oklch(85% 0.045 72);
}
.gen-tip {
font-family: Georgia, serif;
font-size: 0.9375rem;
color: oklch(50% 0.016 65);
font-style: italic;
text-align: center;
line-height: 1.6;
min-height: 2.5rem;
}
.gen-stepper {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.gen-step {
display: flex;
align-items: center;
gap: 0.875rem;
}
.gen-step-node {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.gen-node-done {
width: 20px;
height: 20px;
border-radius: 50%;
background: oklch(54% 0.140 44);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.gen-node-active {
position: relative;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.gen-node-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid oklch(54% 0.140 44);
animation: gen-pulse 1.5s ease-in-out infinite;
}
.gen-node-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: oklch(54% 0.140 44);
}
.gen-node-pending {
width: 8px;
height: 8px;
border-radius: 50%;
background: oklch(82% 0.022 73);
}
.gen-step-label {
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
letter-spacing: 0.04em;
transition: color 0.3s;
}
.gen-step--done .gen-step-label { color: oklch(54% 0.140 44); }
.gen-step--active .gen-step-label { color: oklch(35% 0.018 58); font-weight: 600; }
.gen-step--pending .gen-step-label { color: oklch(72% 0.016 65); }
.gen-error-msg {
font-family: Georgia, serif;
font-size: 0.9375rem;
color: oklch(45% 0.18 25);
text-align: center;
line-height: 1.6;
}
.gen-retry-btn {
background: oklch(54% 0.140 44);
color: #fff;
border: none;
border-radius: 9999px;
padding: 0.75rem 2rem;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.15s;
}
.gen-retry-btn:hover { opacity: 0.85; }
.tip-enter-active { transition: opacity 0.3s ease; }
.tip-leave-active { transition: opacity 0.2s ease; }
.tip-enter-from, .tip-leave-to { opacity: 0; }
/* ── branch loading overlay ──────────────────────────────────────────────── */
.branch-loading-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(255, 251, 235, 0.88);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
}
.branch-loading-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 2rem;
text-align: center;
}
.branch-loading-pulse {
position: relative;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.branch-loading-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid #F59E0B;
animation: gen-pulse 2s ease-in-out infinite;
}
.branch-loading-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: #FEF3C7;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid #FCD34D;
}
.branch-loading-text {
font-family: "DM Serif Display", Georgia, serif;
font-size: 1.0625rem;
color: #92400E;
line-height: 1.5;
}
.branch-loading-error {
font-family: Georgia, serif;
font-size: 0.9375rem;
color: #92400E;
line-height: 1.6;
}
.branch-loading-skip {
background: #F59E0B;
color: #fff;
border: none;
border-radius: 9999px;
padding: 0.75rem 2rem;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.15s;
}
.branch-loading-skip:hover { opacity: 0.85; }
</style>