Back to Body Journal
SOURCE / PINNED RELEASE

Made of little things.

Body Journal

Release
e8049f8da4e2…
Author-recorded commit
61c04bf0e8aa…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6c4554844c7

Archive hash verified: 4369d778da046a8b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import { PhotoControls, photoGallery } from './photo-controls.js';
import { photoEvidence, type CasePhoto } from './photo-record.js';
import { ExampleControls } from './example-controls.js';
import { GroupControls, groupSummary } from './group-controls.js';
import { unlinkConcern, type ConcernGroup } from './concern-groups.js';
import { JawControls } from './jaw-controls.js';
import { mouthLimit } from './jaw-pose.js';
import { layerNames, type AnatomyLayer } from './layers.js';
import './styles.css';
import './napplet-settings.js';
import { runtimeHasDomain } from './domain-availability.js';
import { HandControls } from './hand-controls.js';
import { InspectionControls } from './inspection-controls.js';
import { BodyScene } from './body-scene.js';
import { escape as esc, localDate, makeConcern, profile, poses, poseNames, summary, type Concern, type ConcernType, type Mark, type Mode, type Pose } from './case.js';
import { icon } from './icons.js';
import { AiWorkspace } from './ai-ui.js';
import { DraftWorkspace } from './draft-ui.js';
import { draftString, serializeCase } from './case-record.js';
import { parseSession, sessionString, type SessionRecord } from './session-record.js';
import { type InspectionView } from './inspection-record.js';

const $ = <T extends HTMLElement = HTMLElement>(selector: string): T => { const el = document.querySelector<T>(selector); if (!el) throw new Error(`Missing ${selector}`); return el; };
const concerns: Concern[] = [];
const groups: ConcernGroup[] = [];
const photos: CasePhoto[] = [];
let photoControls: PhotoControls | undefined;
let exampleSource: string | undefined;
let groupControls: GroupControls | undefined;
const emptyProfile = { ...profile };
let selectedId: number | null = null;
let expandedConcernId: number | null = null;
let nextId = 1;
let currentPose: Pose = 'standing';
let currentLayer: AnatomyLayer = 'skin';
let mode: Mode = 'rotate';
let page = 'map';
let scene: BodyScene | undefined;
let handControls: HandControls | undefined;
let inspectionControls: InspectionControls | undefined;
let jawControls: JawControls | undefined;
let createOnStroke = false;
let toastTimer = 0;
const current = () => concerns.find(c => c.id === selectedId);
document.querySelectorAll<HTMLElement>('[data-icon]').forEach(el => el.innerHTML = icon(el.dataset.icon!));
$('#poseOptions').innerHTML = poses.map(p => `<button class="pose-button ${p === 'standing' ? 'selected' : ''}" data-pose="${p}" aria-pressed="${p === 'standing'}">${icon(p)}<span>${poseNames[p]}</span></button>`).join('');

