diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7af0aeb..0f66f08 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+2026-08-26 14:45 UTC — Redesign expandable lists to a leaner, flat style
2026-08-26 14:27 UTC — Add expand all/collapse all to the course tree
2026-08-26 14:13 UTC — Header/footer polish, fix dead link, add focus rings
2026-08-26 13:41 UTC — Move sidebar left, add collapse toggle and remembered sections
diff --git a/README.md b/README.md
index 8f32150..c7fb7b8 100644
--- a/README.md
+++ b/README.md
@@ -169,7 +169,8 @@ silently stay blank instead of erroring.
rather than a level to browse into); every row also has a checkbox, so
several items - found via search or expanded across different tree
levels - can be hidden, shown, or moved to the same destination together
- in one batch instead of one at a time.
+ in one batch instead of one at a time. Rows use the same flat, leading-
+ chevron styling as the course tree (see Playback & progress below).
- *Undo*: a "Last action: ... [Undo]" bar appears after any move or
rename (Sort Unsorted apply, Bulk Rename apply, a Manage Library
move/rename, a bulk move) and reverses the whole batch in one click.
@@ -242,7 +243,11 @@ silently stay blank instead of erroring.
"Expand all" link (toggling to "Collapse all") opens or closes every
section at once - same control on the dashboard's course tree.
Shares its rendering with the loaded-course dashboard view (`templates/
- _course_tree.html`), so the two always look and behave the same.
+ _course_tree.html`), so the two always look and behave the same. Flat
+ list styling - no per-row box or border, a small leading chevron that
+ doubles as the expand affordance instead of a separate right-edge
+ button, indentation for hierarchy - for a denser, cleaner look than
+ boxed rows would give at this list length.
- Auto-play next lesson when one ends, with a cancelable few-second
countdown - on by default, toggle it off in Settings → Video Player
- Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek
diff --git a/VERSION b/VERSION
index b84a828..51b7f31 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2026-08-26 14:27 UTC — add expand all/collapse all to the course tree
+2026-08-26 14:45 UTC — redesign expandable lists to a leaner, flat style
diff --git a/templates/_course_tree.html b/templates/_course_tree.html
index f5abeee..6bfb8b9 100644
--- a/templates/_course_tree.html
+++ b/templates/_course_tree.html
@@ -8,6 +8,13 @@
defined by whichever page imports this - see course_dashboard.html
for the canonical versions.
+ Flat list styling (no per-row box/border), a leading chevron that
+ doubles as the expand affordance instead of a separate right-edge
+ button, and indentation for hierarchy - the chevron's rotation is
+ driven purely by CSS (:has(+ .tree-content.expanded)), so toggling
+ is just adding/removing one class on .tree-content; nothing in JS
+ ever touches the icon directly.
+
`current_lesson` (a Lesson object, not a path string) is optional -
when given, the matching row gets a `current` class instead of being
a plain click target, and stays unclickable since you're already
@@ -20,16 +27,11 @@
{% set stats = section_stats(node) %}
{% if node.children or node.lessons %}
@@ -44,31 +46,21 @@
{% set is_current = current_lesson and lesson.path == current_lesson.path %}
-
-
- {% if lesson.lesson_type == 'video' %}{{ icons.icon('video', 16) }}
- {% elif lesson.lesson_type == 'audio' %}{{ icons.icon('music', 16) }}
- {% elif lesson.lesson_type == 'quiz' %}{{ icons.icon('clipboard', 16) }}
- {% elif lesson.lesson_type == 'mixed' %}{{ icons.icon('package', 16) }}
- {% else %}{{ icons.icon('file-text', 16) }}{% endif %}
-
- {{ lesson.title }}
-
-
- {{ lesson.lesson_type|title }}
- {% if lesson.duration_seconds and lesson.duration_seconds >= 60 %}
- {{ lesson.duration_seconds|format_duration }}
- {% endif %}
- {% if lesson.completed %}
- {{ icons.icon('check', 14) }}
- {% elif percent_watched %}
- {{ percent_watched }}% watched
- {% else %}
- {{ icons.icon('circle', 14) }}
- {% endif %}
-
- {% if percent_watched %}
-
+
+ {% if lesson.lesson_type == 'video' %}{{ icons.icon('video', 15) }}
+ {% elif lesson.lesson_type == 'audio' %}{{ icons.icon('music', 15) }}
+ {% elif lesson.lesson_type == 'quiz' %}{{ icons.icon('clipboard', 15) }}
+ {% elif lesson.lesson_type == 'mixed' %}{{ icons.icon('package', 15) }}
+ {% else %}{{ icons.icon('file-text', 15) }}{% endif %}
+
+
{{ lesson.title }}
+ {% if lesson.duration_seconds and lesson.duration_seconds >= 60 %}
+
{{ lesson.duration_seconds|format_duration }}
+ {% endif %}
+ {% if lesson.completed %}
+
{{ icons.icon('check', 13) }}
+ {% elif percent_watched %}
+
{{ percent_watched }}%
{% endif %}
{% endfor %}
diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html
index 47f9ce4..be6e6ac 100644
--- a/templates/course_dashboard.html
+++ b/templates/course_dashboard.html
@@ -682,6 +682,89 @@
display: block;
}
+ /* Course-outline tree: a leaner, flatter look than the boxed rows
+ above - those stay as-is since directoryRowHtml (the Library
+ folder browser) reuses the same base .tree-header/.tree-icon/
+ .lesson-item/etc. classes and isn't part of this redesign.
+ Scoped under .tree-container (exclusive to the course tree) so
+ nothing outside it is affected. No per-row background/border; a
+ small leading chevron doubles as the expand affordance instead
+ of a separate right-edge button, rotating via :has() so JS
+ never has to touch the icon directly - toggling is just
+ add/remove of one class on .tree-content. */
+ .tree-container .tree-header {
+ background: none;
+ border-left: none;
+ padding: 6px 8px;
+ gap: 8px;
+ }
+ .tree-container .tree-header:hover {
+ background: var(--bg-tertiary);
+ }
+ .tree-container .tree-toggle-icon {
+ display: inline-block;
+ width: 14px;
+ flex-shrink: 0;
+ color: var(--accent);
+ font-size: 0.85em;
+ transition: transform 0.15s ease;
+ }
+ .tree-container .tree-header:has(+ .tree-content.expanded) .tree-toggle-icon {
+ transform: rotate(90deg);
+ }
+ .tree-container .tree-name {
+ font-weight: 600;
+ }
+ .tree-container .tree-content {
+ margin-left: 20px;
+ margin-top: 2px;
+ }
+ .tree-container .lesson-item {
+ background: none;
+ border-left: none;
+ padding: 5px 8px;
+ margin-bottom: 0;
+ gap: 8px;
+ justify-content: flex-start;
+ transform: none;
+ }
+ .tree-container .lesson-item .lesson-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 400;
+ font-size: 0.95em;
+ }
+ .tree-container .lesson-item:hover {
+ background: var(--bg-tertiary);
+ transform: none;
+ }
+ .tree-container .lesson-item.completed {
+ background: none;
+ }
+ .tree-container .lesson-item.completed:hover {
+ background: var(--bg-tertiary);
+ }
+ .tree-container .lesson-item.current {
+ background: var(--bg-tertiary);
+ cursor: default;
+ }
+ .tree-container .lesson-item.current .lesson-name {
+ font-weight: 600;
+ color: var(--accent);
+ }
+ .tree-container .lesson-item .lesson-icon {
+ font-size: 0.9em;
+ color: var(--text-muted);
+ }
+ .tree-container .lesson-item .lesson-duration {
+ font-size: 0.8em;
+ color: var(--text-muted);
+ flex-shrink: 0;
+ }
+
.library-toolbar {
display: flex;
gap: 10px;
@@ -1376,13 +1459,13 @@
}
function toggleTree(element) {
- // Find the content div that comes after this header
+ // Find the content div that comes after this header - the
+ // chevron's rotation is pure CSS (:has(+ .tree-content.expanded)
+ // in _course_tree.html's consumers), so toggling this one class
+ // is the entire job here.
const content = element.nextElementSibling;
- const toggle = element.querySelector('.tree-toggle');
-
if (content && content.classList.contains('tree-content')) {
const isExpanded = content.classList.toggle('expanded');
- if (toggle) toggle.textContent = isExpanded ? '▼' : '▶';
if (content.dataset.sectionPath) saveSectionExpanded(content.dataset.sectionPath, isExpanded);
}
}
@@ -1410,11 +1493,7 @@
const saved = getExpandedSectionPaths();
if (!saved.size) return;
document.querySelectorAll('.tree-content[data-section-path]').forEach(function(content) {
- if (!saved.has(content.dataset.sectionPath)) return;
- content.classList.add('expanded');
- const header = content.previousElementSibling;
- const toggle = header ? header.querySelector('.tree-toggle') : null;
- if (toggle) toggle.textContent = '▼';
+ if (saved.has(content.dataset.sectionPath)) content.classList.add('expanded');
});
}
restoreExpandedSections();
@@ -1430,9 +1509,6 @@
const shouldExpand = !contents.every(c => c.classList.contains('expanded'));
contents.forEach(function(content) {
content.classList.toggle('expanded', shouldExpand);
- const header = content.previousElementSibling;
- const toggle = header ? header.querySelector('.tree-toggle') : null;
- if (toggle) toggle.textContent = shouldExpand ? '▼' : '▶';
if (content.dataset.sectionPath) saveSectionExpanded(content.dataset.sectionPath, shouldExpand);
});
if (btnEl) btnEl.textContent = shouldExpand ? 'Collapse all' : 'Expand all';
diff --git a/templates/lesson_view.html b/templates/lesson_view.html
index cb3c508..c34cb79 100644
--- a/templates/lesson_view.html
+++ b/templates/lesson_view.html
@@ -223,8 +223,10 @@
/* Course-outline sidebar tree - scoped under .lesson-sidebar so
these don't collide with this page's own .lesson-title (the H1)
and similarly-named rules. Mirrors course_dashboard.html's tree
- styling (shared macro, see _course_tree.html) at a denser size
- for the narrower column. */
+ styling (shared macro, see _course_tree.html): a flat list, no
+ per-row background/border, a small leading chevron that doubles
+ as the expand affordance and rotates via :has() rather than any
+ JS touching it directly. */
.lesson-sidebar .tree-container-header {
display: flex;
justify-content: flex-end;
@@ -246,40 +248,33 @@
margin-top: 6px;
}
.lesson-sidebar .tree-item {
- margin-bottom: 5px;
+ margin-bottom: 0;
}
.lesson-sidebar .tree-header {
- background: var(--bg-tertiary);
- padding: 10px 12px;
+ padding: 5px 6px;
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
- justify-content: space-between;
- transition: background 0.3s;
- border-left: 3px solid #666;
+ gap: 6px;
+ transition: background 0.15s;
}
.lesson-sidebar .tree-header:hover {
- background: var(--bg-tertiary-hover);
+ background: var(--bg-tertiary);
}
- .lesson-sidebar .tree-header.directory {
- border-left-color: var(--accent);
- }
- .lesson-sidebar .tree-title {
- display: flex;
- align-items: center;
- gap: 8px;
- flex: 1;
- min-width: 0;
- }
- .lesson-sidebar .tree-icon {
- font-size: 1.1em;
- width: 18px;
- text-align: center;
+ .lesson-sidebar .tree-toggle-icon {
+ display: inline-block;
+ width: 12px;
flex-shrink: 0;
+ color: var(--accent);
+ font-size: 0.8em;
+ transition: transform 0.15s ease;
+ }
+ .lesson-sidebar .tree-header:has(+ .tree-content.expanded) .tree-toggle-icon {
+ transform: rotate(90deg);
}
.lesson-sidebar .tree-name {
- font-weight: 500;
+ font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
@@ -288,132 +283,72 @@
font-size: 0.9em;
}
.lesson-sidebar .tree-stats {
- font-size: 0.75em;
+ font-size: 0.7em;
color: var(--text-muted);
- margin-left: 8px;
flex-shrink: 0;
white-space: nowrap;
}
- .lesson-sidebar .tree-toggle {
- background: none;
- border: none;
- color: var(--text-primary);
- font-size: 1.1em;
- cursor: pointer;
- padding: 4px;
- border-radius: 3px;
- transition: background 0.3s;
- }
- .lesson-sidebar .tree-toggle:hover {
- background: #555;
- }
.lesson-sidebar .tree-content {
- margin-left: 16px;
- margin-top: 5px;
+ margin-left: 14px;
+ margin-top: 1px;
display: none;
}
.lesson-sidebar .tree-content.expanded {
display: block;
}
.lesson-sidebar .lesson-item {
- background: var(--bg-tertiary);
- padding: 8px 12px;
+ padding: 4px 6px;
border-radius: var(--radius);
- margin-bottom: 5px;
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- align-items: center;
- transition: all 0.2s;
- cursor: pointer;
- border-left: 3px solid #666;
- position: relative;
- overflow: hidden;
- gap: 4px;
- }
- .lesson-sidebar .lesson-item:hover {
- border-left-color: var(--accent);
- background: var(--bg-tertiary-hover);
- }
- .lesson-sidebar .lesson-item.completed {
- border-left-color: var(--success);
- }
- .lesson-sidebar .lesson-item.in-progress {
- border-left-color: var(--accent);
- }
- .lesson-sidebar .lesson-item.current {
- border-left-color: var(--accent);
- background: var(--bg-tertiary-hover);
- cursor: default;
- font-weight: 600;
- }
- .lesson-sidebar .lesson-progress-track {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- height: 3px;
- background: rgba(255, 255, 255, 0.08);
- }
- .lesson-sidebar .lesson-progress-fill {
- height: 100%;
- background: var(--accent);
- }
- .lesson-sidebar .lesson-item.completed .lesson-progress-fill {
- background: var(--success);
- }
- .lesson-sidebar .watched-badge {
- font-size: 0.7em;
- background: var(--bg-primary);
- color: var(--accent);
- padding: 1px 6px;
- border-radius: 3px;
- border: 1px solid var(--accent);
- white-space: nowrap;
- }
- .lesson-sidebar .lesson-item .lesson-title {
+ margin-bottom: 0;
display: flex;
align-items: center;
gap: 6px;
- flex: 1;
- min-width: 0;
+ transition: background 0.15s;
+ cursor: pointer;
+ }
+ .lesson-sidebar .lesson-item:hover {
+ background: var(--bg-tertiary);
+ }
+ .lesson-sidebar .lesson-item.current {
+ background: var(--bg-tertiary);
+ cursor: default;
+ }
+ .lesson-sidebar .watched-badge {
+ font-size: 0.65em;
+ color: var(--accent);
+ white-space: nowrap;
+ flex-shrink: 0;
}
.lesson-sidebar .lesson-icon {
- font-size: 1em;
+ font-size: 0.85em;
+ color: var(--text-muted);
flex-shrink: 0;
}
.lesson-sidebar .lesson-name {
- font-size: 0.85em;
+ font-size: 0.8em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ flex: 1;
+ min-width: 0;
}
- .lesson-sidebar .lesson-meta {
- font-size: 0.75em;
- color: var(--text-muted);
- display: flex;
- align-items: center;
- gap: 4px;
- flex-shrink: 0;
- }
- .lesson-sidebar .lesson-type {
- display: none;
+ .lesson-sidebar .lesson-item.current .lesson-name {
+ font-weight: 600;
+ color: var(--accent);
}
.lesson-sidebar .lesson-duration {
- font-size: 0.75em;
+ font-size: 0.7em;
color: var(--text-muted);
white-space: nowrap;
+ flex-shrink: 0;
}
.lesson-sidebar .status-icon {
- font-size: 1.1em;
- font-weight: bold;
+ font-size: 0.9em;
+ flex-shrink: 0;
}
.lesson-sidebar .status-icon.completed {
color: var(--success);
}
- .lesson-sidebar .status-icon.pending {
- color: var(--text-muted);
- }
video, audio {
width: 100%;
max-width: 100%;
@@ -1262,12 +1197,13 @@
// ---- Course outline sidebar (shared tree markup/macro with the
// dashboard's loaded-course view - see _course_tree.html) ----
+ // The chevron's rotation is pure CSS (:has(+ .tree-content.
+ // expanded) in this file's tree styles), so toggling this one
+ // class is the entire job - nothing here ever touches the icon.
function toggleTree(element) {
const content = element.nextElementSibling;
- const toggleBtn = element.querySelector('.tree-toggle');
if (content && content.classList.contains('tree-content')) {
const isExpanded = content.classList.toggle('expanded');
- if (toggleBtn) toggleBtn.textContent = isExpanded ? '▼' : '▶';
if (content.dataset.sectionPath) saveSectionExpanded(content.dataset.sectionPath, isExpanded);
}
}
@@ -1292,9 +1228,6 @@
function expandSection(content) {
content.classList.add('expanded');
- const header = content.previousElementSibling;
- const toggleBtn = header ? header.querySelector('.tree-toggle') : null;
- if (toggleBtn) toggleBtn.textContent = '▼';
}
(function restoreExpandedSections() {
@@ -1334,9 +1267,6 @@
const shouldExpand = !contents.every(c => c.classList.contains('expanded'));
contents.forEach(function(content) {
content.classList.toggle('expanded', shouldExpand);
- const header = content.previousElementSibling;
- const toggle = header ? header.querySelector('.tree-toggle') : null;
- if (toggle) toggle.textContent = shouldExpand ? '▼' : '▶';
if (content.dataset.sectionPath) saveSectionExpanded(content.dataset.sectionPath, shouldExpand);
});
if (btnEl) btnEl.textContent = shouldExpand ? 'Collapse all' : 'Expand all';
diff --git a/templates/unsorted.html b/templates/unsorted.html
index f0d1807..b6bd607 100644
--- a/templates/unsorted.html
+++ b/templates/unsorted.html
@@ -164,14 +164,14 @@
align-items: center;
justify-content: space-between;
gap: 10px;
- padding: 8px 10px;
+ padding: 5px 8px;
border-radius: var(--radius);
- background: var(--bg-tertiary);
- margin-bottom: 5px;
+ background: none;
+ margin-bottom: 0;
cursor: pointer;
}
.curate-row:hover {
- background: var(--bg-tertiary-hover);
+ background: var(--bg-tertiary);
}
.curate-row.is-hidden {
opacity: 0.55;
@@ -188,15 +188,24 @@
text-overflow: ellipsis;
white-space: nowrap;
}
+ /* Leading chevron doubles as the expand affordance (matches the
+ course tree's leaner style - see _course_tree.html) and rotates
+ via :has() rather than any JS touching it directly; toggling is
+ just add/remove of one class on .curate-children. */
.curate-toggle {
- font-size: 1.1em;
- width: 18px;
- text-align: center;
+ display: inline-block;
+ width: 12px;
flex-shrink: 0;
+ color: var(--accent);
+ font-size: 0.85em;
+ transition: transform 0.15s ease;
+ }
+ .curate-row:has(+ .curate-children.expanded) .curate-toggle {
+ transform: rotate(90deg);
}
.curate-children {
- margin-left: 22px;
- margin-top: 4px;
+ margin-left: 16px;
+ margin-top: 1px;
display: none;
}
.curate-children.expanded {
@@ -1456,7 +1465,7 @@
${checkbox}
- ▶
+ ▸
${icon}
${escapeHtml(item.name)}
@@ -1477,16 +1486,13 @@
function toggleCurateDir(rowEl, path) {
const content = rowEl.nextElementSibling;
- const toggleIcon = rowEl.querySelector('.curate-toggle');
if (!content || !content.classList.contains('curate-children')) return;
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
- if (toggleIcon) toggleIcon.textContent = '▶';
return;
}
content.classList.add('expanded');
- if (toggleIcon) toggleIcon.textContent = '▼';
if (content.dataset.loaded === 'true') return;
content.innerHTML = '
Loading...
';