| |
| |
| |
| |
| |
|
|
| import { applyMakeupLookFromHex, type MakeupLookHex } from './customize'; |
|
|
| export const AI_LOOK_VIBES = ['natural', 'glam', 'fun'] as const; |
| export type AiLookVibe = (typeof AI_LOOK_VIBES)[number]; |
|
|
| export const AI_LOOK_VIBE_LABELS: Record<AiLookVibe, string> = { |
| natural: 'Natural', |
| glam: 'Glam', |
| fun: 'Fun', |
| }; |
|
|
| const VIBE_PREF_KEY = 'doremi-ai-look-vibe-pref'; |
|
|
| function readVibePreference(): AiLookVibe { |
| try { |
| const v = sessionStorage.getItem(VIBE_PREF_KEY); |
| if (v && (AI_LOOK_VIBES as readonly string[]).includes(v)) return v as AiLookVibe; |
| } catch { |
| |
| } |
| return 'natural'; |
| } |
|
|
| function storeVibePreference(v: AiLookVibe) { |
| try { |
| sessionStorage.setItem(VIBE_PREF_KEY, v); |
| } catch { |
| |
| } |
| } |
|
|
| function readVibeFromDom(): AiLookVibe { |
| const el = document.querySelector<HTMLInputElement>('input[name="aiLookVibe"]:checked'); |
| const v = el?.value; |
| if (v && (AI_LOOK_VIBES as readonly string[]).includes(v)) return v as AiLookVibe; |
| return 'natural'; |
| } |
|
|
| function syncVibeRadiosFromStorage() { |
| const pref = readVibePreference(); |
| document.querySelectorAll<HTMLInputElement>('input[name="aiLookVibe"]').forEach((r) => { |
| r.checked = r.value === pref; |
| }); |
| } |
|
|
| const LOOK_HEX_KEYS = ['lip', 'eye_shadow', 'liner', 'brow', 'blush'] as const; |
|
|
| function isLookHex(x: unknown): x is MakeupLookHex { |
| if (!x || typeof x !== 'object') return false; |
| const o = x as Record<string, unknown>; |
| for (const k of LOOK_HEX_KEYS) { |
| const v = o[k]; |
| if (typeof v !== 'string') return false; |
| const t = v.trim(); |
| if (!/^#[0-9A-Fa-f]{6}$/i.test(t)) return false; |
| } |
| return true; |
| } |
|
|
| function normalizeLookHex(raw: unknown): MakeupLookHex | undefined { |
| if (!isLookHex(raw)) return undefined; |
| const o = raw as Record<string, string>; |
| const out: Record<string, string> = {}; |
| for (const k of LOOK_HEX_KEYS) { |
| const h = (o[k].trim().startsWith('#') ? o[k].trim().slice(1) : o[k].trim()).toUpperCase(); |
| out[k] = `#${h}`; |
| } |
| return out as MakeupLookHex; |
| } |
|
|
| function stripSparkleEmoji(s: string): string { |
| return s.replace(/\u2728/g, '').replace(/\s{2,}/g, ' ').trim(); |
| } |
|
|
| const STORAGE_KEY = 'doremi-ai-color-reads-v1'; |
|
|
| export type AiColorAnalysis = { |
| headline: string; |
| vibe_tags: string[]; |
| undertone_read: string; |
| lip_colors: string[]; |
| eye_colors: string[]; |
| blush_colors: string[]; |
| liner_brow: string; |
| tips: string[]; |
| confidence_note: string; |
| disclaimer: string; |
| |
| look_hex?: MakeupLookHex; |
| }; |
|
|
| export type SavedAiRead = { |
| id: string; |
| savedAt: number; |
| headline: string; |
| |
| lookVibe?: AiLookVibe; |
| analysis: AiColorAnalysis; |
| }; |
|
|
| type InitArgs = { |
| video: HTMLVideoElement; |
| statusEl: HTMLElement | null; |
| |
| onAfterApplyLook?: (lookVibe: AiLookVibe | null) => void; |
| }; |
|
|
| function newId(): string { |
| if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID(); |
| return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; |
| } |
|
|
| function escapeHtml(s: string): string { |
| return s |
| .replace(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"'); |
| } |
|
|
| function readSavedReads(): SavedAiRead[] { |
| try { |
| const raw = localStorage.getItem(STORAGE_KEY); |
| if (!raw) return []; |
| const parsed = JSON.parse(raw) as unknown; |
| if (!Array.isArray(parsed)) return []; |
| return parsed.filter(isSavedRead); |
| } catch { |
| return []; |
| } |
| } |
|
|
| function isSavedRead(x: unknown): x is SavedAiRead { |
| if (!x || typeof x !== 'object') return false; |
| const o = x as Record<string, unknown>; |
| if (typeof o.id !== 'string' || typeof o.savedAt !== 'number' || typeof o.headline !== 'string') return false; |
| const a = o.analysis; |
| if (!a || typeof a !== 'object') return false; |
| const ar = a as Record<string, unknown>; |
| if (typeof ar.headline !== 'string' || !Array.isArray(ar.vibe_tags)) return false; |
| if (ar.look_hex !== undefined && !isLookHex(ar.look_hex)) return false; |
| if (o.lookVibe !== undefined) { |
| if (typeof o.lookVibe !== 'string' || !(AI_LOOK_VIBES as readonly string[]).includes(o.lookVibe)) return false; |
| } |
| return true; |
| } |
|
|
| function writeSavedReads(reads: SavedAiRead[]) { |
| try { |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(reads)); |
| } catch (e) { |
| console.warn('Could not save AI reads', e); |
| } |
| } |
|
|
| function asStringArray(x: unknown): string[] { |
| if (!Array.isArray(x)) return []; |
| return x.filter((v): v is string => typeof v === 'string'); |
| } |
|
|
| function normalizeAnalysis(raw: Record<string, unknown>): AiColorAnalysis { |
| const headlineRaw = typeof raw.headline === 'string' ? raw.headline : 'Your color read'; |
| const look_hex = normalizeLookHex(raw.look_hex); |
| return { |
| headline: stripSparkleEmoji(headlineRaw) || 'Your color read', |
| vibe_tags: asStringArray(raw.vibe_tags), |
| undertone_read: typeof raw.undertone_read === 'string' ? raw.undertone_read : '', |
| lip_colors: asStringArray(raw.lip_colors), |
| eye_colors: asStringArray(raw.eye_colors), |
| blush_colors: asStringArray(raw.blush_colors), |
| liner_brow: typeof raw.liner_brow === 'string' ? raw.liner_brow : '', |
| tips: asStringArray(raw.tips), |
| confidence_note: typeof raw.confidence_note === 'string' ? raw.confidence_note : '', |
| disclaimer: '', |
| ...(look_hex ? { look_hex } : {}), |
| }; |
| } |
|
|
| |
| function captureMirroredJpeg(video: HTMLVideoElement, maxW = 640, quality = 0.85): Promise<Blob | null> { |
| return new Promise((resolve) => { |
| if (video.readyState < 2 || video.videoWidth < 2) { |
| resolve(null); |
| return; |
| } |
| const vw = video.videoWidth; |
| const vh = video.videoHeight; |
| const w = Math.min(maxW, vw); |
| const h = Math.round((vh / vw) * w); |
| const c = document.createElement('canvas'); |
| c.width = w; |
| c.height = h; |
| const ctx = c.getContext('2d'); |
| if (!ctx) { |
| resolve(null); |
| return; |
| } |
| ctx.translate(w, 0); |
| ctx.scale(-1, 1); |
| ctx.drawImage(video, 0, 0, w, h); |
| c.toBlob((b) => resolve(b), 'image/jpeg', quality); |
| }); |
| } |
|
|
| function pillTags(tags: string[]): string { |
| if (!tags.length) return '<span class="ai-color-muted">no tags</span>'; |
| return tags |
| .map((t) => `<span class="ai-color-pill">${escapeHtml(t)}</span>`) |
| .join(''); |
| } |
|
|
| function bulletList(items: string[]): string { |
| if (!items.length) return ''; |
| return `<ul class="ai-color-list">${items.map((i) => `<li>${escapeHtml(i)}</li>`).join('')}</ul>`; |
| } |
|
|
| function renderLookHexSection(look: MakeupLookHex | undefined): string { |
| if (!look) { |
| return `<p class="ai-color-muted">No hex palette in this read. Run a new color read to get loadable colors.</p>`; |
| } |
| const rows: [string, string][] = [ |
| ['Lips', look.lip], |
| ['Eyeshadow', look.eye_shadow], |
| ['Liner', look.liner], |
| ['Brows', look.brow], |
| ['Blush', look.blush], |
| ]; |
| return ` |
| <div class="ai-color-look-section"> |
| <span class="ai-color-card-title">Palette — tap a swatch to copy hex</span> |
| <div class="ai-color-hex-grid"> |
| ${rows |
| .map( |
| ([label, hex]) => ` |
| <div class="ai-color-hex-row"> |
| <span class="ai-color-hex-label">${escapeHtml(label)}</span> |
| <button type="button" class="ai-color-hex-chip" style="--chip:${escapeHtml(hex)}" data-copy-hex="${escapeHtml(hex)}" title="Copy ${escapeHtml(hex)}"> |
| <span class="ai-color-hex-swatch" aria-hidden="true"></span> |
| <code class="ai-color-hex-code">${escapeHtml(hex)}</code> |
| </button> |
| </div>`, |
| ) |
| .join('')} |
| </div> |
| </div>`; |
| } |
|
|
| function renderAnalysisHtml(a: AiColorAnalysis, vibe: AiLookVibe | null | undefined): string { |
| const vibeLine = |
| vibe != null |
| ? `<p class="ai-color-vibe-badge" role="note"><span>${escapeHtml(AI_LOOK_VIBE_LABELS[vibe])}</span> direction</p>` |
| : ''; |
| return ` |
| <div class="ai-color-result"> |
| ${vibeLine} |
| <h3 class="ai-color-headline">${escapeHtml(a.headline)}</h3> |
| <div class="ai-color-pill-row">${pillTags(a.vibe_tags)}</div> |
| ${renderLookHexSection(a.look_hex)} |
| <p class="ai-color-body">${escapeHtml(a.undertone_read)}</p> |
| <div class="ai-color-grid"> |
| <div class="ai-color-card"> |
| <span class="ai-color-card-title">Lips</span> |
| ${bulletList(a.lip_colors)} |
| </div> |
| <div class="ai-color-card"> |
| <span class="ai-color-card-title">Eyes</span> |
| ${bulletList(a.eye_colors)} |
| </div> |
| <div class="ai-color-card"> |
| <span class="ai-color-card-title">Blush</span> |
| ${bulletList(a.blush_colors)} |
| </div> |
| </div> |
| <p class="ai-color-liner">${escapeHtml(a.liner_brow)}</p> |
| <div class="ai-color-tips"> |
| <span class="ai-color-card-title">Hot tips</span> |
| ${bulletList(a.tips)} |
| </div> |
| <p class="ai-color-note">${escapeHtml(a.confidence_note)}</p> |
| <p class="ai-color-disclaimer">${escapeHtml(a.disclaimer)}</p> |
| </div> |
| `; |
| } |
|
|
| export function initAiColorAnalysis(args: InitArgs): void { |
| const stage = document.querySelector<HTMLDivElement>('.stage'); |
| if (!stage) throw new Error('initAiColorAnalysis: .stage missing'); |
|
|
| stage.insertAdjacentHTML( |
| 'beforeend', |
| ` |
| <div class="ai-color-overlay" id="aiColorOverlay" hidden> |
| <div class="ai-color-shell" role="dialog" aria-modal="true" aria-labelledby="aiColorTitle"> |
| <div class="ai-color-shell-inner"> |
| <div class="ai-color-topbar"> |
| <span class="ai-color-title" id="aiColorTitle">AI color read</span> |
| <button type="button" class="ai-color-x" id="aiColorClose" aria-label="Close">×</button> |
| </div> |
| <div id="aiColorMainBlock"> |
| <p class="ai-color-lede">We’ll use one snapshot (mirrored like your preview) to suggest colors and a hex palette you can load onto the try-on.</p> |
| <fieldset class="ai-color-vibe-fieldset" id="aiColorVibeFieldset"> |
| <legend class="ai-color-vibe-legend">Look direction</legend> |
| <div class="ai-color-vibe-grid" role="radiogroup" aria-label="Look direction"> |
| <label class="ai-color-vibe-option"> |
| <input type="radio" name="aiLookVibe" value="natural" checked /> |
| <span class="ai-color-vibe-option-text"> |
| <span class="ai-color-vibe-name">Natural</span> |
| <span class="ai-color-vibe-hint">Soft, everyday, suits you</span> |
| </span> |
| </label> |
| <label class="ai-color-vibe-option"> |
| <input type="radio" name="aiLookVibe" value="glam" /> |
| <span class="ai-color-vibe-option-text"> |
| <span class="ai-color-vibe-name">Glam</span> |
| <span class="ai-color-vibe-hint">Richer, evening-ready</span> |
| </span> |
| </label> |
| <label class="ai-color-vibe-option"> |
| <input type="radio" name="aiLookVibe" value="fun" /> |
| <span class="ai-color-vibe-option-text"> |
| <span class="ai-color-vibe-name">Fun</span> |
| <span class="ai-color-vibe-hint">Playful, expressive color</span> |
| </span> |
| </label> |
| </div> |
| </fieldset> |
| <div class="ai-color-actions-top" id="aiColorActionsTop"> |
| <button type="button" class="ai-color-primary" id="aiColorRunBtn">Run color read</button> |
| <button type="button" class="ai-color-ghost" id="aiColorLibraryBtn">Scrapbook</button> |
| </div> |
| <div class="ai-color-loading" id="aiColorLoading" hidden> |
| <span class="ai-color-spinner" aria-hidden="true"></span> |
| <span>Reading your light + undertone vibes…</span> |
| </div> |
| <div class="ai-color-error" id="aiColorError" hidden></div> |
| <div class="ai-color-body-slot" id="aiColorBody"></div> |
| <div class="ai-color-footer-actions" id="aiColorFooter" hidden> |
| <button type="button" class="ai-color-primary sm" id="aiColorApplyBtn" hidden>Apply to try-on</button> |
| <button type="button" class="ai-color-secondary" id="aiColorSaveBtn">Save to scrapbook</button> |
| </div> |
| </div> |
| <div class="ai-color-library" id="aiColorLibrary" hidden> |
| <div class="ai-color-library-head"> |
| <span>Saved reads</span> |
| <button type="button" class="ai-color-ghost sm" id="aiColorLibraryBack">Back</button> |
| </div> |
| <div class="ai-color-library-list" id="aiColorLibraryList"></div> |
| <p class="ai-color-library-empty" id="aiColorLibraryEmpty">Nothing saved yet — run a read and tap “Save to scrapbook”.</p> |
| </div> |
| </div> |
| </div> |
| </div> |
| `, |
| ); |
|
|
| const overlay = document.querySelector<HTMLElement>('#aiColorOverlay')!; |
| const closeBtn = document.querySelector<HTMLButtonElement>('#aiColorClose')!; |
| const runBtn = document.querySelector<HTMLButtonElement>('#aiColorRunBtn')!; |
| const libraryBtn = document.querySelector<HTMLButtonElement>('#aiColorLibraryBtn')!; |
| const loadingEl = document.querySelector<HTMLElement>('#aiColorLoading')!; |
| const errorEl = document.querySelector<HTMLElement>('#aiColorError')!; |
| const bodyEl = document.querySelector<HTMLDivElement>('#aiColorBody')!; |
| const footerEl = document.querySelector<HTMLElement>('#aiColorFooter')!; |
| const applyBtn = document.querySelector<HTMLButtonElement>('#aiColorApplyBtn')!; |
| const saveBtn = document.querySelector<HTMLButtonElement>('#aiColorSaveBtn')!; |
| const libraryEl = document.querySelector<HTMLElement>('#aiColorLibrary')!; |
| const libraryList = document.querySelector<HTMLDivElement>('#aiColorLibraryList')!; |
| const libraryEmpty = document.querySelector<HTMLElement>('#aiColorLibraryEmpty')!; |
| const libraryBack = document.querySelector<HTMLButtonElement>('#aiColorLibraryBack')!; |
| const mainBlock = document.querySelector<HTMLElement>('#aiColorMainBlock')!; |
|
|
| let latest: AiColorAnalysis | null = null; |
| let lastRunVibe: AiLookVibe | null = null; |
|
|
| document.querySelectorAll<HTMLInputElement>('input[name="aiLookVibe"]').forEach((r) => { |
| r.addEventListener('change', () => { |
| if (r.checked) storeVibePreference(r.value as AiLookVibe); |
| }); |
| }); |
| syncVibeRadiosFromStorage(); |
|
|
| function setVibeFieldsetDisabled(disabled: boolean) { |
| document.querySelector<HTMLFieldSetElement>('#aiColorVibeFieldset')!.disabled = disabled; |
| } |
|
|
| function toast(msg: string) { |
| if (args.statusEl) args.statusEl.textContent = msg; |
| } |
|
|
| function syncApplyButton() { |
| applyBtn.hidden = !latest?.look_hex; |
| } |
|
|
| function setOpen(open: boolean) { |
| overlay.hidden = !open; |
| if (!open) { |
| showLibrary(false); |
| errorEl.hidden = true; |
| loadingEl.hidden = true; |
| } |
| } |
|
|
| function showLibrary(show: boolean) { |
| libraryEl.hidden = !show; |
| mainBlock.hidden = show; |
| if (show) { |
| loadingEl.hidden = true; |
| renderLibrary(); |
| } |
| } |
|
|
| function renderLibrary() { |
| const reads = readSavedReads().sort((a, b) => b.savedAt - a.savedAt); |
| libraryEmpty.hidden = reads.length > 0; |
| libraryList.innerHTML = reads |
| .map( |
| (r) => ` |
| <div class="ai-color-lib-row" data-read-id="${escapeHtml(r.id)}"> |
| <div class="ai-color-lib-meta"> |
| <span class="ai-color-lib-title">${escapeHtml(r.headline)}</span> |
| <span class="ai-color-lib-date">${new Date(r.savedAt).toLocaleString()}</span> |
| ${ |
| r.lookVibe |
| ? `<span class="ai-color-lib-vibe">${escapeHtml(AI_LOOK_VIBE_LABELS[r.lookVibe])} look</span>` |
| : '' |
| } |
| </div> |
| <div class="ai-color-lib-actions"> |
| ${ |
| r.analysis.look_hex |
| ? `<button type="button" class="ai-color-mini accent" data-act="apply" data-id="${escapeHtml(r.id)}">Apply</button>` |
| : '' |
| } |
| <button type="button" class="ai-color-mini" data-act="view" data-id="${escapeHtml(r.id)}">View</button> |
| <button type="button" class="ai-color-mini danger" data-act="del" data-id="${escapeHtml(r.id)}">×</button> |
| </div> |
| </div> |
| `, |
| ) |
| .join(''); |
| } |
|
|
| function openRead(r: SavedAiRead) { |
| latest = r.analysis; |
| showLibrary(false); |
| bodyEl.innerHTML = renderAnalysisHtml(r.analysis, r.lookVibe ?? null); |
| footerEl.hidden = false; |
| syncApplyButton(); |
| errorEl.hidden = true; |
| toast('Opened a saved read.'); |
| } |
|
|
| libraryList.addEventListener('click', (e) => { |
| const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-act][data-id]'); |
| if (!btn) return; |
| const id = btn.dataset.id!; |
| const reads = readSavedReads(); |
| const r = reads.find((x) => x.id === id); |
| if (!r) return; |
| if (btn.dataset.act === 'view') openRead(r); |
| if (btn.dataset.act === 'apply' && r.analysis.look_hex) { |
| applyMakeupLookFromHex(r.analysis.look_hex); |
| args.onAfterApplyLook?.(r.lookVibe ?? null); |
| setOpen(false); |
| toast('Look applied from scrapbook.'); |
| } |
| if (btn.dataset.act === 'del') { |
| writeSavedReads(reads.filter((x) => x.id !== id)); |
| renderLibrary(); |
| toast('Removed from scrapbook.'); |
| } |
| }); |
|
|
| document.getElementById('aiColorReadTrigger')?.addEventListener('click', (e) => { |
| e.preventDefault(); |
| setOpen(true); |
| errorEl.hidden = true; |
| bodyEl.innerHTML = ''; |
| footerEl.hidden = true; |
| latest = null; |
| lastRunVibe = null; |
| showLibrary(false); |
| syncVibeRadiosFromStorage(); |
| }); |
|
|
| closeBtn.addEventListener('click', () => setOpen(false)); |
| overlay.addEventListener('click', (e) => { |
| if (e.target === overlay) setOpen(false); |
| }); |
|
|
| libraryBtn.addEventListener('click', () => { |
| showLibrary(true); |
| }); |
| libraryBack.addEventListener('click', () => showLibrary(false)); |
|
|
| bodyEl.addEventListener('click', (e) => { |
| const chip = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-copy-hex]'); |
| if (!chip?.dataset.copyHex) return; |
| const h = chip.dataset.copyHex; |
| if (navigator.clipboard?.writeText) { |
| void navigator.clipboard.writeText(h).then( |
| () => toast(`Copied ${h}`), |
| () => toast('Could not copy to clipboard.'), |
| ); |
| } else { |
| toast('Clipboard not available in this context.'); |
| } |
| }); |
|
|
| applyBtn.addEventListener('click', () => { |
| if (!latest?.look_hex) return; |
| applyMakeupLookFromHex(latest.look_hex); |
| args.onAfterApplyLook?.(lastRunVibe ?? readVibeFromDom()); |
| setOpen(false); |
| toast('Look applied to try-on.'); |
| }); |
|
|
| document.addEventListener('keydown', (e) => { |
| if (e.key === 'Escape' && !overlay.hidden) setOpen(false); |
| }); |
|
|
| runBtn.addEventListener('click', async () => { |
| errorEl.hidden = true; |
| bodyEl.innerHTML = ''; |
| footerEl.hidden = true; |
| latest = null; |
| lastRunVibe = null; |
|
|
| const blob = await captureMirroredJpeg(args.video); |
| if (!blob) { |
| errorEl.textContent = 'Camera is not ready yet — give it a sec and try again.'; |
| errorEl.hidden = false; |
| return; |
| } |
|
|
| loadingEl.hidden = false; |
| runBtn.disabled = true; |
| setVibeFieldsetDisabled(true); |
| toast('Sending snapshot for color read…'); |
|
|
| try { |
| const fd = new FormData(); |
| fd.append('image', blob, 'snapshot.jpg'); |
| fd.append('look_vibe', readVibeFromDom()); |
| const res = await fetch('/api/analyze-makeup-colors', { method: 'POST', body: fd }); |
| const rawText = await res.text(); |
| let json: Record<string, unknown> = {}; |
| try { |
| json = JSON.parse(rawText) as Record<string, unknown>; |
| } catch { |
| if (!res.ok) throw new Error(rawText.slice(0, 240) || `Request failed (${res.status})`); |
| } |
|
|
| if (!res.ok) { |
| const detail = json.detail; |
| let msg: string; |
| if (typeof detail === 'string') msg = detail; |
| else if (Array.isArray(detail)) |
| msg = detail |
| .map((item) => |
| item && typeof item === 'object' && 'msg' in item ? String((item as { msg: unknown }).msg) : JSON.stringify(item), |
| ) |
| .join(' '); |
| else msg = `Request failed (${res.status})`; |
| throw new Error(msg); |
| } |
|
|
| const analysisRaw = json.analysis as Record<string, unknown> | undefined; |
| if (!analysisRaw || typeof analysisRaw !== 'object') { |
| throw new Error('Unexpected response shape from server.'); |
| } |
|
|
| const analysis = normalizeAnalysis(analysisRaw); |
| latest = analysis; |
| lastRunVibe = readVibeFromDom(); |
| bodyEl.innerHTML = renderAnalysisHtml(analysis, lastRunVibe); |
| footerEl.hidden = false; |
| syncApplyButton(); |
| toast('Color read ready.'); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : String(err); |
| errorEl.textContent = msg; |
| errorEl.hidden = false; |
| toast('Color read failed — see message in panel.'); |
| } finally { |
| loadingEl.hidden = true; |
| runBtn.disabled = false; |
| setVibeFieldsetDisabled(false); |
| } |
| }); |
|
|
| saveBtn.addEventListener('click', () => { |
| if (!latest) return; |
| const entry: SavedAiRead = { |
| id: newId(), |
| savedAt: Date.now(), |
| headline: latest.headline, |
| lookVibe: lastRunVibe ?? readVibeFromDom(), |
| analysis: latest, |
| }; |
| const all = readSavedReads(); |
| all.push(entry); |
| writeSavedReads(all); |
| toast('Saved to scrapbook.'); |
| }); |
| } |
|
|