function toast(message: string): void { const el = $('#toast'); el.textContent = message; el.hidden = false; clearTimeout(toastTimer); toastTimer = window.setTimeout(() => el.hidden = true, 4200); }
function setPanel(panel: string): void { $('#mapPage').dataset.panel = panel; document.querySelectorAll<HTMLElement>('.mobile-tabs [data-panel]').forEach(el => el.classList.toggle('active',el.dataset.panel === panel)); }
function setMode(next: Mode): void {
  mode = next; scene?.setMode(next);
  document.querySelectorAll<HTMLButtonElement>('button[data-mode]').forEach(el => { el.classList.toggle('selected',el.dataset.mode === next); el.setAttribute('aria-pressed',String(el.dataset.mode === next)); });
  $('#brushControl').hidden = next !== 'paint';
  $('#sceneHint').textContent = next === 'paint' ? 'Paint a concern · right-click drag to rotate · scroll to zoom' : next === 'pose' ? 'Drag a ball · focus a hand for fingertips · right-click drag to rotate' : 'Drag to rotate · scroll or pinch to zoom';
}
function setLayer(layer:AnatomyLayer):void {
  currentLayer=layer;scene?.setLayer(layer);
  $<HTMLSelectElement>('#anatomyLayer').value=layer;
  $('#layerCredit').hidden=layer==='skin';
  refreshFocus();handControls?.layerChanged();jawControls?.layerChanged();
}
function refreshFocus():void {
  handControls?.refresh();jawControls?.refresh();
  const view=scene?.getInspection();
  inspectionControls?.refresh();
  $<HTMLButtonElement>('#anatomyToggle').disabled=!scene;
  if(scene)$<HTMLInputElement>('#brushSize').value=String(scene.getBrush()*1000);
  if(view)document.querySelectorAll<HTMLElement>('[data-view]').forEach(el=>{
    const yaw={front:0,back:Math.PI,left:Math.PI/2,right:-Math.PI/2}[el.dataset.view as 'front'|'back'|'left'|'right'];
    el.classList.toggle('selected',Math.abs(Math.atan2(Math.sin(view.yaw-yaw),Math.cos(view.yaw-yaw)))<.01);
  });
}
function saveInspection(view:InspectionView,persist=true):void {
  const c=current();if(c?.localized && !createOnStroke && c.layer===view.layer)c.inspection=view;
  refreshFocus();if(persist)drafts.change();
}
function syncMarks(): void { scene?.showMarks(concerns, selectConcern); }
function selectConcern(id: number): void {
  selectedId = id; const c = current(); if (!c) return;
  expandedConcernId = id; createOnStroke = false;
  if(c.localized) setLayer(c.layer);
  currentPose = c.pose; scene?.setPose(c.pose,c.joints,c.fingers,c.jawOpening); updatePoseButtons(); renderConcerns(); setPanel('concerns');
  if(!scene?.restoreInspection(c.inspection) && c.inspection?.target)toast('That focus is unavailable for the current illustrated anatomy. Showing the whole body.');
  refreshFocus();handControls?.followView();
  $(`#concern-toggle-${id}`).scrollIntoView({block:'nearest'});
}
function addConcern(location: string, localized = true): Concern {
  const c = makeConcern(nextId++,location,currentPose,localized); if (!localized) c.type = 'Other'; else c.layer=currentLayer;
  c.joints = scene?.getPoseSnapshot() || {}; c.fingers = scene?.getFingerPose(); c.jawOpening = scene?.getJawOpening(); if(localized)c.inspection=scene?.getInspection(); concerns.push(c); selectedId = c.id; expandedConcernId = c.id; renderConcerns(); handControls?.refresh();jawControls?.refresh(); return c;
}
function onMark(location: string, mark: Mark, newStroke: boolean): void {
  let c = current();
  if (!c || !c.localized || c.layer!==currentLayer || (newStroke && createOnStroke)) { c = addConcern(location); createOnStroke = false; }
  if (c.marks.length >= 600) { toast('This concern has enough detail. Undo a stroke or add another concern.'); return; }
  if (!c.marks.length) c.location = location;
  c.marks.push(mark); c.pose = currentPose; c.joints = scene?.getPoseSnapshot() || {}; c.fingers = scene?.getFingerPose(); c.jawOpening = scene?.getJawOpening();
  c.inspection=scene?.getInspection();
  syncMarks(); handControls?.refresh();jawControls?.refresh(); if (newStroke) { expandedConcernId = c.id; renderConcerns(); }
}
try {
  scene = new BodyScene($('#bodyViewport'), { view: saveInspection, mark: onMark, handle: id=>handControls?.selectHandle(id), pose: (joints) => { const c = current(); if (c?.localized && !createOnStroke && c.layer===currentLayer) { c.joints = joints; c.fingers=scene?.getFingerPose(); c.pose = currentPose; } handControls?.refresh(); }, hover: location => { if (mode === 'paint') $('#sceneHint').textContent = location ? `${location} · drag to mark` : 'Paint a concern · right-click drag to rotate · scroll to zoom'; } });
} catch { $('#webglFallback').hidden = false; toast('Use the body-area list to add concerns without 3D.'); }

