Visual polish: theme-aware colors, collapsible cards, skeletons, hover consistency

Templates only, no backend changes:

- Fixed the real bug: JS-generated status/loading text and a few component
  styles used hardcoded hex (#007acc, #999/#666, #28a745, #ff6b6b) instead
  of the CSS variables the theme system already provides, so they didn't
  track the user's chosen theme/accent - most visible on Light or any named
  theme other than the default. Added --success/--error to all 5 templates'
  :root blocks and replaced every non-:root occurrence. Left the
  .lesson-type badge colors and generic UI chrome grays alone (intentional,
  not theme-dependent). Verified live by switching to Nord and Light themes.
- Dashboard cards (Library Stats, Next Up, Pick Back Up, Recently Added,
  Continue Watching, Recently Viewed) are now collapsible, state remembered
  per device via localStorage - the no-course dashboard had grown to 7
  stacked cards.
- Course page's three action buttons get consistent compact sizing and
  icons instead of a plain stack.
- Library browser and transcript search show shimmering skeleton rows
  instead of bare "Loading..."/"Searching..." text while a fetch is in
  flight.
- Hover treatment made consistent: transcript results get the same lift
  .lesson-item already had, heatmap cells get a GitHub-style hover
  scale-up, and Notes Hub links get a background-chip hover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 19:31:31 -04:00
co-authored by Claude Sonnet 5
parent 0454834e62
commit c871d6efae
6 changed files with 236 additions and 88 deletions
+153 -60
View File
@@ -28,6 +28,8 @@
--font-size-base: 16px;
--container-max-width: 1600px;
--radius: 8px;
--success: #28a745;
--error: #ff6b6b;
}
[data-theme="light"] {
--bg-primary: #f2f2f2;
@@ -301,17 +303,77 @@
border-radius: 2px;
background: var(--accent);
opacity: 0.12;
transition: transform 0.15s ease;
}
.heatmap-day.level-1 { opacity: 0.4; }
.heatmap-day.level-2 { opacity: 0.7; }
.heatmap-day.level-3 { opacity: 1; }
.heatmap-day:hover {
transform: scale(1.4);
}
.btn-sm {
padding: 5px 10px;
font-size: 0.8em;
}
.course-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.skeleton-row {
height: 48px;
border-radius: var(--radius);
margin-bottom: 5px;
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-tertiary-hover) 50%, var(--bg-tertiary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.collapsible-header {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.collapsible-header::after {
content: '▾';
font-size: 0.8em;
color: var(--text-muted);
transition: transform 0.2s ease;
margin-left: 10px;
}
.collapsible-header.collapsed::after {
transform: rotate(-90deg);
}
.card-body {
overflow: hidden;
max-height: 3000px;
transition: max-height 0.3s ease, opacity 0.2s ease, margin-top 0.3s ease;
margin-top: 12px;
opacity: 1;
}
.card-body.collapsed {
max-height: 0;
opacity: 0;
margin-top: 0;
}
.library-breadcrumb {
color: var(--text-muted);
font-size: 13px;
@@ -375,11 +437,11 @@
}
.tree-header.lesson {
border-left-color: #28a745;
border-left-color: var(--success);
}
.tree-header.completed {
border-left-color: #28a745;
border-left-color: var(--success);
background: #1e3d1e;
}
@@ -466,7 +528,7 @@
}
.lesson-item.completed {
border-left-color: #28a745;
border-left-color: var(--success);
background: #1e3d1e;
}
@@ -493,7 +555,7 @@
}
.lesson-item.completed .lesson-progress-fill {
background: #28a745;
background: var(--success);
}
.watched-badge {
@@ -573,10 +635,12 @@
margin-bottom: 5px;
border-left: 3px solid var(--accent);
cursor: pointer;
transition: all 0.3s;
}
.transcript-result:hover {
background: var(--bg-tertiary-hover);
transform: translateX(5px);
}
.transcript-result-title {
@@ -641,16 +705,16 @@
}
.status-icon.completed {
color: #28a745;
color: var(--success);
}
.status-icon.pending {
color: #666;
color: var(--text-muted);
}
.last-accessed {
background: #2d5a2d;
border-left-color: #28a745;
border-left-color: var(--success);
margin-top: 10px;
padding: 10px;
border-radius: 5px;
@@ -722,7 +786,7 @@
<div class="container">
<a href="/reset_course">← Select Different Course</a>
<a href="#stats">Progress</a>
<span style="color: #666; margin-left: auto;">
<span style="color: var(--text-muted); margin-left: auto;">
{{ stats.total_lessons }} lessons
</span>
</div>
@@ -736,7 +800,7 @@
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
</div>
<div style="text-align: right;">
<div style="color: #007acc; font-size: 1.2em;">
<div style="color: var(--accent); font-size: 1.2em;">
{{ "%.1f"|format(stats.completion_percentage) }}%
</div>
{% if stats.remaining_display %}
@@ -751,15 +815,15 @@
</div>
{% if stats.total_lessons %}
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 5px;">
<button class="btn btn-secondary" onclick="markCourseWatched()">
Mark all as completed
<div class="course-actions">
<button class="btn btn-secondary btn-sm" onclick="markCourseWatched()">
Mark all as completed
</button>
<button class="btn btn-secondary" id="queue-toggle-btn" onclick="toggleQueued()">
<button class="btn btn-secondary btn-sm" id="queue-toggle-btn" onclick="toggleQueued()">
{{ '📌 Remove from Next Up' if is_queued else '📌 Add to Next Up' }}
</button>
{% if has_note %}
<a class="btn btn-secondary" href="/course/study-guide">Download Study Guide</a>
<a class="btn btn-secondary btn-sm" href="/course/study-guide">📄 Download Study Guide</a>
{% endif %}
</div>
{% endif %}
@@ -879,48 +943,50 @@
{% if library_stats and library_stats.total_courses %}
<div class="container">
<div class="card">
<h2>📊 Library Stats</h2>
<div class="stats-grid">
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.total_courses }}</div>
<div class="stats-tile-label">Courses</div>
<h2 class="collapsible-header" data-card-id="library-stats">📊 Library Stats</h2>
<div class="card-body">
<div class="stats-grid">
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.total_courses }}</div>
<div class="stats-tile-label">Courses</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.completed_lessons }}/{{ library_stats.lessons_tracked }}</div>
<div class="stats-tile-label">Lessons completed</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.watched_display }}</div>
<div class="stats-tile-label">Watched</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.streak_days }}</div>
<div class="stats-tile-label">Day streak</div>
</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.completed_lessons }}/{{ library_stats.lessons_tracked }}</div>
<div class="stats-tile-label">Lessons completed</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.watched_display }}</div>
<div class="stats-tile-label">Watched</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.streak_days }}</div>
<div class="stats-tile-label">Day streak</div>
</div>
</div>
{% if activity_heatmap %}
<div class="heatmap-scroll">
<div class="heatmap-grid">
{% for week in activity_heatmap|batch(7) %}
<div class="heatmap-week">
{% for day in week %}
{% set level = 0 if day.count == 0 else (1 if day.count == 1 else (2 if day.count == 2 else 3)) %}
<div class="heatmap-day level-{{ level }}"
title="{{ day.date }}: {{ day.count }} lesson{{ '' if day.count == 1 else 's' }}"></div>
{% if activity_heatmap %}
<div class="heatmap-scroll">
<div class="heatmap-grid">
{% for week in activity_heatmap|batch(7) %}
<div class="heatmap-week">
{% for day in week %}
{% set level = 0 if day.count == 0 else (1 if day.count == 1 else (2 if day.count == 2 else 3)) %}
<div class="heatmap-day level-{{ level }}"
title="{{ day.date }}: {{ day.count }} lesson{{ '' if day.count == 1 else 's' }}"></div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if next_up %}
<div class="container">
<div class="card" id="next-up-card">
<h2>📌 Next Up</h2>
<div id="next-up-list" style="margin-top: 12px;">
<h2 class="collapsible-header" data-card-id="next-up">📌 Next Up</h2>
<div class="card-body" id="next-up-list">
{% for item in next_up %}
<div class="lesson-item" data-path="{{ item.path }}">
<div class="lesson-title" style="cursor: pointer;" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
@@ -946,8 +1012,8 @@
{% if stale_courses %}
<div class="container">
<div class="card">
<h2>⏰ Pick Back Up</h2>
<div style="margin-top: 12px;">
<h2 class="collapsible-header" data-card-id="pick-back-up">⏰ Pick Back Up</h2>
<div class="card-body">
{% for item in stale_courses %}
{{ render_course_row(item, 'Last touched ' + item.last_touched_display) }}
{% endfor %}
@@ -958,8 +1024,8 @@
{% if recently_added %}
<div class="container">
<div class="card">
<h2>🆕 Recently Added</h2>
<div style="margin-top: 12px;">
<h2 class="collapsible-header" data-card-id="recently-added">🆕 Recently Added</h2>
<div class="card-body">
{% for item in recently_added %}
{{ render_course_row(item, item.added_display) }}
{% endfor %}
@@ -970,8 +1036,8 @@
{% if continue_watching %}
<div class="container">
<div class="card">
<h2>▶ Continue Watching</h2>
<div style="margin-top: 12px;">
<h2 class="collapsible-header" data-card-id="continue-watching">▶ Continue Watching</h2>
<div class="card-body">
{% for view in continue_watching %}
{{ render_view_row(view) }}
{% endfor %}
@@ -982,8 +1048,8 @@
{% if recent_views %}
<div class="container">
<div class="card">
<h2>🕐 Recently Viewed</h2>
<div style="margin-top: 12px;">
<h2 class="collapsible-header" data-card-id="recently-viewed">🕐 Recently Viewed</h2>
<div class="card-body">
{% for view in recent_views %}
{{ render_view_row(view) }}
{% endfor %}
@@ -1077,7 +1143,7 @@
if (transcriptResults) transcriptResults.innerHTML = '';
const container = document.getElementById('library-groups');
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
container.innerHTML = skeletonRowsHtml(3);
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
fetch(url)
.then(r => r.json())
@@ -1087,7 +1153,7 @@
})
.catch(() => {
container.innerHTML =
'<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
'<p style="color:var(--error);">Could not reach the library scanner.</p>';
});
}
@@ -1125,7 +1191,7 @@
const reason = (data.errors && data.errors.length)
? data.errors.join(' ')
: (isRoot ? `No course folders found under ${data.library_path}.` : 'No courses in here.');
container.innerHTML = `<p style="color:#999; padding: 6px 0;">${reason}${isRoot ? ' You can still load a course by path below.' : ''}</p>`;
container.innerHTML = `<p style="color:var(--text-muted); padding: 6px 0;">${reason}${isRoot ? ' You can still load a course by path below.' : ''}</p>`;
return;
}
@@ -1138,6 +1204,12 @@
fetchLibraryLevel(path);
}
// A few shimmering placeholder rows for in-flight fetches, instead
// of bare "Loading..." text.
function skeletonRowsHtml(count) {
return Array(count).fill('<div class="skeleton-row"></div>').join('');
}
// Fallback if a course's thumbnail image 404s/errors after the row
// already rendered (e.g. the file was removed on disk in between) -
// swaps back to the plain icon rather than showing a broken image.
@@ -1215,7 +1287,7 @@
searchActive = true;
const container = document.getElementById('library-groups');
const transcriptContainer = document.getElementById('transcript-results');
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching...</p>';
container.innerHTML = skeletonRowsHtml(2);
if (transcriptContainer) transcriptContainer.innerHTML = '';
fetch(`/library/search?q=${encodeURIComponent(query)}`)
@@ -1225,13 +1297,13 @@
`<span class="crumb current" style="max-width: none;">Search results for "${query}"</span>`;
lastLibraryItems = data.results;
if (!data.results.length) {
container.innerHTML = `<p style="color:#999; padding: 6px 0;">No courses match "${query}".</p>`;
container.innerHTML = `<p style="color:var(--text-muted); padding: 6px 0;">No courses match "${query}".</p>`;
return;
}
renderItemRows(sortLibraryItems(data.results));
})
.catch(() => {
container.innerHTML = '<p style="color:#ff6b6b;">Search failed.</p>';
container.innerHTML = '<p style="color:var(--error);">Search failed.</p>';
});
const searchTranscripts = document.getElementById('search-transcripts-toggle');
@@ -1243,7 +1315,7 @@
function runTranscriptSearch(query) {
const transcriptContainer = document.getElementById('transcript-results');
if (!transcriptContainer) return;
transcriptContainer.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching transcripts...</p>';
transcriptContainer.innerHTML = skeletonRowsHtml(2);
fetch(`/library/search-transcripts?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(data => {
@@ -1373,7 +1445,28 @@
}
{% endif %}
// ---- Collapsible dashboard cards (client-only, remembered per device) ----
function initCollapsibleCards() {
document.querySelectorAll('.collapsible-header').forEach(function(header) {
const cardId = header.dataset.cardId;
const body = header.nextElementSibling;
if (!cardId || !body) return;
if (localStorage.getItem('offlineu-collapsed-' + cardId) === '1') {
header.classList.add('collapsed');
body.classList.add('collapsed');
}
header.addEventListener('click', function() {
const isCollapsed = body.classList.toggle('collapsed');
header.classList.toggle('collapsed', isCollapsed);
localStorage.setItem('offlineu-collapsed-' + cardId, isCollapsed ? '1' : '0');
});
});
}
document.addEventListener('DOMContentLoaded', loadLibrary);
document.addEventListener('DOMContentLoaded', initCollapsibleCards);
</script>
<script src="/static/theme.js"></script>
+2
View File
@@ -22,6 +22,8 @@
--font-size-base: 16px;
--container-max-width: 1600px;
--radius: 8px;
--success: #28a745;
--error: #ff6b6b;
}
[data-theme="light"] {
--bg-primary: #f2f2f2;
+9 -7
View File
@@ -22,6 +22,8 @@
--font-size-base: 16px;
--container-max-width: 1600px;
--radius: 8px;
--success: #28a745;
--error: #ff6b6b;
}
[data-theme="light"] {
--bg-primary: #f2f2f2;
@@ -364,7 +366,7 @@
<div style="margin-bottom: 20px;">
<h4>{{ text_file.split('/')[-1] }}</h4>
<div class="text-content" id="content-{{ loop.index0 }}">
<div style="text-align: center; color: #666;">Loading content...</div>
<div style="text-align: center; color: var(--text-muted);">Loading content...</div>
</div>
<a href="/files/{{ text_file|replace('\\', '/') }}" class="file-link" target="_blank">
📄 Open {{ text_file.split('/')[-1] }} in new tab
@@ -450,7 +452,7 @@
// fetching them as text would show garbled binary content,
// so just point at the download/open link instead.
contentDiv.innerHTML =
'<div style="color: #999;">Preview isn\'t available for this file type. ' +
'<div style="color: var(--text-muted);">Preview isn\'t available for this file type. ' +
'Use the "Open in new tab" link below to view or download it.</div>';
} else {
// For text files, fetch and display content
@@ -478,9 +480,9 @@
.catch(error => {
console.error('Error loading content:', error);
if (contentDiv) {
contentDiv.innerHTML =
'<div style="color: #ff6b6b;">Error loading content: ' + error.message + '</div>' +
'<div style="color: #999; font-size: 0.9em; margin-top: 10px;">File path: ' + filePath + '</div>';
contentDiv.innerHTML =
'<div style="color: var(--error);">Error loading content: ' + error.message + '</div>' +
'<div style="color: var(--text-muted); font-size: 0.9em; margin-top: 10px;">File path: ' + filePath + '</div>';
}
});
}
@@ -762,7 +764,7 @@
const btn = document.querySelector('button[onclick="markCompleted()"]');
if (btn) {
btn.textContent = 'Completed ✓';
btn.style.background = '#28a745';
btn.style.background = 'var(--success)';
btn.disabled = true;
}
isCompleted = true;
@@ -775,7 +777,7 @@
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'success' ? '#28a745' : '#007acc'};
background: ${type === 'success' ? 'var(--success)' : 'var(--accent)'};
color: white;
padding: 15px 20px;
border-radius: 5px;
+10 -2
View File
@@ -22,6 +22,8 @@
--font-size-base: 16px;
--container-max-width: 1600px;
--radius: 8px;
--success: #28a745;
--error: #ff6b6b;
}
[data-theme="light"] {
--bg-primary: #f2f2f2;
@@ -171,13 +173,19 @@
line-height: 1.5;
}
.note-card-link {
display: block;
display: inline-block;
margin-top: 12px;
font-size: 0.85em;
color: var(--accent);
text-decoration: none;
padding: 4px 10px;
border-radius: var(--radius);
transition: background 0.2s ease;
}
.note-card-link:hover {
background: var(--bg-tertiary);
text-decoration: underline;
}
.note-card-link:hover { text-decoration: underline; }
</style>
</head>
<body>
+21 -19
View File
@@ -22,6 +22,8 @@
--font-size-base: 16px;
--container-max-width: 1600px;
--radius: 8px;
--success: #28a745;
--error: #ff6b6b;
}
[data-theme="light"] {
--bg-primary: #f2f2f2;
@@ -279,7 +281,7 @@
.curate-row-status {
flex-basis: 100%;
font-size: 0.8em;
color: #ff6b6b;
color: var(--error);
}
.bulk-rename-row {
display: flex;
@@ -307,7 +309,7 @@
}
#save-status {
font-size: 0.9em;
color: #28a745;
color: var(--success);
opacity: 0;
transition: opacity 0.3s;
}
@@ -658,7 +660,7 @@
renderCurateLevel(data, container);
})
.catch(() => {
container.innerHTML = '<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
container.innerHTML = '<p style="color:var(--error);">Could not reach the library scanner.</p>';
});
}
@@ -834,7 +836,7 @@
const preview = document.getElementById('bulk-rename-preview');
preview.innerHTML = '';
if (!pattern) {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = 'Enter text to find first.';
return;
}
@@ -847,7 +849,7 @@
renderBulkRenamePreview();
})
.catch(() => {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
@@ -898,7 +900,7 @@
const failed = results.filter(r => !r.success);
const activeCourseReset = results.some(r => r.active_course_reset);
status.style.color = failed.length ? '#ff6b6b' : '#28a745';
status.style.color = failed.length ? 'var(--error)' : 'var(--success)';
let message = `${succeeded} renamed`;
if (failed.length) {
message += `, ${failed.length} failed: ` +
@@ -919,7 +921,7 @@
loadCurateLevel(null, document.getElementById('curate-tree'), true);
})
.catch(() => {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
@@ -935,16 +937,16 @@
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.color = '#28a745';
status.style.color = 'var(--success)';
status.textContent = '✓ Saved — reload the main page to see it take effect';
showSaved();
} else {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = data.error || 'Could not save';
}
})
.catch(err => {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
});
}
@@ -955,7 +957,7 @@
const coursePath = input.value.trim();
if (!coursePath) {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = 'Please enter a course path.';
return;
}
@@ -971,16 +973,16 @@
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.color = '#28a745';
status.style.color = 'var(--success)';
status.textContent = `✓ Loaded "${data.course_name}" — redirecting...`;
setTimeout(() => { window.location.href = '/'; }, 1000);
} else {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = data.error || 'Could not load course';
}
})
.catch(() => {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
});
}
@@ -1055,17 +1057,17 @@
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.color = '#28a745';
status.style.color = 'var(--success)';
status.textContent = 'Saved.';
document.getElementById('outline-token').value = '';
loadOutlineConfig();
} else {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = data.error || 'Could not save.';
}
})
.catch(() => {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
@@ -1077,11 +1079,11 @@
fetch('/api/outline/test')
.then(r => r.json())
.then(data => {
status.style.color = data.success ? '#28a745' : '#ff6b6b';
status.style.color = data.success ? 'var(--success)' : 'var(--error)';
status.textContent = data.success ? 'Connected.' : (data.error || 'Connection failed.');
})
.catch(() => {
status.style.color = '#ff6b6b';
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}