Files
nib-v1/internal/export/template.html
T
lerko 564039112a
CI / test (pull_request) Failing after 10s
feat(export): add HTML card deck export
Self-contained single-file HTML export for cards. Mobile-first,
dark theme, zero dependencies. Each card type gets its own
interactive treatment: snippet tap-to-copy, template slot filling,
checklist with progress bar, decision structured layout, link
tap targets. Filter chips by type, search across all cards.

Usage: nib export -f html -o deck.html
       nib export -f html -t triage -o triage.html
2026-05-20 21:49:19 -04:00

356 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{.Title}}</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#111110;--surface:#1a1a19;--surface2:#232322;
--border:#2e2e2c;--border-focus:#4a4a47;
--text:#e8e6e1;--text2:#9a9890;--text3:#6b6a63;
--accent:#c4a46c;--accent2:#a68a52;
--red:#c45b5b;--green:#6bab6b;--blue:#6b8fc4;
--mono:"Berkeley Mono","SF Mono","Cascadia Code","JetBrains Mono",monospace;
--sans:"Inter","SF Pro Text","Segoe UI",system-ui,sans-serif;
--card-radius:12px;
--safe-bottom:env(safe-area-inset-bottom,0px);
}
html{font-size:16px;-webkit-text-size-adjust:100%}
body{
font-family:var(--sans);color:var(--text);background:var(--bg);
min-height:100dvh;padding:0 0 calc(72px + var(--safe-bottom));
-webkit-font-smoothing:antialiased;
}
.deck-header{
position:sticky;top:0;z-index:10;
background:var(--bg);border-bottom:1px solid var(--border);
padding:16px 20px 12px;
}
.deck-title{font-size:1.25rem;font-weight:600;color:var(--text);letter-spacing:-0.01em}
.deck-meta{font-size:0.8rem;color:var(--text3);margin-top:4px}
.filter-bar{
display:flex;gap:8px;padding:12px 20px;overflow-x:auto;
-webkit-overflow-scrolling:touch;scrollbar-width:none;
}
.filter-bar::-webkit-scrollbar{display:none}
.filter-chip{
flex-shrink:0;padding:6px 14px;border-radius:20px;
font-size:0.8rem;font-weight:500;border:1px solid var(--border);
background:var(--surface);color:var(--text2);cursor:pointer;
transition:all 0.15s ease;-webkit-tap-highlight-color:transparent;
}
.filter-chip.active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
.search-wrap{padding:8px 20px 4px}
.search-input{
width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);
background:var(--surface);color:var(--text);font-size:0.9rem;
font-family:var(--sans);outline:none;transition:border-color 0.15s;
}
.search-input:focus{border-color:var(--accent)}
.search-input::placeholder{color:var(--text3)}
.card-list{padding:8px 16px}
.card{
background:var(--surface);border:1px solid var(--border);
border-radius:var(--card-radius);padding:16px;margin-bottom:10px;
transition:border-color 0.15s;position:relative;
}
.card:active{border-color:var(--border-focus)}
.card.pinned{border-left:3px solid var(--accent)}
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.card-glyph{
font-size:1.1rem;width:28px;height:28px;
display:flex;align-items:center;justify-content:center;
background:var(--surface2);border-radius:6px;flex-shrink:0;
}
.card-type{
font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;
color:var(--text3);
}
.card-use{font-size:0.7rem;color:var(--text3);margin-left:auto}
.card-title{
font-size:0.95rem;font-weight:600;color:var(--text);
line-height:1.4;margin-bottom:6px;
}
.card-body{
font-size:0.85rem;color:var(--text2);line-height:1.55;
white-space:pre-wrap;word-break:break-word;
}
.card-body code{
font-family:var(--mono);font-size:0.8rem;
background:var(--surface2);padding:2px 5px;border-radius:4px;
}
.card-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.tag{
font-size:0.7rem;color:var(--accent);background:var(--surface2);
padding:3px 8px;border-radius:6px;
}
/* snippet */
.snippet-block{
background:var(--bg);border:1px solid var(--border);border-radius:8px;
padding:12px;margin-top:8px;position:relative;overflow-x:auto;
}
.snippet-block pre{
font-family:var(--mono);font-size:0.8rem;color:var(--text);
white-space:pre-wrap;word-break:break-word;margin:0;line-height:1.5;
}
.copy-btn{
position:absolute;top:8px;right:8px;
padding:4px 10px;border-radius:6px;border:1px solid var(--border);
background:var(--surface);color:var(--text2);font-size:0.7rem;
cursor:pointer;transition:all 0.15s;
}
.copy-btn:active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
/* checklist */
.checklist{margin-top:8px;list-style:none}
.checklist li{
display:flex;align-items:flex-start;gap:10px;
padding:8px 0;border-bottom:1px solid var(--border);
font-size:0.85rem;color:var(--text2);
}
.checklist li:last-child{border-bottom:none}
.check-box{
width:20px;height:20px;border-radius:5px;border:2px solid var(--border);
flex-shrink:0;cursor:pointer;display:flex;align-items:center;justify-content:center;
transition:all 0.15s;margin-top:1px;
}
.check-box.checked{background:var(--green);border-color:var(--green)}
.check-box.checked::after{content:"✓";color:var(--bg);font-size:0.7rem;font-weight:700}
.check-text.done{text-decoration:line-through;color:var(--text3)}
.progress-bar{
height:4px;background:var(--surface2);border-radius:2px;margin-top:10px;overflow:hidden;
}
.progress-fill{height:100%;background:var(--green);border-radius:2px;transition:width 0.3s}
/* template */
.template-field{margin-top:8px}
.template-field label{
display:block;font-size:0.7rem;font-weight:600;
text-transform:uppercase;letter-spacing:0.05em;
color:var(--text3);margin-bottom:4px;
}
.template-field input{
width:100%;padding:8px 12px;border-radius:8px;
border:1px solid var(--border);background:var(--surface2);
color:var(--text);font-size:0.85rem;font-family:var(--sans);
outline:none;transition:border-color 0.15s;
}
.template-field input:focus{border-color:var(--accent)}
.template-output{margin-top:10px}
.template-copy-btn{
width:100%;padding:10px;border-radius:8px;border:1px solid var(--accent);
background:transparent;color:var(--accent);font-size:0.85rem;font-weight:600;
cursor:pointer;transition:all 0.15s;font-family:var(--sans);
}
.template-copy-btn:active{background:var(--accent);color:var(--bg)}
/* decision */
.decision-grid{margin-top:8px}
.decision-row{
padding:8px 0;border-bottom:1px solid var(--border);
}
.decision-row:last-child{border-bottom:none}
.decision-label{
font-size:0.7rem;font-weight:600;text-transform:uppercase;
letter-spacing:0.05em;color:var(--text3);margin-bottom:2px;
}
.decision-value{font-size:0.85rem;color:var(--text);line-height:1.4}
.decision-rejected{color:var(--red);font-style:italic}
/* link */
.link-target{
display:flex;align-items:center;gap:10px;margin-top:8px;
padding:12px;background:var(--bg);border:1px solid var(--border);
border-radius:8px;text-decoration:none;color:var(--text);
transition:border-color 0.15s;
}
.link-target:active{border-color:var(--accent)}
.link-url{font-size:0.8rem;color:var(--text2);word-break:break-all;flex:1}
.link-arrow{font-size:1.2rem;color:var(--accent);flex-shrink:0}
.empty-state{
text-align:center;padding:60px 20px;color:var(--text3);font-size:0.9rem;
}
@media(min-width:640px){
.card-list{
display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
gap:10px;padding:8px 20px;
}
.card{margin-bottom:0}
}
</style>
</head>
<body>
<div class="deck-header">
<div class="deck-title">{{.Title}}</div>
<div class="deck-meta">{{.Count}} cards{{if .ExportedAt}} · exported {{.ExportedAt}}{{end}}</div>
</div>
<div class="search-wrap">
<input type="text" class="search-input" placeholder="search cards…" id="search">
</div>
<div class="filter-bar" id="filters">
<button class="filter-chip active" data-type="all">All</button>
{{range .Types}}
<button class="filter-chip" data-type="{{.}}">{{.}}</button>
{{end}}
</div>
<div class="card-list" id="cards">
{{range .Cards}}
<div class="card{{if .Pinned}} pinned{{end}}" data-type="{{.CardType}}" data-search="{{.SearchText}}">
<div class="card-top">
<span class="card-glyph">{{.Glyph}}</span>
<span class="card-type">{{.CardType}}</span>
{{if gt .UseCount 0}}<span class="card-use">{{.UseCount}}×</span>{{end}}
</div>
{{if .Title}}<div class="card-title">{{.Title}}</div>{{end}}
{{if eq .CardType "snippet"}}
<div class="snippet-block">
<button class="copy-btn" onclick="copyText(this)">copy</button>
<pre>{{.Body}}</pre>
</div>
{{else if eq .CardType "checklist"}}
<div class="card-body">{{.Description}}</div>
<ul class="checklist" data-card-id="{{.ID}}">
{{range $i, $step := .Steps}}
<li>
<div class="check-box{{if $step.Done}} checked{{end}}" onclick="toggleCheck(this)" data-idx="{{$i}}"></div>
<span class="check-text{{if $step.Done}} done{{end}}">{{$step.Text}}</span>
</li>
{{end}}
</ul>
<div class="progress-bar"><div class="progress-fill" style="width:{{.Progress}}%"></div></div>
{{else if eq .CardType "template"}}
<div class="card-body">{{.Description}}</div>
<form class="template-form" data-template="{{.TemplateBody}}" onsubmit="return false">
{{range .Slots}}
<div class="template-field">
<label>{{.Name}}</label>
<input type="text" data-slot="{{.Name}}" placeholder="{{.Name}}{{if .Default}} ({{.Default}}){{end}}" oninput="updateTemplate(this)">
</div>
{{end}}
<div class="template-output">
<button class="template-copy-btn" onclick="copyTemplate(this)">copy filled template</button>
</div>
</form>
{{else if eq .CardType "decision"}}
<div class="decision-grid">
{{if .Decision.Chose}}<div class="decision-row"><div class="decision-label">Chose</div><div class="decision-value">{{.Decision.Chose}}</div></div>{{end}}
{{if .Decision.Why}}<div class="decision-row"><div class="decision-label">Why</div><div class="decision-value">{{.Decision.Why}}</div></div>{{end}}
{{if .Decision.Rejected}}<div class="decision-row"><div class="decision-label">Rejected</div><div class="decision-value decision-rejected">{{range $i, $r := .Decision.Rejected}}{{if $i}}, {{end}}{{$r}}{{end}}</div></div>{{end}}
</div>
{{if .Body}}<div class="card-body" style="margin-top:8px">{{.Body}}</div>{{end}}
{{else if eq .CardType "link"}}
{{if .Body}}<div class="card-body">{{.Body}}</div>{{end}}
<a class="link-target" href="{{.LinkURL}}" target="_blank" rel="noopener">
<span class="link-url">{{.LinkURL}}</span>
<span class="link-arrow"></span>
</a>
{{else}}
<div class="card-body">{{.Body}}</div>
{{end}}
{{if .Tags}}
<div class="card-tags">
{{range .Tags}}<span class="tag">#{{.}}</span>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="empty-state" id="empty" style="display:none">no matching cards</div>
<script>
(function(){
const cards=document.querySelectorAll('.card');
const chips=document.querySelectorAll('.filter-chip');
const search=document.getElementById('search');
const empty=document.getElementById('empty');
let activeType='all';
function applyFilters(){
const q=search.value.toLowerCase().trim();
let visible=0;
cards.forEach(c=>{
const typeMatch=activeType==='all'||c.dataset.type===activeType;
const searchMatch=!q||c.dataset.search.toLowerCase().includes(q);
c.style.display=(typeMatch&&searchMatch)?'':'none';
if(typeMatch&&searchMatch)visible++;
});
empty.style.display=visible?'none':'block';
}
chips.forEach(chip=>{
chip.addEventListener('click',()=>{
chips.forEach(c=>c.classList.remove('active'));
chip.classList.add('active');
activeType=chip.dataset.type;
applyFilters();
});
});
search.addEventListener('input',applyFilters);
})();
function copyText(btn){
const pre=btn.parentElement.querySelector('pre');
navigator.clipboard.writeText(pre.textContent).then(()=>{
btn.textContent='copied!';
setTimeout(()=>btn.textContent='copy',1500);
});
}
function toggleCheck(box){
box.classList.toggle('checked');
const span=box.nextElementSibling;
span.classList.toggle('done');
const list=box.closest('.checklist');
const boxes=list.querySelectorAll('.check-box');
const done=[...boxes].filter(b=>b.classList.contains('checked')).length;
const bar=list.nextElementSibling.querySelector('.progress-fill');
bar.style.width=Math.round((done/boxes.length)*100)+'%';
}
function updateTemplate(input){
const form=input.closest('.template-form');
const tmpl=form.dataset.template;
let filled=tmpl;
form.querySelectorAll('input[data-slot]').forEach(inp=>{
const re=new RegExp('\\$\\{'+inp.dataset.slot+'\\}','g');
filled=filled.replace(re,inp.value||'${'+inp.dataset.slot+'}');
});
form._filled=filled;
}
function copyTemplate(btn){
const form=btn.closest('.template-form');
const text=form._filled||form.dataset.template;
navigator.clipboard.writeText(text).then(()=>{
btn.textContent='copied!';
setTimeout(()=>btn.textContent='copy filled template',1500);
});
}
</script>
</body>
</html>