Refactor chapter page: semantic HTML, accessibility improvements, Tailwind hover classes, and book page summary zoom overlay
This commit is contained in:
@@ -1802,13 +1802,15 @@ func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request)
|
||||
// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────
|
||||
|
||||
const chapterTmpl = `
|
||||
<!-- ─── Sticky reader toolbar (nav, not header, so Reader Mode ignores it) ─── -->
|
||||
<!-- aria-hidden keeps it out of accessibility trees / Reader Mode heuristics -->
|
||||
<nav id="reader-header" aria-hidden="true"
|
||||
style="position:sticky;top:0;z-index:50;background:#09090b;border-bottom:1px solid #27272a;">
|
||||
<!-- ─── Sticky reader toolbar ────────────────────────────────────────────────
|
||||
Uses <nav> with a descriptive aria-label so screen readers expose it as a
|
||||
landmark. aria-hidden was previously misused here — removed. -->
|
||||
<nav id="reader-header" aria-label="Reader controls"
|
||||
class="sticky top-0 z-50 bg-zinc-950 border-b border-zinc-800">
|
||||
|
||||
<div class="max-w-2xl mx-auto px-4 flex items-center gap-2 h-14">
|
||||
|
||||
<!-- Back to book (book/list icon, visually distinct from ← Prev) -->
|
||||
<!-- Back to book list -->
|
||||
<a href="/books/{{.Slug}}"
|
||||
hx-get="/books/{{.Slug}}"
|
||||
hx-target="#main-content"
|
||||
@@ -1816,8 +1818,7 @@ const chapterTmpl = `
|
||||
hx-swap="innerHTML"
|
||||
title="Back to chapter list"
|
||||
aria-label="Back to chapter list"
|
||||
style="flex-shrink:0;padding:0.375rem 0.5rem;border-radius:0.5rem;color:#a1a1aa;font-size:1rem;text-decoration:none;transition:color 0.15s;line-height:1;"
|
||||
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#a1a1aa'">
|
||||
class="flex-shrink-0 px-2 py-1.5 rounded-lg text-zinc-400 hover:text-amber-400 transition-colors text-base leading-none no-underline">
|
||||
☰
|
||||
</a>
|
||||
|
||||
@@ -1830,20 +1831,21 @@ const chapterTmpl = `
|
||||
hx-swap="innerHTML"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;background:transparent;color:#a1a1aa;font-size:0.8125rem;text-decoration:none;transition:color 0.15s;"
|
||||
onmouseover="this.style.color='#f4f4f5'" onmouseout="this.style.color='#a1a1aa'">
|
||||
class="flex-shrink-0 px-2.5 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 text-[0.8125rem] transition-colors no-underline">
|
||||
← Prev
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
<!-- Chapter title button — opens chapter list drawer -->
|
||||
<button id="chapter-list-btn"
|
||||
type="button"
|
||||
onclick="toggleChapterList()"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
title="Jump to chapter"
|
||||
aria-label="Jump to chapter"
|
||||
style="flex:1;min-width:0;display:flex;align-items:center;justify-content:center;gap:0.375rem;overflow:hidden;background:transparent;border:none;cursor:pointer;padding:0.25rem 0.25rem;">
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.8125rem;color:#a1a1aa;max-width:100%;">{{.Title}}</span>
|
||||
<span style="flex-shrink:0;font-size:0.625rem;color:#52525b;" aria-hidden="true">▼</span>
|
||||
class="flex-1 min-w-0 flex items-center justify-center gap-1.5 overflow-hidden bg-transparent border-none cursor-pointer py-1 px-1">
|
||||
<span class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.8125rem] text-zinc-400 max-w-full">{{.Title}}</span>
|
||||
<span class="flex-shrink-0 text-[0.625rem] text-zinc-600" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
|
||||
<!-- Next chapter -->
|
||||
@@ -1855,97 +1857,101 @@ const chapterTmpl = `
|
||||
hx-swap="innerHTML"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;background:transparent;color:#a1a1aa;font-size:0.8125rem;text-decoration:none;transition:color 0.15s;"
|
||||
onmouseover="this.style.color='#f4f4f5'" onmouseout="this.style.color='#a1a1aa'">
|
||||
class="flex-shrink-0 px-2.5 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 text-[0.8125rem] transition-colors no-underline">
|
||||
Next →
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
<!-- TTS play/pause button -->
|
||||
<!-- TTS play/pause -->
|
||||
<button id="tts-btn"
|
||||
type="button"
|
||||
onclick="ttsToggle()"
|
||||
title="Listen"
|
||||
aria-label="Listen"
|
||||
style="flex-shrink:0;display:flex;align-items:center;gap:0.375rem;padding:0.375rem 0.625rem;border-radius:0.5rem;background:transparent;color:#f59e0b;font-size:0.8125rem;font-weight:500;border:none;cursor:pointer;transition:color 0.15s;"
|
||||
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#f59e0b'">
|
||||
class="flex-shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-transparent text-amber-500 hover:text-amber-400 text-[0.8125rem] font-medium border-none cursor-pointer transition-colors">
|
||||
<span id="tts-icon" aria-hidden="true">▶</span>
|
||||
<span id="tts-label">Listen</span>
|
||||
</button>
|
||||
|
||||
<!-- Settings gear button -->
|
||||
<!-- Settings -->
|
||||
<button id="settings-btn"
|
||||
type="button"
|
||||
onclick="toggleSettings()"
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
style="flex-shrink:0;padding:0.375rem 0.5rem;border-radius:0.5rem;background:transparent;color:#a1a1aa;font-size:1rem;border:none;cursor:pointer;transition:color 0.15s;"
|
||||
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#a1a1aa'">
|
||||
aria-label="Reader settings"
|
||||
aria-haspopup="dialog"
|
||||
class="flex-shrink-0 px-2 py-1.5 rounded-lg bg-transparent text-zinc-400 hover:text-amber-400 text-base border-none cursor-pointer transition-colors">
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TTS status strip -->
|
||||
<div id="tts-status-bar" style="display:none;padding:0.125rem 1rem;background:#18181b;font-size:0.75rem;color:#71717a;text-align:center;" aria-live="polite">
|
||||
<div id="tts-status-bar" hidden
|
||||
class="px-4 py-0.5 bg-zinc-900 text-xs text-zinc-500 text-center"
|
||||
aria-live="polite" aria-atomic="true">
|
||||
<span id="tts-status"></span>
|
||||
</div>
|
||||
|
||||
<!-- Chapter list drawer -->
|
||||
<div id="chapter-list-panel"
|
||||
style="display:none;position:absolute;left:0;right:0;top:100%;max-height:60vh;overflow-y:auto;background:#18181b;border-bottom:1px solid #27272a;box-shadow:0 8px 24px rgba(0,0,0,0.6);z-index:99;">
|
||||
<div style="max-width:42rem;margin:0 auto;padding:0.5rem 0;">
|
||||
role="listbox"
|
||||
aria-label="Chapter list"
|
||||
hidden
|
||||
class="absolute left-0 right-0 top-full max-h-[60vh] overflow-y-auto bg-zinc-900 border-b border-zinc-800 shadow-2xl z-[99]">
|
||||
<div class="max-w-2xl mx-auto py-2">
|
||||
{{range .AllChapters}}
|
||||
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||||
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
role="option"
|
||||
aria-selected="{{if eq .Number $.ChapterN}}true{{else}}false{{end}}"
|
||||
onclick="closeChapterList()"
|
||||
{{if eq .Number $.ChapterN}}data-current="1"{{end}}
|
||||
style="display:flex;align-items:baseline;gap:0.75rem;padding:0.5rem 1rem;text-decoration:none;transition:background 0.1s;{{if eq .Number $.ChapterN}}background:#27272a;{{end}}"
|
||||
onmouseover="this.style.background='#27272a'" onmouseout="this.style.background='{{if eq .Number $.ChapterN}}#27272a{{else}}transparent{{end}}'">
|
||||
<span style="flex-shrink:0;font-size:0.75rem;color:#52525b;width:2.5rem;text-align:right;" aria-hidden="true">{{.Number}}</span>
|
||||
<span style="font-size:0.875rem;{{if eq .Number $.ChapterN}}color:#fbbf24;font-weight:500;{{else}}color:#d4d4d8;{{end}}">{{if .Title}}{{.Title}}{{else}}Chapter {{.Number}}{{end}}</span>
|
||||
{{if eq .Number $.ChapterN}}<span style="flex-shrink:0;font-size:0.7rem;color:#f59e0b;margin-left:auto;" aria-hidden="true">▶ now</span>{{end}}
|
||||
class="flex items-baseline gap-3 px-4 py-2 no-underline transition-colors hover:bg-zinc-800 {{if eq .Number $.ChapterN}}bg-zinc-800{{end}}">
|
||||
<span class="flex-shrink-0 text-xs text-zinc-600 w-10 text-right" aria-hidden="true">{{.Number}}</span>
|
||||
<span class="text-sm {{if eq .Number $.ChapterN}}text-amber-400 font-medium{{else}}text-zinc-300{{end}}">
|
||||
{{if .Title}}{{.Title}}{{else}}Chapter {{.Number}}{{end}}
|
||||
</span>
|
||||
{{if eq .Number $.ChapterN}}<span class="ml-auto flex-shrink-0 text-[0.7rem] text-amber-500" aria-hidden="true">▶ now</span>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings dropdown panel -->
|
||||
<!-- Settings panel -->
|
||||
<div id="settings-panel"
|
||||
style="display:none;position:absolute;right:max(0.5rem,calc(50% - 32rem + 0.5rem));top:calc(100% + 0.25rem);min-width:260px;background:#18181b;border:1px solid #27272a;border-radius:0.75rem;padding:1rem;box-shadow:0 8px 24px rgba(0,0,0,0.5);z-index:100;">
|
||||
<!-- Voice -->
|
||||
<label style="display:block;margin-bottom:0.875rem;">
|
||||
<span style="display:block;font-size:0.75rem;color:#71717a;margin-bottom:0.375rem;">Voice</span>
|
||||
role="dialog"
|
||||
aria-label="Reader settings"
|
||||
hidden
|
||||
class="absolute right-[max(0.5rem,calc(50%-32rem+0.5rem))] top-[calc(100%+0.25rem)] min-w-[260px] bg-zinc-900 border border-zinc-800 rounded-xl p-4 shadow-2xl z-[100]">
|
||||
<label class="block mb-3.5">
|
||||
<span class="block text-xs text-zinc-500 mb-1.5">Voice</span>
|
||||
<select id="tts-voice"
|
||||
style="width:100%;border-radius:0.5rem;background:#27272a;border:1px solid #3f3f46;padding:0.375rem 0.5rem;font-size:0.875rem;color:#e4e4e7;outline:none;">
|
||||
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-2 py-1.5 text-sm text-zinc-200 outline-none">
|
||||
{{range .Voices}}
|
||||
<option value="{{.}}"{{if eq . $.DefaultVoice}} selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<!-- Speed -->
|
||||
<label style="display:block;margin-bottom:0.875rem;">
|
||||
<span style="display:block;font-size:0.75rem;color:#71717a;margin-bottom:0.375rem;">Speed — <span id="tts-speed-label">1.0×</span></span>
|
||||
<label class="block mb-3.5">
|
||||
<span class="block text-xs text-zinc-500 mb-1.5">Speed — <span id="tts-speed-label">1.0×</span></span>
|
||||
<input id="tts-speed" type="range"
|
||||
min="0.5" max="2" step="0.1" value="1"
|
||||
style="width:100%;accent-color:#f59e0b;cursor:pointer;" />
|
||||
class="w-full accent-amber-500 cursor-pointer" />
|
||||
</label>
|
||||
<!-- Auto-next -->
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;user-select:none;">
|
||||
<input id="tts-autoplay" type="checkbox" style="accent-color:#f59e0b;cursor:pointer;width:1rem;height:1rem;" />
|
||||
<span style="font-size:0.875rem;color:#d4d4d8;">Auto-play next chapter</span>
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input id="tts-autoplay" type="checkbox" class="accent-amber-500 cursor-pointer w-4 h-4" />
|
||||
<span class="text-sm text-zinc-300">Auto-play next chapter</span>
|
||||
</label>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Audio element outside nav/header so Reader Mode never sees it -->
|
||||
<audio id="tts-audio" aria-hidden="true" style="display:none"></audio>
|
||||
<!-- Hidden audio element -->
|
||||
<audio id="tts-audio" aria-hidden="true" hidden></audio>
|
||||
|
||||
<!-- ─── Chapter content ────────────────────────────────────────────────────── -->
|
||||
<!-- role="main" + <article> give Reader Mode a strong content anchor -->
|
||||
<div role="main" class="max-w-2xl mx-auto px-4 py-10">
|
||||
<!-- ─── Chapter content ───────────────────────────────────────────────────── -->
|
||||
<main class="max-w-2xl mx-auto px-4 py-10">
|
||||
|
||||
<!-- Visible h1 inside the content area — Reader Mode anchors on this title -->
|
||||
<h1 class="text-2xl font-bold text-zinc-100 mb-6 leading-snug">{{.Title}}</h1>
|
||||
|
||||
<article id="chapter-article" class="prose text-zinc-300 leading-relaxed text-[1.05rem]">
|
||||
@@ -1974,69 +1980,68 @@ const chapterTmpl = `
|
||||
</a>
|
||||
{{else}}<span></span>{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ─── Audio queue panel (sticky bottom) ───────────────────────────────────── -->
|
||||
<div id="audio-queue-bar"
|
||||
role="region"
|
||||
aria-label="Audio queue"
|
||||
style="position:fixed;bottom:0;left:0;right:0;z-index:60;background:#09090b;border-top:1px solid #27272a;font-size:0.75rem;color:#a1a1aa;">
|
||||
<!-- ─── Audio queue bar (sticky bottom) ──────────────────────────────────── -->
|
||||
<aside id="audio-queue-bar"
|
||||
aria-label="Audio queue"
|
||||
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950 border-t border-zinc-800 text-xs text-zinc-400">
|
||||
|
||||
<!-- Toggle handle ─────────────────────────────────────────────────────────── -->
|
||||
<!-- Toggle handle -->
|
||||
<button id="queue-toggle"
|
||||
type="button"
|
||||
onclick="window.toggleQueuePanel()"
|
||||
aria-expanded="false"
|
||||
style="width:100%;display:flex;align-items:center;justify-content:between;gap:0.5rem;padding:0.4rem 1rem;background:transparent;border:none;cursor:pointer;color:#a1a1aa;font-size:0.7rem;text-align:left;">
|
||||
<span style="flex:1;display:flex;align-items:center;gap:0.5rem;">
|
||||
<span id="queue-toggle-icon" style="font-size:0.65rem;">▲</span>
|
||||
<span style="font-weight:600;letter-spacing:0.05em;text-transform:uppercase;">Audio Queue</span>
|
||||
class="w-full flex items-center gap-2 px-4 py-1.5 bg-transparent border-none cursor-pointer text-zinc-400 text-[0.7rem] text-left">
|
||||
<span class="flex-1 flex items-center gap-2">
|
||||
<span id="queue-toggle-icon" aria-hidden="true" class="text-[0.65rem]">▲</span>
|
||||
<span class="font-semibold tracking-widest uppercase">Audio Queue</span>
|
||||
<span id="queue-now-badge" class="queue-badge queue-badge-idle">idle</span>
|
||||
</span>
|
||||
<!-- Mini scrubber always visible in the handle row -->
|
||||
<span style="flex:2;display:flex;align-items:center;gap:0.5rem;padding:0 0.5rem;">
|
||||
<span id="queue-time-cur" style="min-width:2.5rem;text-align:right;font-variant-numeric:tabular-nums;">0:00</span>
|
||||
<span style="flex:1;position:relative;height:3px;background:#3f3f46;border-radius:2px;overflow:hidden;">
|
||||
<span id="queue-scrubber-fill" style="position:absolute;left:0;top:0;height:100%;width:0%;background:#f59e0b;transition:width 0.3s linear;"></span>
|
||||
<!-- Mini scrubber -->
|
||||
<span class="flex-[2] flex items-center gap-2 px-2" aria-hidden="true">
|
||||
<span id="queue-time-cur" class="min-w-[2.5rem] text-right tabular-nums">0:00</span>
|
||||
<span class="flex-1 relative h-[3px] bg-zinc-700 rounded-sm overflow-hidden">
|
||||
<span id="queue-scrubber-fill" class="absolute left-0 top-0 h-full w-0 bg-amber-500 transition-[width] duration-300 linear"></span>
|
||||
</span>
|
||||
<span id="queue-time-tot" style="min-width:2.5rem;font-variant-numeric:tabular-nums;">0:00</span>
|
||||
<span id="queue-time-tot" class="min-w-[2.5rem] tabular-nums">0:00</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Expanded panel ────────────────────────────────────────────────────────── -->
|
||||
<div id="queue-panel-body" style="display:none;border-top:1px solid #27272a;">
|
||||
<!-- Expanded panel -->
|
||||
<div id="queue-panel-body" hidden class="border-t border-zinc-800">
|
||||
|
||||
<!-- Now playing row -->
|
||||
<!-- Now playing -->
|
||||
<div class="queue-row" id="queue-row-current">
|
||||
<span class="queue-row-label">Now</span>
|
||||
<span class="queue-row-title" id="queue-cur-title">—</span>
|
||||
<span class="queue-badge" id="queue-cur-badge">idle</span>
|
||||
</div>
|
||||
|
||||
<!-- Up next row (hidden if no next chapter) -->
|
||||
<div class="queue-row" id="queue-row-next" style="display:none;">
|
||||
<!-- Up next -->
|
||||
<div class="queue-row" id="queue-row-next" hidden>
|
||||
<span class="queue-row-label">Next</span>
|
||||
<span class="queue-row-title" id="queue-next-title">—</span>
|
||||
<span class="queue-badge" id="queue-next-badge">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Debug info row -->
|
||||
<!-- Debug row -->
|
||||
<div class="queue-row queue-row-debug" id="queue-row-debug">
|
||||
<span class="queue-row-label">dbg</span>
|
||||
<span id="queue-debug-text" style="font-family:monospace;word-break:break-all;"></span>
|
||||
<span id="queue-debug-text" class="font-mono break-all"></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
/* Paragraph TTS highlight — no cursor change so paragraphs look editorial */
|
||||
/* Paragraph TTS highlight */
|
||||
#chapter-article p {
|
||||
border-radius: 0.375rem;
|
||||
margin-left: -0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
/* Subtle tap affordance only on touch/pointer devices that support hover */
|
||||
@media (hover: hover) {
|
||||
#chapter-article p:hover { background: rgba(251,191,36,0.07); }
|
||||
}
|
||||
@@ -2045,16 +2050,13 @@ const chapterTmpl = `
|
||||
border-left: 2px solid #f59e0b;
|
||||
padding-left: calc(0.5rem - 2px);
|
||||
}
|
||||
/* Prevent sticky nav from covering anchor targets */
|
||||
#main-content { scroll-padding-top: 3.5rem; }
|
||||
/* Improve base reading typography */
|
||||
|
||||
/* Prevent sticky nav from obscuring anchor targets */
|
||||
#main-content { scroll-padding-top: 3.5rem; padding-bottom: 4rem; }
|
||||
#chapter-article { font-size: 1.0625rem; line-height: 1.8; }
|
||||
#chapter-article p + p { margin-top: 0; }
|
||||
|
||||
/* ── Audio queue bar ─────────────────────────────────────────────────────── */
|
||||
/* Extra bottom padding so chapter text is never hidden behind the sticky bar */
|
||||
#main-content { padding-bottom: 4rem; }
|
||||
|
||||
/* Audio queue badges */
|
||||
.queue-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1em 0.5em;
|
||||
@@ -2065,13 +2067,14 @@ const chapterTmpl = `
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.queue-badge-idle { background: #27272a; color: #71717a; }
|
||||
.queue-badge-generating { background: #78350f; color: #fbbf24; }
|
||||
.queue-badge-ready { background: #14532d; color: #4ade80; }
|
||||
.queue-badge-playing { background: #1e3a5f; color: #60a5fa; }
|
||||
.queue-badge-paused { background: #1c1917; color: #d6d3d1; }
|
||||
.queue-badge-error { background: #450a0a; color: #f87171; }
|
||||
.queue-badge-idle { background: #27272a; color: #71717a; }
|
||||
.queue-badge-generating { background: #78350f; color: #fbbf24; }
|
||||
.queue-badge-ready { background: #14532d; color: #4ade80; }
|
||||
.queue-badge-playing { background: #1e3a5f; color: #60a5fa; }
|
||||
.queue-badge-paused { background: #1c1917; color: #d6d3d1; }
|
||||
.queue-badge-error { background: #450a0a; color: #f87171; }
|
||||
|
||||
/* Queue rows */
|
||||
.queue-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2095,11 +2098,7 @@ const chapterTmpl = `
|
||||
white-space: nowrap;
|
||||
color: #d4d4d8;
|
||||
}
|
||||
.queue-row-debug {
|
||||
background: #0c0c0e;
|
||||
color: #52525b;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.queue-row-debug { background: #0c0c0e; color: #52525b; font-size: 0.65rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user