function options(values: string[], selected: string): string { return values.map(t => `<option ${t === selected ? 'selected' : ''}>${esc(t)}</option>`).join(''); }
function renderConcerns(): void {
  groupControls?.refresh();
  $('#concernCount').textContent = String(concerns.length); $('#mobileCount').textContent = String(concerns.length);
  $('#caseStatus').textContent = concerns.length ? `${concerns.length} concern${concerns.length === 1 ? '' : 's'} in your story` : 'Your story starts with one concern';
  if (!concerns.length) { $('#concernContent').innerHTML = `<div class="empty-concerns"><div class="empty-mark">${icon('brush')}<span>+</span></div><h3>Start with what you feel.</h3><p>Mark a spot or brush over an area on the body. We’ll help you describe the rest.</p><button class="primary-button" id="startMarking">Mark your first concern ${icon('brush')}</button><span class="muted small">You can add more than one.</span></div>`; return; }
  $('#concernContent').innerHTML = `<div class="concern-list">${concerns.map((item,i) => {
    const expanded = item.id === expandedConcernId;
    return `<section class="concern-card" style="--concern:${item.color}"><h3 class="concern-heading"><button id="concern-toggle-${item.id}" class="concern-tab ${item.id === selectedId ? 'active' : ''}" data-concern="${item.id}" aria-expanded="${expanded}" aria-controls="concern-panel-${item.id}"><span class="concern-number">${i+1}</span><span class="concern-title"><strong>${esc(item.location)}</strong><small>${esc(item.type)} · ${item.localized ? `${layerNames[item.layer]} · ${esc(poseNames[item.pose])}` : 'General symptom'}${item.id === selectedId ? ' · Selected' : ''}</small></span><span class="concern-chevron" aria-hidden="true">${icon('chevron')}</span></button></h3><div id="concern-panel-${item.id}" role="region" aria-labelledby="concern-toggle-${item.id}" ${expanded ? '' : 'hidden'}>${expanded ? concernEditor(item) : ''}</div></section>`;
  }).join('')}</div>`;
}
function concernEditor(c: Concern): string {
  return `  <div class="concern-editor" style="--concern:${c.color}"><div class="editor-caption"><span>CONCERN ${concerns.indexOf(c)+1}</span><button id="deleteConcern" aria-label="Delete selected concern">Remove</button></div>
  <label class="field">What are you experiencing?<select id="concernType">${options(['Pain','Skin change','Numbness','Swelling','Other'],c.type)}</select></label>
  <label class="field location-field">Location <input id="location" value="${esc(c.location)}" maxlength="100" /><span class="field-hint">${c.layer!=='skin' ? 'Selected structure in a reference illustration; this does not identify the cause. Adjust the location if needed.' : 'Approximate surface label. Adjust this description if needed.'}</span></label>${c.marks.some(m => !m.surface) ? '<p class="inline-error">This concern contains markings from the earlier mannequin. Its text is preserved; repaint the area on this model. <button class="text-button" id="clearOldMarks">Clear old markings</button></p>' : ''}
  ${c.type === 'Pain' ? `<div class="field severity-field"><label for="severity">How intense is it?<strong><output id="severityValue">${c.severity ?? '—'}</output><span> / 10</span></strong></label><input id="severity" type="range" min="0" max="10" value="${c.severity ?? 4}" /><div class="range-ends"><span>No pain</span><span>Worst imaginable</span></div></div>` : ''}
  <div class="field-row"><label class="field">When did it start?<select id="onset"><option value="">Choose</option>${options(['Today','A few days ago','1–2 weeks ago','Over a month ago','Over 3 months ago','Not sure'],c.onset)}</select></label><label class="field">Pattern<select id="pattern">${options(['Not specified','Constant','Comes and goes','Getting worse','Getting better'],c.pattern)}</select></label></div>
  <label class="field">Timing in your own words<textarea id="timingText" maxlength="600" rows="2" placeholder="For decades; always / as long as I remember; since childhood; flares for 2–3 days…">${esc(c.timingText)}</textarea><span class="field-hint">Describe duration, episodes and changes. Keep an approximate answer if you do not know the date.</span></label><label class="field">Is this chronic or long-term for you?<select id="chronic"><option value="">Not specified</option><option value="yes" ${c.chronic === 'yes' ? 'selected' : ''}>Yes · chronic / long-term</option><option value="no" ${c.chronic === 'no' ? 'selected' : ''}>No</option><option value="unsure" ${c.chronic === 'unsure' ? 'selected' : ''}>Not sure</option></select><span class="field-hint">Your description, not a diagnosis. It can still come and go.</span></label><details class="intake-details"><summary>More about the timing & impact</summary><label class="field">Exact start date, if known<input id="onsetDate" type="date" max="${localDate()}" value="${esc(c.onsetDate)}" /><span class="field-hint">The approximate answer and your own timing description are also kept.</span></label><label class="field">How did it begin?<select id="onsetSpeed"><option value="">Not specified</option>${options(['Sudden','Gradual','Not sure'],c.onsetSpeed)}</select></label><label class="field">Effect on daily life<textarea id="impact" maxlength="2000" rows="2" placeholder="Walking, working, sleeping, eating…">${esc(c.impact)}</textarea></label><label class="field">What helps, and what have you tried?<textarea id="relief" maxlength="2000" rows="2" placeholder="Rest, treatments, medicines and their effects…">${esc(c.relief)}</textarea></label></details>
  <label class="field">In your own words<textarea id="description" rows="3" maxlength="2000" placeholder="What does it feel like? What else have you noticed?">${esc(c.description)}</textarea></label>
  <div class="field"><span>When do you notice it?</span><div class="chips">${['At rest','Moving','At night','After exercise'].map(t => `<button data-trigger="${t}" class="chip ${c.triggers.includes(t) ? 'selected' : ''}" aria-pressed="${c.triggers.includes(t)}">${t}</button>`).join('')}</div></div>
  <div class="observation-note">${icon(c.pose)}<span>${c.localized ? `Shown ${poseNames[c.pose].toLowerCase()}.` : 'No body location needed.'} <button id="poseForConcern">${c.localized ? 'Change position' : 'Mark on body'}</button></span></div>
  <button class="attachment-button" id="concernAttachment">${icon('upload')}<span>Add a photo</span><span>+</span></button>${photoGallery(photos, c.id)}<p class="field-hint">Up to four resized photos per case. Review before sharing with AI.</p></div>`;
}
function updatePoseButtons(): void {
  document.querySelectorAll<HTMLElement>('[data-pose]').forEach(el => { el.classList.toggle('selected',el.dataset.pose === currentPose); el.setAttribute('aria-pressed',String(el.dataset.pose === currentPose)); });
  $('#playPose').hidden = !['walking','jogging'].includes(currentPose); $('#playPose').textContent = '▶';
}
function setPose(pose: Pose): void {
  currentPose = pose; scene?.setPose(pose,undefined,scene.getFingerPose(),scene.getJawOpening()); handControls?.refresh();jawControls?.refresh(); updatePoseButtons(); const c = current();
  if (c && !createOnStroke && c.layer===currentLayer) { c.pose = pose; c.joints = scene?.getPoseSnapshot() || {}; c.fingers = scene?.getFingerPose(); c.jawOpening = scene?.getJawOpening(); c.inspection=scene?.getInspection(); renderConcerns(); }
}
function setPage(next: string): void {
  page = next; $('#mapPage').hidden = page !== 'map'; $('#contextPage').hidden = page !== 'context'; $('#reviewPage').hidden = page !== 'review';
  $('.mobile-tabs').classList.toggle('page-hidden',page !== 'map');
  document.querySelectorAll<HTMLElement>('[data-page]').forEach(el => el.classList.toggle('active',el.dataset.page === page));
  if (page === 'context') renderContext(); if (page === 'review') renderReview();
  $('#nextButton').innerHTML = page === 'map' ? 'Continue to context <span>→</span>' : page === 'context' ? 'Review your story <span>→</span>' : 'Back to body map <span>↶</span>';
}
function renderContext(): void {
  $('#contextPage').innerHTML = `<div class="page-intro"><span class="section-caption">THE BIGGER PICTURE</span><h2>A little context.<br>A clearer story.</h2><p>Share what feels relevant. It’s okay to leave something blank or say you’re not sure.</p><div class="context-summary">${icon('target')}<span><strong>${concerns.length} concern${concerns.length === 1 ? '' : 's'} mapped</strong><br>${concerns.map(c => esc(c.location)).join(' · ') || 'Add a concern whenever you’re ready.'}</span></div></div>
  <div class="context-grid"><section class="context-card"><span class="section-caption">HEALTH BACKGROUND</span><h3>What should we know?</h3><label class="field">Existing conditions or previous injuries<textarea id="conditions" maxlength="2000" rows="3" placeholder="For example, a previous knee injury or a long-term condition…">${esc(profile.conditions)}</textarea></label><label class="field">Medications & supplements<textarea id="medications" maxlength="2000" rows="2" placeholder="Include recent changes, if any">${esc(profile.medications)}</textarea></label><label class="field">Allergies & reactions<input id="allergies" maxlength="2000" placeholder="What are you allergic to, and what happens?" value="${esc(profile.allergies)}" /></label><label class="field">Smoking history<select id="smoking"><option value="">Select or skip</option>${options(['Never smoked','Previously smoked','Currently smoke','Prefer not to say'],profile.smoking)}</select></label><label class="field">Could pregnancy be relevant?<select id="pregnancy"><option value="">Select or skip</option>${options(['Not applicable','No','Possible / not sure','Currently pregnant','Recently gave birth','Prefer not to say'],profile.pregnancy)}</select></label><label class="field">Recent illness, injury, travel or changes<textarea id="recentChanges" maxlength="2000" rows="3" placeholder="Anything that changed before this started…">${esc(profile.recentChanges)}</textarea></label></section>
  <section class="context-card attachment-card"><span class="section-caption">SUPPORTING DETAILS</span><h3>Bring the pieces together.</h3><p class="muted">Add photos to the whole case or an observation. You choose which copies to include in AI review.</p><button class="upload-zone" id="contextAttachment">${icon('upload')}<strong>Add a photo</strong><span>JPEG, PNG or WebP · up to four saved copies</span></button>${photoGallery(photos)}<p class="field-hint">Photos are resized and compressed to fit host storage. Wait for “Session saved” before reloading. Document/PDF import is not available yet.</p><div class="quiet-note"><span>◇</span><p>Photos stay with this case until you choose to send them. Host browser storage has no encryption guarantee.</p></div></section></div>`;
}
function renderReview(): void {
  $('#reviewPage').innerHTML = `<div class="page-intro"><span class="section-caption">YOUR STORY, TOGETHER</span><h2>A clearer picture<br>starts here.</h2><p>Check that this sounds like you. Your words and body map belong together.</p><div class="review-badge">${icon('document')}<span>Patient-reported summary<br><strong>${ai.hasReport ? 'AI report available below' : 'No AI analysis has been run'}</strong></span></div><button class="outline-button" data-page="map">← Edit your body map</button></div><div class="review-column"><section class="review-report" data-napplet-select="text"><div class="report-heading"><span class="section-caption">SYMPTOM SUMMARY</span><span class="report-stamp">DRAFT</span></div><h3>Your concerns, in your words.</h3><p class="report-profile">${profile.age ? `${esc(profile.age)} years · ` : ''}${profile.sex ? `${esc(profile.sex)} · ` : ''}${profile.heightConfirmed ? `${profile.height} cm · ` : ''}${profile.weightConfirmed ? `${profile.weight} kg` : ''}<span>Unconfirmed body measurements are omitted from AI requests.</span></p>${concerns.length ? concerns.map((c,i) => `<div class="report-concern"><span class="concern-number" style="--concern:${c.color}">${i+1}</span><div><h4>${esc(c.location)}</h4><p>${esc(summary(c))}</p></div></div>`).join('') : '<div class="review-empty">No concerns yet. Add a body location or a general symptom to start your summary.</div>'}${groupSummary(groups, concerns)}${Object.entries({ 'Health background':profile.conditions,'Medications':profile.medications,'Allergies':profile.allergies,'Smoking history':profile.smoking,'Pregnancy context':profile.pregnancy,'Recent changes':profile.recentChanges }).filter(([,v]) => v).map(([k,v]) => `<div class="report-background"><strong>${k}</strong><p>${esc(v)}</p></div>`).join('')}${photos.length ? `<div class="report-background"><strong>Your photos</strong>${photoGallery(photos)}</div>` : ''}<div class="report-bottom"><span>◷</span>As described in this session. Unanswered questions remain unknown.</div></section>${ai.analysisMarkup()}</div>`;
}
function modal(content: string): void { $('#modalContent').innerHTML = `<button id="closeModal" class="modal-close" aria-label="Close dialog">×</button>${content}`; const d = $<HTMLDialogElement>('#modal'); if (!d.open) d.showModal(); }
function regionModal(general = false): void {
  const regions = general ? ['Fatigue','Nausea','Dizziness','Fever','Sleep changes','Something else'] : ['Head / face','Neck','Left shoulder','Right shoulder','Chest','Upper back','Abdomen','Lower back','Left elbow','Right elbow','Left hand','Right hand','Left hip','Right hip','Penis','Scrotum','Vulva','Perineum / anus','Left knee','Right knee','Left ankle / foot','Right ankle / foot'];
  modal(`<span class="section-caption">${general ? 'GENERAL SYMPTOM' : 'CHOOSE A LOCATION'}</span><h2>${general ? 'Not everything has a spot.' : 'Where do you feel it?'}</h2><p class="muted">${general ? 'Add something you’re feeling without placing it on the body.' : 'Use a name instead of painting. You can refine it in the concern card.'}</p><div class="region-grid">${regions.map(r => `<button class="region-option" data-region="${r}" data-general="${general}">${r}<span>+</span></button>`).join('')}</div>`);
}
const ai = new AiWorkspace({ modal, toast, caseText: () => serializeCase(profile, concerns, groups, photos, exampleSource), photos: () => photos, photoSelection: (id, included) => { const p = photos.find(p => p.id === id); if (p) { p.includeInAi = included; drafts.change(); } }, evidence: () => {
  const body = scene ? scene.capture(concerns) : { images: [], notes: ['3D rendering is unavailable. Only your written body locations are available.'] };
  return { images: [...photoEvidence(photos), ...body.images].slice(0, 50), notes: [...body.notes, ...(body.images.length + photoEvidence(photos).length > 50 ? ['Image limit reached; some body-map views were omitted.'] : [])] };
}, changed: () => { if (page === 'review') renderReview(); }, reportChanged: () => drafts.change() });
function restoreSession(record: SessionRecord): void {
  const { draft, view } = record;
  photoControls?.reset(); photos.splice(0, photos.length, ...(record.photos || [])); exampleSource = record.exampleSource;
  refreshExampleBanner();
  Object.assign(profile, draft.profile); concerns.splice(0, concerns.length, ...draft.concerns);
  groups.splice(0, groups.length, ...draft.groups); groupControls?.restore(view.expandedGroupIds);
  selectedId = view.selectedId; expandedConcernId = view.expandedConcernId === undefined ? selectedId : view.expandedConcernId; nextId = Math.max(0, ...concerns.map(c => c.id)) + 1;
  for (const id of ['age','sex','anatomy','height','weight'] as const) $<HTMLInputElement>(`#${id}`).value = String(profile[id]);
  for (const id of ['heightConfirmed','weightConfirmed'] as const) $<HTMLInputElement>(`#${id}`).checked = profile[id];
  $('#heightValue').textContent = String(profile.height); $('#weightValue').textContent = String(profile.weight);
  scene?.setBody(profile.height, profile.weight, profile.sex, profile.anatomy); ai.restoreReport(record.completed); createOnStroke = view.newObservation || false;
  setLayer(view.layer || current()?.layer || 'skin');
  currentPose = view.pose; scene?.setPose(currentPose, view.joints || current()?.joints, view.fingers || current()?.fingers, view.jawOpening ?? current()?.jawOpening ?? 0); updatePoseButtons();
  scene?.restoreInspection(view.inspection || current()?.inspection);refreshFocus();handControls?.followView();
  inspectionControls?.setOpen(view.anatomyPanelOpen ?? true);
  syncMarks(); renderConcerns(); setPage(view.page); setPanel(view.panel); setMode('rotate');
}
function snapshotSession(nextPhotos = photos): string { return sessionString({
  version: 1, draft: JSON.parse(draftString(profile, concerns, groups)), completed: ai.savedReport, photos: nextPhotos, exampleSource,
  view: { page: page as SessionRecord['view']['page'], panel: $('#mapPage').dataset.panel as SessionRecord['view']['panel'], selectedId, expandedConcernId, expandedGroupIds: groupControls?.expandedIds, pose: currentPose, layer: currentLayer, inspection:scene?.getInspection(), fingers:scene?.getFingerPose(), jawOpening:scene?.getJawOpening(), joints:scene?.getPoseSnapshot(), anatomyPanelOpen:inspectionControls?.open, newObservation:createOnStroke, exampleAttachment: false },
}); }
const drafts = new DraftWorkspace({ modal, snapshot: () => snapshotSession(), restore: restoreSession, reset: () => restoreSession(parseSession(draftString(emptyProfile, []))) });
photoControls = new PhotoControls(photos, concerns, { modal, toast, validate: next => { snapshotSession(next); }, changed: () => { renderConcerns(); if (page === 'context') renderContext(); if (page === 'review') renderReview(); drafts.change(); } });
function refreshExampleBanner(): void {
  const banner = $('#exampleBanner'); banner.hidden = !exampleSource;
  banner.innerHTML = exampleSource ? `<span><strong>Synthetic example</strong> · ${esc(exampleSource)}. These details describe a fictional person.</span><button class="text-button" id="startOwnCase">Start my own case</button>` : '';
  $('.app-shell').classList.toggle('has-example', !!exampleSource);
}
const examples = new ExampleControls({ modal, populated: () => concerns.length > 0 || photos.length > 0 || ai.hasReport || (Object.keys(emptyProfile) as (keyof typeof profile)[]).some(k => profile[k] !== emptyProfile[k]), load: record => { restoreSession(record); drafts.change(); }, reset: () => { restoreSession(parseSession(draftString(emptyProfile, []))); drafts.change(); } });

