feat(viewer): bundle md + slug + conditional owner pill [v0.2.0]

Closes 3 of 6 UX-round1 fixes in one TDD cycle (49→56 tests).

drawer-bundle-md: TaskRecord.md_content fetched in build-time;
drawer reads recordsBySlug instead of fetch — 0 runtime hits to
git.kzntsv.site/raw (private repos 401'd browser fetches without token).
Fallback dl-block when md=null.

ux-show-slug: <code class="card-slug"> rendered under card-title in
both render.ts and board.js renderCard (re-render path).

ux-owner-conditional: distinctOwner(records) pre-scan. Single owner →
pill omitted on cards, header gets <span class="owner-summary">
Owner: X</span>. Multi-owner → pill restored, no header summary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vitya
2026-05-22 18:51:12 +03:00
parent 768167e376
commit fdca0b375a
8 changed files with 253 additions and 54 deletions

View File

@@ -287,3 +287,44 @@ aside.drawer[hidden] {
color: var(--muted);
font-size: 0.7rem;
}
.card-slug {
display: block;
font: 0.7rem/1.3 ui-monospace, SFMono-Regular, Consolas, monospace;
color: var(--muted);
margin-bottom: 0.35rem;
word-break: break-all;
opacity: 0.85;
}
.owner-summary {
font-size: 0.8rem;
color: var(--muted);
border: 1px solid var(--border);
padding: 0.1rem 0.55rem;
border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.drawer-empty {
color: var(--muted);
font-style: italic;
}
.status-block {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.3rem 0.8rem;
font-size: 0.85rem;
margin: 0;
}
.status-block dt {
color: var(--muted);
font-weight: 500;
}
.status-block dd {
margin: 0;
word-break: break-word;
}

View File

@@ -34,6 +34,12 @@ const STATUS_EMOJI = {
const ARCHIVE_DAYS = 14;
const SOLO_OWNER = (() => {
const owners = new Set();
for (const r of records) if (r.project_owner) owners.add(r.project_owner);
return owners.size === 1 ? [...owners][0] : null;
})();
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
@@ -79,7 +85,7 @@ function renderCard(rec, now, mode) {
const primary = mode === 'by-project'
? `<span class="badge status">${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}</span>`
: `<span class="badge project">${escapeHtml(rec.project)}</span>`;
const ownerPill = rec.project_owner
const ownerPill = rec.project_owner && SOLO_OWNER === null
? `<span class="badge owner">${escapeHtml(rec.project_owner)}</span>`
: '';
const dateSpan = rec.last_commit_iso
@@ -87,6 +93,7 @@ function renderCard(rec, now, mode) {
: '';
return `<li class="card" data-slug="${escapeHtml(rec.slug)}" data-raw-url="${escapeHtml(rec.raw_url)}" data-archived="${archived ? 'true' : 'false'}">
<div class="card-title">${escapeHtml(truncate(rec.title, 80))}</div>
<code class="card-slug">${escapeHtml(rec.slug)}</code>
<div class="card-meta">
${primary}
${ownerPill}
@@ -197,21 +204,46 @@ toggleGrouping?.addEventListener('change', () => {
rerender();
});
async function openDrawer(card) {
function openDrawer(card) {
if (!drawer || !drawerContent) return;
const rawUrl = card.dataset.rawUrl;
const slug = card.dataset.slug;
if (!rawUrl) return;
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2><p>loading…</p>`;
const slug = card.dataset.slug || '';
const rec = recordsBySlug.get(slug);
drawer.hidden = false;
try {
const res = await fetch(rawUrl, { credentials: 'omit' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const md = await res.text();
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2>` + marked.parse(md);
} catch (err) {
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2><p>Failed to load: ${escapeHtml(String(err))}</p>`;
if (!rec) {
drawerContent.innerHTML = `<h2>${escapeHtml(slug)}</h2><p>record not found.</p>`;
return;
}
const md = rec.md_content;
if (md === null || md === undefined) {
drawerContent.innerHTML =
`<h2>${escapeHtml(slug)}</h2>` +
`<p class="drawer-empty">No per-task <code>.md</code> file in repo — status block only.</p>` +
renderStatusBlock(rec);
return;
}
drawerContent.innerHTML = `<h2>${escapeHtml(slug)}</h2>` + marked.parse(md);
}
function renderStatusBlock(rec) {
const rows = [
['Status', `${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}`],
['Title', rec.title],
['Where stopped', rec.where_stopped],
['Next action', rec.next_action],
['Blocker', rec.blocker],
['Branch', rec.branch],
];
return (
'<dl class="status-block">' +
rows
.filter(([, v]) => v !== null && v !== undefined && v !== '')
.map(
([k, v]) =>
`<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`,
)
.join('') +
'</dl>'
);
}
document.addEventListener('click', (e) => {