diff --git a/extension/src/popup/components/types/__tests__/document.save.test.ts b/extension/src/popup/components/types/__tests__/document.save.test.ts
new file mode 100644
index 0000000..543cfe0
--- /dev/null
+++ b/extension/src/popup/components/types/__tests__/document.save.test.ts
@@ -0,0 +1,89 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../../popup', async () => {
+ const navigate = vi.fn();
+ const setState = vi.fn();
+ const sendMessage = vi.fn();
+ const getState = vi.fn(() => ({
+ view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
+ searchQuery: '', activeGroup: null, error: null, loading: false,
+ capturedTabId: null, capturedUrl: '', newType: 'document',
+ }));
+ const escapeHtml = (s: string) => s
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ return { navigate, setState, sendMessage, getState, escapeHtml };
+});
+
+import { renderForm } from '../document';
+import { sendMessage } from '../../../popup';
+import type { Item, AttachmentRef } from '../../../../shared/types';
+
+const PRIMARY: AttachmentRef = {
+ id: 'primaryid',
+ filename: 'passport.pdf',
+ mime_type: 'application/pdf',
+ size: 240000,
+ created: 1700000000,
+};
+
+describe('Document form save', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '
';
+ vi.mocked(sendMessage).mockReset();
+ vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
+ });
+
+ it('rejects save when primary_attachment is missing', async () => {
+ const app = document.getElementById('app')!;
+ renderForm(app, 'add', null);
+ (document.getElementById('f-title') as HTMLInputElement).value = 'Passport';
+ const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
+ document.getElementById('save-btn')!.click();
+ await new Promise((r) => setTimeout(r, 50));
+ expect(alertSpy).not.toHaveBeenCalled();
+ // setState called with the error
+ const { setState } = await import('../../../popup');
+ expect(vi.mocked(setState)).toHaveBeenCalledWith(
+ expect.objectContaining({ error: expect.stringContaining('primary attachment') }),
+ );
+ const calls = vi.mocked(sendMessage).mock.calls;
+ const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
+ expect(addCall).toBeUndefined();
+ });
+
+ it('sends update_item with correct wire shape when valid', async () => {
+ vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
+ const existingDraft: Item = {
+ id: 'docid',
+ type: 'document',
+ core: {
+ type: 'document',
+ filename: PRIMARY.filename,
+ mime_type: PRIMARY.mime_type,
+ primary_attachment: PRIMARY.id,
+ },
+ title: 'Passport',
+ tags: [],
+ favorite: false,
+ created: 0,
+ modified: 0,
+ trashed_at: undefined,
+ sections: [],
+ attachments: [PRIMARY],
+ field_history: {},
+ };
+ const app = document.getElementById('app')!;
+ renderForm(app, 'edit', existingDraft);
+ document.getElementById('save-btn')!.click();
+ await new Promise((r) => setTimeout(r, 50));
+ const calls = vi.mocked(sendMessage).mock.calls;
+ const updateCall = calls.find(([msg]) => (msg as { type: string }).type === 'update_item');
+ expect(updateCall).toBeDefined();
+ const msg = updateCall![0] as { type: 'update_item'; item: any };
+ expect(msg.item.core.primary_attachment).toBe(PRIMARY.id);
+ expect(msg.item.core.type).toBe('document');
+ expect(msg.item.attachments).toEqual([PRIMARY]);
+ expect(msg.item.type).toBe('document');
+ });
+});
diff --git a/extension/src/popup/components/types/document.ts b/extension/src/popup/components/types/document.ts
new file mode 100644
index 0000000..57822dd
--- /dev/null
+++ b/extension/src/popup/components/types/document.ts
@@ -0,0 +1,376 @@
+/// Document item type โ title + REQUIRED primary attachment + optional
+/// notes/tags + optional supplementary attachments.
+/// Primary attachment is referenced by ID from the item's attachments array.
+
+import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
+import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
+import {
+ renderSectionsEditor, wireSectionsEditor,
+} from '../fields';
+import {
+ renderAttachmentsDisclosure,
+ wireAttachmentsDisclosure,
+ teardownAttachmentsDisclosure,
+} from '../attachments-disclosure';
+
+let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
+let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
+let sectionsExpanded = false;
+
+export function teardown(): void {
+ teardownAttachmentsDisclosure();
+ if (activeKeyHandler) {
+ document.removeEventListener('keydown', activeKeyHandler);
+ activeKeyHandler = null;
+ }
+ if (activeFormEscHandler) {
+ document.removeEventListener('keydown', activeFormEscHandler);
+ activeFormEscHandler = null;
+ }
+ sectionsExpanded = false;
+}
+
+const formatBytes = (n: number): string => {
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+};
+
+export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
+ teardown();
+ const state = getState();
+ const isEdit = mode === 'edit';
+ const c = existing?.core.type === 'document' ? existing.core : null;
+
+ const sectionsDraft: Section[] = existing
+ ? JSON.parse(JSON.stringify(existing.sections)) as Section[]
+ : [];
+ let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
+ let primaryId: string = c?.primary_attachment ?? '';
+
+ const renderPrimary = (): string => {
+ const primaryRef = attachmentsDraft.find((a) => a.id === primaryId);
+ if (!primaryRef) {
+ return `
+
+ + attach primary file
+
+ `;
+ }
+ return `
+
+ ๐
+ ${escapeHtml(primaryRef.filename)}
+ ${formatBytes(primaryRef.size)}
+ โ change
+
+ `;
+ };
+
+ app.innerHTML = `
+
+
${isEdit ? 'edit document' : 'new document'}
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''}
+
+
+
+
+
+
+ ${renderPrimary()}
+
+
+
+
+
+
+
+
+
+
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
+ ${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
+
+
+
+
+
+ `;
+
+ const wirePrimaryPicker = (): void => {
+ const picker = document.getElementById('primary-picker');
+ const fileInput = document.getElementById('primary-file-input') as HTMLInputElement | null;
+ picker?.addEventListener('click', () => fileInput?.click());
+ fileInput?.addEventListener('change', async () => {
+ const file = fileInput.files?.[0];
+ if (!file) return;
+ const bytes = await file.arrayBuffer();
+ const resp = await sendMessage({
+ type: 'upload_attachment',
+ itemId: existing?.id ?? '',
+ filename: file.name,
+ mimeType: file.type || 'application/octet-stream',
+ bytes,
+ });
+ if (resp && resp.ok) {
+ const data = resp.data as { attachment: AttachmentRef };
+ attachmentsDraft = [...attachmentsDraft.filter((a) => a.id !== primaryId), data.attachment];
+ primaryId = data.attachment.id;
+ const picker2 = document.getElementById('primary-picker');
+ if (picker2) {
+ picker2.outerHTML = renderPrimary();
+ wirePrimaryPicker();
+ }
+ } else {
+ alert(`upload failed: ${resp?.error ?? 'service worker unavailable'}`);
+ }
+ fileInput.value = '';
+ });
+ };
+ wirePrimaryPicker();
+
+ const rerender = (): void => {
+ const disclosure = app.querySelector('.disclosure');
+ if (!disclosure) return;
+ sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
+ disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
+ wireSectionsEditor(app, sectionsDraft, rerender);
+ };
+ wireSectionsEditor(app, sectionsDraft, rerender);
+
+ const wireDisclosure = (): void => {
+ wireAttachmentsDisclosure(app, {
+ itemId: existing?.id ?? '',
+ attachments: attachmentsDraft,
+ mode: 'edit',
+ onChange: (next) => {
+ attachmentsDraft = next;
+ // If the user removed the primary, clear primaryId so save validation will block
+ if (!attachmentsDraft.find((a) => a.id === primaryId)) primaryId = '';
+ const disc = app.querySelector('.attachments-disclosure');
+ if (disc) {
+ disc.outerHTML = renderAttachmentsDisclosure({
+ itemId: existing?.id ?? '',
+ attachments: attachmentsDraft,
+ mode: 'edit',
+ });
+ wireDisclosure();
+ }
+ },
+ });
+ };
+ wireDisclosure();
+
+ document.getElementById('cancel-btn')?.addEventListener('click', () => {
+ setState({ error: null });
+ navigate(isEdit ? 'detail' : 'list');
+ });
+
+ document.getElementById('save-btn')?.addEventListener('click', async () => {
+ await saveDocument(mode, existing, primaryId, attachmentsDraft, sectionsDraft);
+ });
+
+ const escHandler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setState({ error: null });
+ navigate(isEdit ? 'detail' : 'list');
+ }
+ };
+ activeFormEscHandler = escHandler;
+ document.addEventListener('keydown', escHandler);
+
+ (document.getElementById('f-title') as HTMLInputElement | null)?.focus();
+}
+
+async function saveDocument(
+ mode: 'add' | 'edit',
+ existing: Item | null,
+ primaryId: string,
+ attachmentsDraft: AttachmentRef[],
+ sectionsDraft: Section[],
+): Promise {
+ const state = getState();
+ const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
+ if (!title) { setState({ error: 'Title is required' }); return; }
+
+ const primaryRef = attachmentsDraft.find((a) => a.id === primaryId);
+ if (!primaryId || !primaryRef) {
+ setState({ error: 'primary attachment required' });
+ return;
+ }
+
+ const notesRaw = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim();
+ const tagsRaw = (document.getElementById('f-tags') as HTMLInputElement).value;
+ const tags = tagsRaw.split(',').map((t) => t.trim()).filter(Boolean);
+
+ const core = {
+ type: 'document' as const,
+ filename: primaryRef.filename,
+ mime_type: primaryRef.mime_type,
+ primary_attachment: primaryId,
+ };
+
+ const now = Math.floor(Date.now() / 1000);
+ const item: Item = {
+ id: existing?.id ?? '',
+ title,
+ type: 'document',
+ tags,
+ favorite: existing?.favorite ?? false,
+ group: existing?.group,
+ notes: notesRaw || undefined,
+ created: existing?.created ?? now,
+ modified: now,
+ trashed_at: undefined,
+ core,
+ sections: sectionsDraft,
+ attachments: attachmentsDraft,
+ field_history: existing?.field_history ?? {},
+ };
+
+ setState({ loading: true, error: null });
+ const resp = mode === 'add'
+ ? await sendMessage({ type: 'add_item', item })
+ : await sendMessage({ type: 'update_item', id: state.selectedId!, item });
+ if (resp.ok) {
+ const listResp = await sendMessage({ type: 'list_items' });
+ if (listResp.ok) {
+ const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
+ navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
+ } else navigate('list');
+ } else {
+ setState({ loading: false, error: resp.error });
+ }
+}
+
+export async function renderDetail(app: HTMLElement, item: Item): Promise {
+ teardown();
+ if (item.core.type !== 'document') return;
+ const c = item.core;
+ const primaryRef = item.attachments.find((a) => a.id === c.primary_attachment);
+ if (!primaryRef) {
+ app.innerHTML = `document missing primary attachment
`;
+ return;
+ }
+
+ const isImageMime = primaryRef.mime_type.startsWith('image/');
+ const supplementary = item.attachments.filter((a) => a.id !== c.primary_attachment);
+
+ const tagsHtml = item.tags && item.tags.length > 0
+ ? `${item.tags.map((t) => escapeHtml(t)).join(', ')}
`
+ : '';
+
+ app.innerHTML = `
+
+
${escapeHtml(item.title)}
+
+
+
๐
+
+
${escapeHtml(primaryRef.filename)}
+
${formatBytes(primaryRef.size)} ยท ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}
+
+ โ download
+ ${isImageMime ? '๐ preview' : ''}
+
+
+
+
+ ${item.notes ? `
` : ''}
+ ${tagsHtml}
+
+ ${supplementary.length > 0 ? renderAttachmentsDisclosure({ itemId: item.id, attachments: supplementary, mode: 'view' }) : ''}
+
+
+
+
+
+
+
+ `;
+
+ // Lazy-load thumb for image-mime primaries
+ if (isImageMime) {
+ const thumb = document.querySelector('.document-signature-block__thumb') as HTMLElement;
+ sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id }).then((resp) => {
+ if (!resp || !resp.ok) return;
+ const data = resp.data as { bytes: ArrayBuffer };
+ const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
+ const url = URL.createObjectURL(blob);
+ if (thumb) thumb.innerHTML = `
`;
+ }).catch(() => {});
+ }
+
+ document.getElementById('doc-download')?.addEventListener('click', async () => {
+ const resp = await sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id });
+ if (!resp || !resp.ok) { alert(`download failed: ${resp?.error ?? 'service worker unavailable'}`); return; }
+ const data = resp.data as { bytes: ArrayBuffer };
+ const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = primaryRef.filename;
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
+ });
+
+ document.getElementById('doc-preview')?.addEventListener('click', async () => {
+ const sigblock = document.getElementById('doc-sigblock')!;
+ const existingPreview = sigblock.querySelector('.document-signature-block__preview');
+ if (existingPreview) { existingPreview.remove(); return; }
+ const resp = await sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id });
+ if (!resp || !resp.ok) return;
+ const data = resp.data as { bytes: ArrayBuffer };
+ const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
+ const url = URL.createObjectURL(blob);
+ const preview = document.createElement('div');
+ preview.className = 'document-signature-block__preview';
+ preview.innerHTML = `
`;
+ sigblock.appendChild(preview);
+ });
+
+ // Wire supplementary disclosure
+ if (supplementary.length > 0) {
+ wireAttachmentsDisclosure(app, { itemId: item.id, attachments: supplementary, mode: 'view' });
+ }
+
+ document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
+ document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
+ document.getElementById('trash-btn')?.addEventListener('click', async () => {
+ if (!confirm(`Move "${item.title}" to trash?`)) return;
+ teardown();
+ const resp = await sendMessage({ type: 'delete_item', id: item.id });
+ if (!resp.ok) { setState({ error: resp.error }); return; }
+ const listResp = await sendMessage({ type: 'list_items' });
+ if (listResp.ok) {
+ const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
+ navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
+ } else navigate('list');
+ });
+
+ const handler = async (e: KeyboardEvent) => {
+ const t = e.target;
+ if (t instanceof HTMLElement) {
+ const tag = t.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
+ }
+ switch (e.key) {
+ case 'Escape': teardown(); navigate('list'); break;
+ case 'e': teardown(); navigate('edit'); break;
+ case 'd':
+ e.preventDefault();
+ if (confirm(`Move "${item.title}" to trash?`)) {
+ teardown();
+ const resp = await sendMessage({ type: 'delete_item', id: item.id });
+ if (!resp.ok) { setState({ error: resp.error }); return; }
+ const listResp = await sendMessage({ type: 'list_items' });
+ if (listResp.ok) {
+ const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
+ navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
+ } else navigate('list');
+ }
+ break;
+ }
+ };
+ activeKeyHandler = handler;
+ document.addEventListener('keydown', handler);
+}
diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css
index 54f69ad..189cb9e 100644
--- a/extension/src/popup/styles.css
+++ b/extension/src/popup/styles.css
@@ -892,3 +892,109 @@ textarea {
border-color: #aa812a;
color: #c9d1d9;
}
+
+/* --- Document type signature block + primary picker (ฮณโ) --- */
+
+.document-signature-block {
+ border-left: 3px solid #aa812a;
+ background: #161b22;
+ padding: 10px;
+ margin: 8px 0;
+ border-radius: 0 4px 4px 0;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.document-signature-block__thumb {
+ width: 48px;
+ height: 60px;
+ border-radius: 2px;
+ background: linear-gradient(135deg, #b88a30, #7c5719);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ flex-shrink: 0;
+ overflow: hidden;
+ color: #fff3cf;
+}
+.document-signature-block__thumb img {
+ width: 100%; height: 100%; object-fit: contain;
+}
+.document-signature-block__info { flex: 1; min-width: 0; }
+.document-signature-block__name {
+ font-size: 11px;
+ color: #f1cf6e;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.document-signature-block__meta {
+ font-size: 9px;
+ color: #8b949e;
+ font-family: ui-monospace, monospace;
+ margin-top: 2px;
+}
+.document-signature-block__actions {
+ font-size: 9px;
+ margin-top: 4px;
+}
+.document-signature-block__preview {
+ margin-top: 8px;
+ background: #0d1117;
+ border: 1px solid #30363d;
+ border-radius: 3px;
+ padding: 6px;
+ text-align: center;
+}
+.document-signature-block__preview img {
+ max-width: 100%;
+ max-height: 200px;
+ border-radius: 2px;
+}
+
+/* Document primary picker (form mode) */
+.document-primary-row {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 4px;
+ padding: 6px 8px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ cursor: pointer;
+}
+.document-primary-row--empty {
+ border-style: dashed;
+ border-color: #aa812a;
+ color: #8b949e;
+ justify-content: center;
+ padding: 10px 8px;
+}
+.document-primary-row__thumb {
+ width: 18px; height: 18px;
+ border-radius: 2px;
+ background: linear-gradient(135deg, #b88a30, #7c5719);
+ display: flex; align-items: center; justify-content: center;
+ font-size: 10px; flex-shrink: 0;
+}
+.document-primary-row__name {
+ flex: 1;
+ color: #c9d1d9;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.document-primary-row__meta {
+ color: #6e7681;
+ font-size: 9px;
+ font-family: ui-monospace, monospace;
+}
+.document-primary-row__action {
+ color: #d2ab43;
+ font-size: 10px;
+ padding: 0 6px;
+ cursor: pointer;
+}