/// Field rendering primitives used by every typed-item detail view. /// /// Pure functions that return HTML strings. Caller is responsible for /// mounting the strings into the DOM (typically via `app.innerHTML = ...`). /// After mounting, call `wireFieldHandlers(scope)` once to bind reveal + /// copy click handlers on any rendered rows. import { escapeHtml } from '../../shared/state'; import { colorizePassword } from '../../shared/password-coloring'; import type { Item, Section, Field, FieldValue } from '../../shared/types'; export interface RowOpts { label: string; value: string; copyable?: boolean; href?: string; monospace?: boolean; multiline?: boolean; } /// Plain label/value row. Optional copy button, optional anchor wrap, /// optional monospace styling, optional multiline (renders in a
).
export function renderRow(opts: RowOpts): string {
  const { label, value, copyable, href, monospace, multiline } = opts;
  const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
  let valueHtml: string;
  if (multiline) {
    valueHtml = `
${escapeHtml(value)}
`; } else if (href) { valueHtml = `${escapeHtml(value)}`; } else { valueHtml = escapeHtml(value); } const actions = copyable ? `` : ''; return `
${escapeHtml(label)} ${valueHtml} ${actions}
`; } export interface ConcealedRowOpts { id: string; label: string; value: string; kind?: 'password' | 'concealed'; monospace?: boolean; multiline?: boolean; } /// Concealed row — value rendered hidden until the user clicks "show". /// Plaintext is stored in `data-field-value` on the row element and copied /// to the visible value span on reveal. Copy button always copies plaintext. /// When `kind` is "password", wireFieldHandlers applies colorizePassword on /// reveal so digits/symbols/letters are rendered in distinct colours. export function renderConcealedRow(opts: ConcealedRowOpts): string { const { id, label, value, kind, monospace, multiline } = opts; const placeholder = multiline ? `•••• (${value.length} chars)` : '••••'; const valueClass = `field-row__value${monospace ? ' monospace' : ''}`; const kindAttr = kind ? ` data-field-kind="${escapeHtml(kind)}"` : ''; return `
${escapeHtml(label)} ${escapeHtml(placeholder)}
`; } export interface SignatureBlockOpts { accent?: 'gold' | 'green' | 'amber' | 'red'; children: string; } /// Container for the type-specific signature panel. `children` is HTML /// the caller has already produced (and escaped where needed). export function renderSignatureBlock(opts: SignatureBlockOpts): string { const accent = opts.accent ?? 'gold'; return `
${opts.children}
`; } /// Wire reveal-toggle + copy click handlers within `scope`. Idempotent — /// safe to call multiple times against the same scope. export function wireFieldHandlers(scope: HTMLElement): void { scope.querySelectorAll('[data-field-action="reveal"]').forEach((btn) => { btn.addEventListener('click', () => { const row = btn.closest('[data-field-id]') as HTMLElement | null; if (!row) return; const valueEl = row.querySelector('[data-field-role="value"]') as HTMLElement | null; if (!valueEl) return; const revealed = row.getAttribute('data-revealed') === 'true'; const plaintext = row.getAttribute('data-field-value') ?? ''; const multiline = row.getAttribute('data-field-multiline') === 'true'; if (revealed) { const placeholder = multiline ? `•••• (${plaintext.length} chars)` : '••••'; valueEl.textContent = placeholder; row.setAttribute('data-revealed', 'false'); btn.textContent = 'show'; } else { const isPassword = row.getAttribute('data-field-kind') === 'password'; valueEl.textContent = ''; if (isPassword) { valueEl.appendChild(colorizePassword(plaintext)); } else { valueEl.textContent = plaintext; } row.setAttribute('data-revealed', 'true'); btn.textContent = 'hide'; } }); }); scope.querySelectorAll('[data-field-action="copy"]').forEach((btn) => { btn.addEventListener('click', async () => { const value = btn.getAttribute('data-field-value') ?? ''; try { await navigator.clipboard.writeText(value); } catch { /* swallow — UX is the visual flash below */ } const original = btn.textContent; btn.textContent = 'copied'; setTimeout(() => { if (btn.textContent === 'copied') btn.textContent = original; }, 1500); }); }); } /// Render an Item's sections as read-only field rows. Each section with /// ≥1 field emits a header (if named) or thin separator (if anonymous) /// plus field rows via renderRow / renderConcealedRow. Sections with /// 0 fields are skipped. Fields with unsupported kinds are silently /// skipped (β₂ supports text, password, concealed only). /// /// `idPrefix` uniquifies concealed-row IDs (`${idPrefix}-s{i}-f{j}`) /// so multiple typed-item detail views rendered in sequence don't /// collide on wireFieldHandlers lookups. export function renderSections(item: Item, idPrefix: string): string { let out = ''; item.sections.forEach((section, sIdx) => { const visibleFields = section.fields.filter( (f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed', ); if (visibleFields.length === 0) return; if (section.name) { out += `
${escapeHtml(section.name)}
`; } else { out += `
`; } visibleFields.forEach((field, fIdx) => { if (field.value.kind === 'text') { out += renderRow({ label: field.label, value: field.value.value, copyable: true }); } else if (field.value.kind === 'password' || field.value.kind === 'concealed') { out += renderConcealedRow({ id: `${idPrefix}-s${sIdx}-f${fIdx}`, label: field.label, value: field.value.value, kind: field.value.kind, }); } }); }); return out; } /// 16-char hex FieldId. crypto.getRandomValues for 8 bytes. export function generateFieldId(): string { const bytes = new Uint8Array(8); crypto.getRandomValues(bytes); return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); } function makeField(kind: 'text' | 'password' | 'concealed'): Field { const value: FieldValue = { kind, value: '' }; return { id: generateFieldId(), label: 'new field', kind, value, hidden_by_default: kind !== 'text', }; } /// Render the collapsible custom-sections editor. Returns HTML for the /// disclosure toggle + body. The expanded-state is owned externally /// (via a module-scope flag in the caller); this helper reads it as /// the `expanded` parameter. export function renderSectionsEditor(sections: Section[], expanded: boolean): string { const sectionCount = sections.length; const fieldCount = sections.reduce((sum, s) => sum + s.fields.length, 0); const sectionLabel = sectionCount === 1 ? '1 section' : `${sectionCount} sections`; const fieldLabel = fieldCount === 1 ? '1 field' : `${fieldCount} fields`; const summary = sectionCount === 0 && fieldCount === 0 ? 'no custom fields' : `${sectionLabel}, ${fieldLabel}`; const body = sections.map((section, sIdx) => renderSectionBlock(section, sIdx)).join(''); return `
${body}
`; } function renderSectionBlock(section: Section, sIdx: number): string { const nameDisplay = section.name ? `${escapeHtml(section.name)}` : `(anonymous)`; // Only render supported kinds. Other-kind fields stay in sectionsDraft // untouched so they survive save intact. const editable = section.fields.filter( (f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed', ); const fieldsHtml = editable.map((f) => renderEditorField(f, sIdx, 0)).join(''); const preservedCount = section.fields.length - editable.length; const preservedNote = preservedCount > 0 ? `
${preservedCount} field${preservedCount === 1 ? '' : 's'} of unsupported kind (edit via CLI)
` : ''; return `
${nameDisplay}
${fieldsHtml} ${preservedNote}
`; } function renderEditorField(field: Field, sIdx: number, _fIdx: number): string { const valueStr = (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed') ? field.value.value : ''; const inputType = field.value.kind === 'text' ? 'text' : 'password'; return `
`; } function findField( sectionsDraft: Section[], fieldId: string, ): { section: Section; fieldIdx: number } | null { for (const section of sectionsDraft) { const idx = section.fields.findIndex((f) => f.id === fieldId); if (idx >= 0) return { section, fieldIdx: idx }; } return null; } /// Wire click + input handlers on a rendered sections-editor. Mutations /// happen in place on `sectionsDraft`. `rerender` is called after any /// structural change (add/remove) to regenerate the disclosure body; /// label/value edits do NOT trigger rerender (would steal focus). export function wireSectionsEditor( scope: HTMLElement, sectionsDraft: Section[], rerender: () => void, ): void { const toggle = scope.querySelector('.disclosure__toggle') as HTMLButtonElement | null; toggle?.addEventListener('click', () => { const disclosure = scope.querySelector('.disclosure') as HTMLElement | null; if (!disclosure) return; const expanded = disclosure.getAttribute('data-expanded') === 'true'; disclosure.setAttribute('data-expanded', expanded ? 'false' : 'true'); }); scope.querySelector('.add-section')?.addEventListener('click', () => { sectionsDraft.push({ name: undefined, fields: [] }); rerender(); }); scope.querySelectorAll('[data-rename-section]').forEach((btn) => { btn.addEventListener('click', () => { const sIdx = Number(btn.dataset.renameSection); const current = sectionsDraft[sIdx]?.name ?? ''; const name = window.prompt('Section name (empty for none):', current); if (name === null) return; const trimmed = name.trim(); sectionsDraft[sIdx].name = trimmed || undefined; rerender(); }); }); scope.querySelectorAll('[data-remove-section]').forEach((btn) => { btn.addEventListener('click', () => { const sIdx = Number(btn.dataset.removeSection); const name = sectionsDraft[sIdx]?.name ?? '(anonymous)'; if (!window.confirm(`Remove section "${name}" and all its fields?`)) return; sectionsDraft.splice(sIdx, 1); rerender(); }); }); scope.querySelectorAll('[data-add-field]').forEach((btn) => { btn.addEventListener('click', () => { const sIdx = Number(btn.dataset.sectionIdx); const kind = btn.dataset.addField as 'text' | 'password' | 'concealed'; sectionsDraft[sIdx].fields.push(makeField(kind)); rerender(); }); }); scope.querySelectorAll('[data-delete-field]').forEach((btn) => { btn.addEventListener('click', () => { const fieldId = btn.dataset.deleteField ?? ''; const found = findField(sectionsDraft, fieldId); if (!found) return; found.section.fields = found.section.fields.filter((f) => f.id !== fieldId); rerender(); }); }); scope.querySelectorAll('[data-field-label]').forEach((input) => { input.addEventListener('input', () => { const fieldId = input.dataset.fieldLabel ?? ''; const found = findField(sectionsDraft, fieldId); if (found) { found.section.fields[found.fieldIdx].label = input.value; } }); }); scope.querySelectorAll('[data-field-value-input]').forEach((input) => { input.addEventListener('input', () => { const fieldId = input.dataset.fieldValueInput ?? ''; const found = findField(sectionsDraft, fieldId); if (!found) return; const field = found.section.fields[found.fieldIdx]; // Only mutate supported kinds. Unsupported kinds are never rendered // as editable (filtered by renderSectionBlock), so this path shouldn't // fire for them — but guard defensively. if (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed') { const kind = field.value.kind; field.value = { kind, value: input.value }; } }); }); }