groupControls = new GroupControls($('#concernGroups'), groups, concerns, { changed: () => drafts.change(), toast, select: id => { selectConcern(id); $(`#concern-toggle-${id}`).focus(); } });

if(scene)inspectionControls = new InspectionControls($('.body-panel'),scene,()=>drafts.change());

handControls = new HandControls($('.body-panel'),scene,(fingers,persist)=>{
  const c=current();if(c?.localized && !createOnStroke && c.layer===currentLayer){c.fingers=fingers;c.joints=scene!.getPoseSnapshot();c.pose=currentPose;}
  if(persist)drafts.change();
},()=>current()?.localized && !createOnStroke && current()?.layer===currentLayer ? `Editing concern ${concerns.indexOf(current()!)+1}: ${current()!.location}` : 'Applies to your next body mark.');

jawControls = new JawControls($('.body-panel'),scene,(opening,persist)=>{
  const c=current();if(c?.localized && !createOnStroke && c.layer===currentLayer){c.jawOpening=opening;c.joints=scene!.getPoseSnapshot();c.pose=currentPose;}
  if(persist){renderConcerns();drafts.change();}
},()=>current()?.localized && !createOnStroke && current()?.layer===currentLayer ? `Editing concern ${concerns.indexOf(current()!)+1}: ${current()!.location}` : 'Applies to your next body mark.');

