Restyle recap.html to match recap-relay
The recap output looked notably different from recap-relay (bigger 15px font, different palette, single-column cards). Match recap-relay's job-output view: slate/indigo palette (--bg #0a0e1a, --accent #818cf8), 13px base type with the Helvetica/Arial stack, monospace accent-soft timestamps, and the two-pane layout — topic list on the left, full diarized transcript on the right, click a topic to scroll + highlight its range (inline JS, data baked in; no backend fetch). The summary/takeaways render as recap-relay-style cards in a band above the split. markdown() output unchanged. 66 tests pass.
This commit is contained in:
@@ -82,6 +82,10 @@ enum RecapRenderer {
|
|||||||
|
|
||||||
// MARK: - HTML
|
// MARK: - HTML
|
||||||
|
|
||||||
|
/// Mirror of recap-relay's job-output view: a header, an optional band of recap
|
||||||
|
/// cards (summary + takeaways), then a two-pane split — topic list on the left,
|
||||||
|
/// full diarized transcript on the right, click a topic to jump + highlight its
|
||||||
|
/// range. Self-contained (data baked in; the click handler is inline JS).
|
||||||
static func html(file: SpeakersFile, result: RecapResult, title: String,
|
static func html(file: SpeakersFile, result: RecapResult, title: String,
|
||||||
entries: [RecapAnalyzer.Entry]) -> String {
|
entries: [RecapAnalyzer.Entry]) -> String {
|
||||||
let speakers = RecapAnalyzer.orderedSpeakerNames(entries)
|
let speakers = RecapAnalyzer.orderedSpeakerNames(entries)
|
||||||
@@ -91,66 +95,78 @@ enum RecapRenderer {
|
|||||||
return "<span class=\"chip\" style=\"background:\(c)\">\(esc(name))</span>"
|
return "<span class=\"chip\" style=\"background:\(c)\">\(esc(name))</span>"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body = ""
|
// Header: title, meta line, speaker legend.
|
||||||
let sub = "\(esc(file.app)) · \(RecapAnalyzer.mmss(file.durationSec))"
|
let sub = "\(esc(file.app)) · \(RecapAnalyzer.mmss(file.durationSec))"
|
||||||
+ (speakers.isEmpty ? "" : " · \(speakers.count) speaker\(speakers.count == 1 ? "" : "s")")
|
+ (speakers.isEmpty ? "" : " · \(speakers.count) speaker\(speakers.count == 1 ? "" : "s")")
|
||||||
body += "<header><h1>\(esc(title))</h1><div class=\"sub\">\(sub)</div>"
|
var header = "<div class=\"header\"><div class=\"htext\"><h1>\(esc(title))</h1><div class=\"meta\">\(sub)</div></div>"
|
||||||
if !speakers.isEmpty {
|
if !speakers.isEmpty {
|
||||||
body += "<div class=\"legend\">" + speakers.map { chip($0) }.joined() + "</div>"
|
header += "<div class=\"legend\">" + speakers.map { chip($0) }.joined() + "</div>"
|
||||||
}
|
}
|
||||||
body += "</header>"
|
header += "</div>"
|
||||||
|
|
||||||
|
// Recap cards band (summary + template takeaways).
|
||||||
|
var cards = ""
|
||||||
if let x = result.extras {
|
if let x = result.extras {
|
||||||
if !x.tldr.isEmpty {
|
if !x.tldr.isEmpty {
|
||||||
body += card("Summary", "<p>\(esc(x.tldr))</p>"
|
cards += card("Summary", "<p>\(esc(x.tldr))</p>"
|
||||||
+ (x.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.primarySpeakers.map(esc).joined(separator: ", "))</p>"))
|
+ (x.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.primarySpeakers.map(esc).joined(separator: ", "))</p>"))
|
||||||
}
|
}
|
||||||
for section in x.sections where !section.isEmpty {
|
for section in x.sections where !section.isEmpty {
|
||||||
switch section.kind {
|
switch section.kind {
|
||||||
case .paragraph:
|
case .paragraph:
|
||||||
body += card(section.title, "<p>\(esc(section.paragraph))</p>")
|
cards += card(section.title, "<p>\(esc(section.paragraph))</p>")
|
||||||
case .bullets:
|
case .bullets:
|
||||||
body += card(section.title, "<ul>" + section.bullets.map { "<li>\(esc($0))</li>" }.joined() + "</ul>")
|
cards += card(section.title, "<ul>" + section.bullets.map { "<li>\(esc($0))</li>" }.joined() + "</ul>")
|
||||||
case .items:
|
case .items:
|
||||||
let lis = section.items.map { item -> String in
|
let lis = section.items.map { item -> String in
|
||||||
var s = "<li>\(esc(item.text))"
|
var s = "<li>\(esc(item.text))"
|
||||||
if let who = item.who { s += " <strong>\(esc(who))</strong>" }
|
if let who = item.who { s += " <span class=\"who\">\(esc(who))</span>" }
|
||||||
if let note = item.note { s += " <span class=\"muted\">(\(esc(note)))</span>" }
|
if let note = item.note { s += " <span class=\"note\">(\(esc(note)))</span>" }
|
||||||
if let when = item.when { s += " <span class=\"ts\">\(RecapAnalyzer.mmss(Double(when)))</span>" }
|
if let when = item.when { s += " <span class=\"ts-badge\">\(RecapAnalyzer.mmss(Double(when)))</span>" }
|
||||||
return s + "</li>"
|
return s + "</li>"
|
||||||
}.joined()
|
}.joined()
|
||||||
body += card(section.title, "<ul>\(lis)</ul>")
|
cards += card(section.title, "<ul>\(lis)</ul>")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let band = cards.isEmpty ? "" : "<div class=\"band\">\(cards)</div>"
|
||||||
|
|
||||||
if !result.sections.isEmpty {
|
// Left pane: topic cards (click to jump). data-start/data-end index entries.
|
||||||
var topics = ""
|
var left = "<div class=\"left\">"
|
||||||
for (i, sec) in result.sections.enumerated() {
|
if result.sections.isEmpty {
|
||||||
let range = entries.indices.contains(sec.startIndex) && entries.indices.contains(sec.endIndex)
|
left += "<div class=\"empty\">No topic sections.</div>"
|
||||||
? "<span class=\"ts\">\(RecapAnalyzer.mmss(entries[sec.startIndex].offset))–\(RecapAnalyzer.mmss(entries[sec.endIndex].end))</span>" : ""
|
} else {
|
||||||
topics += "<details class=\"topic\"><summary><span class=\"tnum\">\(i + 1)</span> \(esc(sec.title)) \(range)</summary>"
|
for sec in result.sections {
|
||||||
if !sec.summary.isEmpty { topics += "<p>\(esc(sec.summary))</p>" }
|
let s = max(0, min(sec.startIndex, entries.count - 1))
|
||||||
topics += "<div class=\"turns\">" + turnsHtml(sec, entries: entries, chip: chip) + "</div></details>"
|
let e = max(s, min(sec.endIndex, entries.count - 1))
|
||||||
|
let time = entries.indices.contains(s) && entries.indices.contains(e)
|
||||||
|
? "<span class=\"chunk-time\">\(RecapAnalyzer.mmss(entries[s].offset)) — \(RecapAnalyzer.mmss(entries[e].end))</span>" : ""
|
||||||
|
left += "<div class=\"chunk\" data-start=\"\(s)\" data-end=\"\(e)\" onclick=\"jump(this)\">"
|
||||||
|
+ "<div class=\"chunk-title\">\(esc(sec.title))\(time)</div>"
|
||||||
|
+ (sec.summary.isEmpty ? "" : "<div class=\"chunk-summary\">\(esc(sec.summary))</div>")
|
||||||
|
+ "</div>"
|
||||||
}
|
}
|
||||||
body += card("Topics", topics)
|
|
||||||
}
|
}
|
||||||
|
left += "</div>"
|
||||||
|
|
||||||
let full = entries.map { "<div class=\"turn\"><span class=\"ts\">\(RecapAnalyzer.mmss($0.offset))</span> \(chip($0.speaker)) <span class=\"txt\">\(esc($0.text))</span></div>" }.joined()
|
// Right pane: full diarized transcript, one line per turn (id=entry-i).
|
||||||
body += "<details class=\"topic\" open><summary>Full Transcript</summary><div class=\"turns\">\(full)</div></details>"
|
var right = "<div class=\"right\">"
|
||||||
|
if entries.isEmpty {
|
||||||
|
right += "<div class=\"empty\">No transcript.</div>"
|
||||||
|
} else {
|
||||||
|
for (i, en) in entries.enumerated() {
|
||||||
|
right += "<div class=\"transcript-line\" id=\"entry-\(i)\">"
|
||||||
|
+ "<span class=\"ts-badge\">\(RecapAnalyzer.mmss(en.offset))</span>"
|
||||||
|
+ chip(en.speaker)
|
||||||
|
+ "<span class=\"ts-text\">\(esc(en.text))</span></div>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
right += "</div>"
|
||||||
|
|
||||||
|
let body = header + band + "<div class=\"split\">\(left)\(right)</div>"
|
||||||
return htmlShell(title: esc(title), body: body)
|
return htmlShell(title: esc(title), body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func turnsHtml(_ sec: TopicSection, entries: [RecapAnalyzer.Entry],
|
|
||||||
chip: (String) -> String) -> String {
|
|
||||||
guard sec.startIndex <= sec.endIndex, entries.indices.contains(sec.startIndex), entries.indices.contains(sec.endIndex)
|
|
||||||
else { return "" }
|
|
||||||
return entries[sec.startIndex...sec.endIndex].map {
|
|
||||||
"<div class=\"turn\"><span class=\"ts\">\(RecapAnalyzer.mmss($0.offset))</span> \(chip($0.speaker)) <span class=\"txt\">\(esc($0.text))</span></div>"
|
|
||||||
}.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func card(_ title: String, _ inner: String) -> String {
|
private static func card(_ title: String, _ inner: String) -> String {
|
||||||
"<section class=\"card\"><h2>\(esc(title))</h2>\(inner)</section>"
|
"<section class=\"card\"><h2>\(esc(title))</h2>\(inner)</section>"
|
||||||
}
|
}
|
||||||
@@ -176,34 +192,63 @@ enum RecapRenderer {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>\(title)</title>
|
<title>\(title)</title>
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#15171c;--card:#1d2026;--fg:#e6e8ec;--muted:#9aa0aa;--line:#2a2e36;--accent:#5b8def;}
|
:root{--bg:#0a0e1a;--panel:#111827;--panel-2:#1e293b;--line:#1e293b;--line-2:#334155;
|
||||||
|
--fg:#e2e8f0;--fg-dim:#94a3b8;--fg-faint:#64748b;--accent:#818cf8;--accent-soft:#a5b4fc;}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;}
|
body{margin:0;background:var(--bg);color:var(--fg);min-height:100vh;
|
||||||
main{max-width:820px;margin:0 auto;padding:32px 20px 80px;}
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;font-size:13px;line-height:1.55}
|
||||||
header h1{margin:0 0 4px;font-size:24px}
|
.header{padding:14px 24px;background:var(--panel);border-bottom:1px solid var(--line);
|
||||||
.sub{color:var(--muted);font-size:13px}
|
display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
||||||
.legend{margin-top:12px;display:flex;flex-wrap:wrap;gap:6px}
|
.header .htext{min-width:0}
|
||||||
.chip{display:inline-block;padding:1px 8px;border-radius:10px;color:#fff;font-size:12px;font-weight:600}
|
.header h1{margin:0;font-size:16px;font-weight:700;color:var(--fg)}
|
||||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin-top:18px}
|
.header .meta{font-size:11px;color:var(--fg-faint);margin-top:2px;font-variant-numeric:tabular-nums}
|
||||||
.card h2{margin:0 0 10px;font-size:16px;color:var(--accent)}
|
.legend{margin-left:auto;display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
|
||||||
.muted{color:var(--muted)}
|
.chip{display:inline-block;padding:1px 8px;border-radius:999px;color:#fff;font-size:10px;font-weight:700;white-space:nowrap}
|
||||||
ul{margin:0;padding-left:18px} li{margin:4px 0}
|
.band{padding:16px 24px;display:grid;gap:12px}
|
||||||
ul.actions{list-style:none;padding-left:0}
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
|
||||||
.ts{color:var(--muted);font-variant-numeric:tabular-nums;font-size:12px;margin-right:4px}
|
.card h2{margin:0 0 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--accent-soft)}
|
||||||
blockquote{margin:0 0 12px;padding:8px 12px;border-left:3px solid var(--accent);background:#0e0f13;border-radius:0 8px 8px 0}
|
.card p{margin:0 0 8px}
|
||||||
blockquote cite{display:block;color:var(--muted);font-size:12px;margin-top:4px;font-style:normal}
|
.card p:last-child{margin-bottom:0}
|
||||||
details.topic{border-top:1px solid var(--line);padding:10px 0}
|
.card .muted{color:var(--fg-dim);font-size:12px}
|
||||||
details.topic > summary{cursor:pointer;font-weight:600;list-style:none}
|
.card ul{margin:0;padding-left:18px}
|
||||||
details.topic > summary::-webkit-details-marker{display:none}
|
.card li{margin:5px 0;color:var(--fg)}
|
||||||
.tnum{display:inline-block;min-width:20px;color:var(--accent);font-weight:700}
|
.card .who{color:var(--accent-soft);font-weight:600}
|
||||||
.turns{margin-top:10px}
|
.card .note{color:var(--fg-faint)}
|
||||||
.turn{margin:6px 0;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}
|
.split{display:flex;min-height:calc(100vh - 56px)}
|
||||||
.turn .txt{flex:1;min-width:60%}
|
.left{flex:0 0 42%;max-width:42%;border-right:1px solid var(--line);overflow-y:auto;padding:16px;background:var(--bg)}
|
||||||
@media print{body{background:#fff;color:#000}.card,blockquote{background:#fff;border-color:#ccc}details.topic{}.chip{border:1px solid #999}}
|
.right{flex:1;min-width:0;overflow-y:auto;padding:16px;background:var(--panel)}
|
||||||
|
@media(max-width:900px){.split{flex-direction:column}.left,.right{flex:none;max-width:100%;border-right:none}
|
||||||
|
.left{border-bottom:1px solid var(--line)}}
|
||||||
|
.chunk{padding:12px 14px;margin-bottom:8px;background:var(--panel);border:1px solid var(--line);
|
||||||
|
border-radius:10px;cursor:pointer;transition:border-color .15s,background .15s}
|
||||||
|
.chunk:hover{border-color:var(--accent)}
|
||||||
|
.chunk.active{border-color:var(--accent);background:rgba(129,140,248,.06);box-shadow:0 2px 16px rgba(129,140,248,.10)}
|
||||||
|
.chunk-title{font-size:13px;font-weight:700;color:var(--fg);margin-bottom:4px}
|
||||||
|
.chunk-time{font-size:10px;color:var(--fg-faint);margin-left:6px;font-weight:500;font-family:"SF Mono",Menlo,monospace}
|
||||||
|
.chunk-summary{font-size:12px;color:var(--fg-dim);line-height:1.5}
|
||||||
|
.transcript-line{display:flex;gap:10px;padding:4px 8px;border-radius:6px;line-height:1.6;align-items:baseline;scroll-margin-top:16px}
|
||||||
|
.transcript-line.hl{background:rgba(129,140,248,.10)}
|
||||||
|
.ts-badge{flex:0 0 auto;font-family:"SF Mono",Menlo,monospace;font-size:11px;color:var(--accent-soft);min-width:52px}
|
||||||
|
.ts-text{flex:1;font-size:13px;color:var(--fg)}
|
||||||
|
.empty{padding:32px 16px;text-align:center;color:var(--fg-faint)}
|
||||||
|
.foot{padding:14px 24px;color:var(--fg-faint);font-size:11px;border-top:1px solid var(--line)}
|
||||||
|
@media print{body{background:#fff;color:#000}.header,.right,.left,.card,.chunk{background:#fff;border-color:#ccc}
|
||||||
|
.split{display:block}.left,.right{max-width:100%}.chip{border:1px solid #999}}
|
||||||
</style></head>
|
</style></head>
|
||||||
<body><main>\(body)
|
<body>\(body)
|
||||||
<footer class="sub" style="margin-top:40px">Ten31 Transcripts · generated on-device</footer>
|
<div class="foot">Ten31 Transcripts · generated on-device</div>
|
||||||
</main></body></html>
|
<script>
|
||||||
|
function jump(el){
|
||||||
|
document.querySelectorAll('.chunk.active').forEach(function(x){x.classList.remove('active')});
|
||||||
|
el.classList.add('active');
|
||||||
|
var s=+el.dataset.start, e=+el.dataset.end;
|
||||||
|
var t=document.getElementById('entry-'+s);
|
||||||
|
if(t) t.scrollIntoView({behavior:'smooth',block:'start'});
|
||||||
|
document.querySelectorAll('.transcript-line.hl').forEach(function(x){x.classList.remove('hl')});
|
||||||
|
for(var i=s;i<=e;i++){var x=document.getElementById('entry-'+i); if(x) x.classList.add('hl');}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user