document.addEventListener('click', e => {
  const button = (e.target as Element).closest<HTMLElement>('button'); if (!button) return;
  const data = button.dataset, id = button.id;
  if (data.page) setPage(data.page);
  if (data.panel) setPanel(data.panel);
  if (data.mode) setMode(data.mode as Mode);
  if (data.view) { scene?.setView(data.view as 'front'|'back'|'left'|'right'); document.querySelectorAll('[data-view]').forEach(el => el.classList.toggle('selected',(el as HTMLElement).dataset.view === data.view)); }
  if (data.pose) setPose(data.pose as Pose);
  if (data.concern) {
    const concernId = Number(data.concern);
    if (expandedConcernId === concernId) { expandedConcernId = null; renderConcerns(); }
    else selectConcern(concernId);
    $(`#concern-toggle-${concernId}`).focus({preventScroll:true});
  }
  if (data.trigger) { const c = current(); if (c) { c.triggers = c.triggers.includes(data.trigger) ? c.triggers.filter(t => t !== data.trigger) : [...c.triggers,data.trigger]; renderConcerns(); } }
  if (data.region) { createOnStroke = false; addConcern(data.region,data.general !== 'true'); $<HTMLDialogElement>('#modal').close(); setPage('map'); setPanel('concerns'); }
  if (id === 'startMarking' || id === 'newConcern') { createOnStroke = true; handControls?.refresh();jawControls?.refresh(); setMode('paint'); setPanel('body'); toast('Paint a new area, or choose a body area from the list.'); }
  if (id === 'chooseRegion') regionModal();
  if (id === 'generalConcern') regionModal(true);
  if (id === 'closeModal') $<HTMLDialogElement>('#modal').close();
  if (id === 'focusPelvis') { setPanel('body');setLayer('skin'); scene?.focusPelvis(); }
  if (id === 'fingerControls') {setMode('pose');setPanel('body');}
  if (id === 'zoomIn') scene?.changeZoom(-0.3);
  if (id === 'zoomOut') scene?.changeZoom(0.3);
  if (id === 'playPose') button.textContent = scene?.toggleAnimation() ? 'Ⅱ' : '▶';
  if (id === 'poseForConcern') { const c = current(); if(c) setLayer(c.layer); if (c && !c.localized) c.localized = true; setPanel('body'); setMode(c?.marks.length ? 'pose' : 'paint'); }
  if (id === 'undoMark') { const c = current(); if (c?.marks.length) { const stroke = c.marks.at(-1)!.stroke; c.marks = c.marks.filter(m => m.stroke !== stroke); syncMarks(); } else toast('No painted strokes to undo.'); }
  if (id === 'deleteConcern') { const index = concerns.findIndex(c => c.id === selectedId); if (index >= 0) { unlinkConcern(groups, concerns[index].id); for (const p of photos) if (p.concernId === concerns[index].id) p.concernId = null; concerns.splice(index,1); } selectedId = concerns.at(-1)?.id || null; expandedConcernId = selectedId; renderConcerns(); syncMarks(); if (selectedId) { selectConcern(selectedId); $(`#concern-toggle-${selectedId}`).focus({preventScroll:true}); } else $('#newConcern').focus(); }
  if (id === 'nextButton') setPage(page === 'map' ? 'context' : page === 'context' ? 'review' : 'map');
  if (id === 'connectionsButton') ai.open();
  if (id === 'draftsButton' || id === 'sessionStatus') drafts.open();
  if (id === 'concernAttachment') void photoControls?.add(selectedId);
  if (id === 'contextAttachment') void photoControls?.add(null);
  if (id === 'clearOldMarks') { const c = current(); if (c) { c.marks = c.marks.filter(m => m.surface); syncMarks(); renderConcerns(); } }
  if (id === 'layerCredit') modal(`<span class="section-caption">${layerNames[currentLayer].toUpperCase()} LAYER</span><h2>Reference anatomy</h2><p class="muted">BodyParts3D, © The Database Center for Life Science, licensed under CC Attribution 4.0 International. These meshes were fitted to this app’s posing rig; their proportions and deformation are approximate and are not your personal anatomy.</p><p class="muted">Named structures are selectable and paintable. The organ layer shows selected major organs with a faint body outline for orientation. Reproductive anatomy follows the illustrated anatomy setting: male source meshes or original female schematics. Coverage is incomplete; lungs and detailed cavities are not included. Female reproductive shapes are schematic, not source atlas scans.</p><p class="muted">${mouthLimit}</p><p class="field-hint" data-napplet-select="text">Source: dbarchive.biosciencedbc.jp/en/bodyparts3d/<br>License: creativecommons.org/licenses/by/4.0/</p>`);
  if (data.page || data.panel || data.pose || data.concern || data.trigger || data.region || ['nextButton','poseForConcern','undoMark','deleteConcern','confirmExampleAttachment','addExampleAttachment','removeAttachment','clearOldMarks'].includes(id)) drafts.change();
});
document.addEventListener('input', e => {
  const input = e.target as HTMLInputElement; const id = input.id;
  if (id === 'height' || id === 'weight') { profile[id] = Number(input.value); $(`#${id}Value`).textContent = input.value; scene?.setBody(profile.height,profile.weight,profile.sex,profile.anatomy); }
  if (['age','sex','anatomy','conditions','medications','allergies','smoking','pregnancy','recentChanges'].includes(id)) (profile as unknown as Record<string,string>)[id] = input.value;
  if (id === 'sex' || id === 'anatomy') {
    scene?.setBody(profile.height,profile.weight,profile.sex,profile.anatomy);
    // Keep an earlier anatomy's annotations, but don't mix a new structure into
    // an observation whose saved surface is now hidden by the illustration choice.
    if (scene && current()?.marks.some(mark => mark.surface && !scene!.canRenderMark(mark))) createOnStroke = true;
    syncMarks();
    refreshFocus();
  }
  if (id === 'heightConfirmed' || id === 'weightConfirmed') profile[id] = input.checked;
  if (id === 'anatomyLayer') {setLayer(input.value as AnatomyLayer);handControls?.layerChanged(true);}
  if (id === 'brushSize') scene?.setBrush(Number(input.value)/1000);
  const c = current(); if (c) {
    if (id === 'severity') { c.severity = Number(input.value); $('#severityValue').textContent = input.value; }
    if (['location','onset','onsetDate','onsetSpeed','pattern','description','impact','relief','timingText','chronic'].includes(id)) (c as unknown as Record<string,string>)[id] = input.value;
    if (id === 'onset' || id === 'timingText') c.onsetRecordedOn = localDate();
    if (id === 'concernType') { c.type = input.value as ConcernType; renderConcerns(); }
  }
  if (!input.closest('#modal, #handControls, #jawControls, #anatomyPanel, #concernGroups')) drafts.change();
});
document.addEventListener('pointerup', e => {
  const input = e.target as HTMLInputElement;
  if (input.id === 'severity') { const c = current(); if (c) { c.severity = Number(input.value); $('#severityValue').textContent = input.value; } }
  if (input.closest('#bodyViewport') || input.id === 'severity') drafts.change();
});
document.addEventListener('pointercancel', e => { if ((e.target as Element).closest('#bodyViewport')) drafts.change(); });
document.addEventListener('lostpointercapture', e => { if ((e.target as Element).closest('#bodyViewport')) drafts.change(); });
document.addEventListener('change', e => { if ((e.target as HTMLElement).id === 'location') { renderConcerns(); syncMarks(); } });
$<HTMLDialogElement>('#modal').addEventListener('click', e => { if (e.target === e.currentTarget) $<HTMLDialogElement>('#modal').close(); });

renderConcerns(); setMode('rotate');refreshFocus();
$('#draftsButton').setAttribute('title', runtimeHasDomain('storage') ? 'Manage your automatically saved session' : 'Session storage is unavailable in this host');
$('#app').inert = true;
void Promise.all([ai.init(), drafts.init()]).finally(() => { $('#app').inert = false; $('#app').dataset.ready = 'true'; });
window.addEventListener('pagehide', () => { drafts.dispose(); photoControls?.dispose(); examples.dispose(); groupControls?.dispose(); handControls?.dispose(); scene?.dispose(); ai.dispose(); clearTimeout(toastTimer); }, { once: true });