// @requirements REQ-PRODUCT-SERVICE-DETECTION REQ-PRODUCT-DISCOVERY import {mountCatalogSelectionReview} from './catalog-review-ui.js'; import {readSetupCatalogRevisions,normalizeCatalogSelectionRevisions} from './catalog-review.js'; import type {CatalogSelectionRevision} from './catalog-review.js'; // @requirements REQ-PRODUCT-SERVICE-CATALOG // @requirements REQ-PRODUCT-CF-ADMIN /** @requirements REQ-PRODUCT-SETUP Local-only admin questionnaire. Hosts supply access control and optional surrounding workflow. */ import {mountCatalogPicker} from './catalog-ui.js'; import {normalizeSetupCatalogSelections,readSetupCatalogSelections} from './catalog-selection.js'; import type {CatalogServiceSelection} from './catalog-selection.js'; import {createStarterConfig} from './starter.js'; import {mountPurposeEditor} from './setup-purpose-ui.js'; import {createSetupPlan,normalizeSetupAnswers,readSetupAnswers,setupQuestions,setupMarkets,setupFactFields} from './setup.js'; import type {SetupAnswers,SetupPlan,SetupQuestionId,SetupChoice,SetupFactId} from './setup.js'; import {isSafeLink} from './config.js'; export interface SetupUIOptions {config?:unknown;answers?:unknown;documentationBase?:string;catalog?:unknown;loadCatalog?:()=>Promise} export interface MountedSetup {getPlan():SetupPlan|null;destroy():void} export function mountSetup(container:HTMLElement,options:SetupUIOptions={}):MountedSetup { const doc=container.ownerDocument,view=doc.defaultView;if(!view)throw new Error('A document window is required.'); const docs=options.documentationBase??'https://notabenda.org/guide'; if(!isSafeLink(docs)||docs.startsWith('mailto:')||/[?#]/u.test(docs)||docs.endsWith('/'))throw new Error('Use a safe documentation base without a trailing slash.'); let base=options.config,plan:SetupPlan|null=null,destroyed=false,importing=false,invalidImport=false,revision=0,importEpoch=0; let catalogSelections:CatalogServiceSelection[]=options.answers===undefined?[]:readSetupCatalogSelections(options.answers); let catalogSelectionRevisions:CatalogSelectionRevision[]=options.answers===undefined?[]:readSetupCatalogRevisions(options.answers); const initial=options.answers===undefined?normalizeSetupAnswers({schemaVersion:1,markets:[],answers:{}}):readSetupAnswers(options.answers); if(base!==undefined)createSetupPlan(initial,{config:base}); const root=doc.createElement('div');root.className='nb-setup';container.append(root); const node=(tag:K,text?:string)=>{const el=doc.createElement(tag);if(text!==undefined)el.textContent=text;return el;}; const privacy=node('p','Answers stay in this tab. Download your private report to keep them before leaving.');privacy.className='nb-setup-local-note';root.append(privacy); const form=node('form');form.className='nb-setup-form';form.noValidate=true;root.append(form); const status=node('p');status.className='nb-setup-status';status.setAttribute('role','status');root.append(status); const results=node('section');results.setAttribute('aria-label','Setup recommendations');root.append(results); const outline=node('nav');outline.className='nb-setup-outline';outline.setAttribute('aria-label','Setup sections');form.append(outline); const stages:HTMLFieldSetElement[]=[],stepButtons:HTMLButtonElement[]=[];let activeStage=0; const stageNames=['Start','Site facts','Markets','Practices','Storage','Review']; const stageHints=['Import or begin fresh','Your public details','Coverage to assess','Facts and reasons','Necessity and scope','Recommendations']; const makeStep=(index:number,target:string)=>{const button=node('button');button.type='button';button.className='nb-setup-step';button.setAttribute('aria-controls',target);button.setAttribute('aria-label',String(index+1).padStart(2,'0')+' / '+stageNames[index]);const number=node('span',String(index+1).padStart(2,'0'));number.className='nb-setup-step-number';number.setAttribute('aria-hidden','true');const copy=node('span');copy.className='nb-setup-step-copy';copy.append(node('strong',stageNames[index]),node('small',stageHints[index]));button.append(number,copy);button.addEventListener('click',()=>showStage(index,true));outline.append(button);stepButtons.push(button);return button;}; const instance='nb-setup-'+crypto.randomUUID();let sectionNumber=0; const fieldset=(title:string)=>{const el=node('fieldset');el.id=instance+'-section-'+sectionNumber++;el.append(node('legend',title));form.append(el);stages.push(el);makeStep(stages.length-1,el.id);return el;}; let sequence=0;const field=(parent:HTMLElement,title:string,control:HTMLElement)=>{const id=instance+'-'+sequence++;control.id=id;const label=node('label',title);label.htmlFor=id;const wrapper=node('div');wrapper.className='nb-setup-field';wrapper.append(label,control);parent.append(wrapper);return wrapper;}; const imported=fieldset('Import or start a configuration');imported.append(node('p','Optional: import an existing JSON config from this computer. It is not uploaded. Existing behavior is preserved; exported documents return to draft review. Operator-private config fields are excluded from this advisory export.')); const imports=node('div');imports.className='nb-setup-imports';imported.append(imports); const file=node('input');file.type='file';file.accept='.json,application/json';field(imports,'Import a site config',file); const reportFile=node('input');reportFile.type='file';reportFile.accept='.json,application/json';field(imports,'Resume a private report',reportFile); for(const [index,tile] of [...imports.children].entries()){tile.prepend(node('span',index===0?'01 / Configuration':'02 / Saved answers'));tile.append(node('p',index===0?'Bring your inventory and settings. Existing behavior is preserved in the draft.':'Pick up your assessments and private justifications from a previous session.'));} const reset=node('button','Start without an imported config');reset.type='button';reset.className='nb-choice-button';imported.append(reset); const purposeEditor=mountPurposeEditor(imported,{getConfig:()=>{if(importing||invalidImport)throw Error('Finish importing a valid config.');return base??createStarterConfig();},onChange:result=>{if(importing||invalidImport)throw Error('Finish importing first.');clear();base=result.config;selectionList();showInheritedFacts();fillStorage();}}); let picker:{destroy():void}|undefined; const selectedServices=node('section');selectedServices.className='nb-setup-selected-services';imported.append(selectedServices); let activeCatalogReview:{destroy():void}|undefined; function selectionList(){activeCatalogReview?.destroy();activeCatalogReview=undefined;selectedServices.replaceChildren();if(!catalogSelections.length)return;selectedServices.append(node('h3','Catalog selection notes'));for(const record of catalogSelections){const row=node('div');row.append(node('strong',record.service.name),node('p',record.service.provider+' · '+record.service.location));try{normalizeSetupCatalogSelections([record],base??createStarterConfig());}catch{row.append(node('p','Needs renewed review: the draft service or purpose facts no longer match this note. Re-review the changed facts before preparing a new report, or remove only the stale note.'));}const renew=node('button','Re-review '+record.service.name);renew.type='button';renew.className='nb-choice-button';row.append(renew);const reviewContainer=node('div');row.append(reviewContainer); renew.addEventListener('click',()=>{activeCatalogReview?.destroy();activeCatalogReview=mountCatalogSelectionReview(reviewContainer,{documentationBase:docs,previous:record,getConfig:()=>{if(importing||invalidImport)throw Error('Finish importing first.');return base??createStarterConfig();},...(options.catalog===undefined?{}:{catalog:options.catalog}),...(options.loadCatalog?{loadCatalog:options.loadCatalog}:{}),onConfirm:result=>{if(importing||invalidImport||!catalogSelections.includes(record))throw Error('Draft changed.');const history=normalizeCatalogSelectionRevisions([...catalogSelectionRevisions,result.revision]);if(new TextEncoder().encode(JSON.stringify(history)).length>256*1024)throw Error('Export this review history before starting a new report.');clear();base=result.config;catalogSelections=catalogSelections.map(r=>r===record?result.selection:r);catalogSelectionRevisions=history;selectionList();fillStorage();status.textContent='Renewed the private catalog review. Previous and new pins stay in the private report; prepare recommendations to export it.';status.tabIndex=-1;status.focus();}});}); const remove=node('button','Remove catalog selection for '+record.service.name);remove.type='button';remove.className='nb-choice-button';remove.addEventListener('click',()=>{clear();let removed=false;if(base){const next=structuredClone(base) as import('./types.js').SiteConfig;const service=next.services.find(s=>s.id===record.service.id);let matching=false;try{normalizeSetupCatalogSelections([record],next);matching=true;}catch{}if(matching&&service&&Object.keys(service).every(key=>Object.hasOwn(record.service,key))&&!next.storage.some(s=>s.serviceId===service.id)&&!next.consent?.services.some(s=>s.id===service.id)){next.services=next.services.filter(s=>s.id!==service.id);removed=true;}base=next;}catalogSelections=catalogSelections.filter(r=>r!==record);selectionList();fillStorage();status.textContent=removed?'Removed the local service and its private selection note.':'Removed the private selection note. Existing referenced or changed inventory was preserved.';});row.append(remove);selectedServices.append(row);}} selectionList(); function projectDiscoveryGuide(){ const discoveryGuide=node('aside');discoveryGuide.className='nb-setup-discovery';discoveryGuide.append(node('h3','Run project discovery for a fuller inventory'),node('p','The browser cannot see your server dependencies or infrastructure. After installing Notabenda, run this from your project folder to find more service candidates.')); const command=node('pre');command.className='nb-setup-command';command.tabIndex=0;command.setAttribute('aria-label','Shell command for project discovery');const code=node('code');command.append(code); for(const [index,token] of ['npx','notabenda','discover','--project','.','--out-dir','./service-review'].entries()){if(index)code.append(doc.createTextNode(' '));const span=node('span',token);span.className=token.startsWith('--')?'nb-setup-command-flag':index<3?'nb-setup-command-name':'nb-setup-command-value';code.append(span);} const discoveryLink=node('a','Project discovery guide');discoveryLink.href=docs+'/service-catalog#search-a-project-with-npx';discoveryGuide.append(command,node('p','Review the findings before selecting services. A match does not prove production use, and some services may be missed. Keep the report private; it contains project paths.'),discoveryLink);return discoveryGuide; } imported.append(projectDiscoveryGuide()); if(options.catalog!==undefined||options.loadCatalog)picker=mountCatalogPicker(imported,{documentationBase:docs,...(options.catalog===undefined?{}:{catalog:options.catalog}),...(options.loadCatalog?{loadCatalog:options.loadCatalog}:{}),getConfig:()=>{if(importing||invalidImport)throw Error('Finish importing a valid config.');return base??createStarterConfig();},onSelect:result=>{if(importing||invalidImport)throw Error('Finish importing first.');if(catalogSelections.length>=32)throw Error('Selection limit reached.');clear();base=result.config;catalogSelections.push(result.selection);selectionList();showInheritedFacts();fillStorage();}}); const basics=fieldset('Site and operator details');basics.append(node('p','Leave blank to keep imported or starter values. These fields belong in public configuration.')); const factGrid=node('div');factGrid.className='nb-setup-facts';basics.append(factGrid); const inheritedFacts=new Map(); const facts=new Map(),factErrors=new Map(); for(const [id,label] of setupFactFields){const input=node('input');input.type=id==='controllerEmail'?'email':id==='siteUrl'?'url':'text';input.maxLength=id==='siteId'?120:4000;input.value=initial.publicFacts?.[id]??''; if(id==='siteUrl')input.pattern='https://.+';if(id==='siteId')input.pattern='[a-z][a-z0-9\\-]*'; const wrapper=field(factGrid,label,input),error=node('p');error.id=input.id+'-error';error.className='nb-setup-field-error';error.hidden=true;input.setAttribute('aria-describedby',error.id);const inherited=node('p');inherited.className='nb-setup-inherited';inherited.hidden=true;wrapper.append(inherited,error);inheritedFacts.set(id,inherited);factErrors.set(input,error);facts.set(id,input); } function showInheritedFacts(){const source=base as {site?:{name?:string;id?:string;url?:string};controller?:{name?:string;email?:string;address?:string}}|undefined;const values={siteName:source?.site?.name,siteId:source?.site?.id,siteUrl:source?.site?.url,controllerName:source?.controller?.name,controllerEmail:source?.controller?.email,controllerAddress:source?.controller?.address};for(const [id,hint] of inheritedFacts){const value=values[id];hint.hidden=typeof value!=='string';hint.textContent=typeof value==='string'?'Imported value: '+value:'';}} showInheritedFacts(); function validatePublicFacts():HTMLInputElement|undefined {let first:HTMLInputElement|undefined;for(const [id,input] of facts){const invalid=!input.checkValidity(),error=factErrors.get(input)!;error.hidden=!invalid;error.textContent=invalid?(id==='siteUrl'?'Use a complete HTTPS address, such as https://example.com.':id==='controllerEmail'?'Enter an email address, such as privacy@example.com.':'Use lowercase letters, digits and hyphens, beginning with a letter.'):'';if(invalid){input.setAttribute('aria-invalid','true');first??=input;}else input.removeAttribute('aria-invalid');}return first;} const markets=fieldset('Markets to assess');markets.append(node('p','Select audience or operating markets you want to assess. Selection is not a legal applicability decision. This list is incomplete; no regional pack is production-supported.')); const marketGrid=node('div');marketGrid.className='nb-setup-markets';markets.append(marketGrid); const marketInputs=new Map();for(const market of setupMarkets){const label=node('label');label.className='nb-setup-check';const input=node('input');input.type='checkbox';input.checked=initial.markets.includes(market.id);label.append(input,doc.createTextNode(market.label));marketGrid.append(label);marketInputs.set(market.id,input);} const practices=fieldset('Assess your practices.');practices.className='nb-setup-practices';practices.append(node('p','Answer directly on each card. Not sure stays unresolved; supporting facts stay private. Nothing is enabled by an answer.')); const assessmentSummary=node('p');assessmentSummary.className='nb-setup-practice-summary';assessmentSummary.setAttribute('role','status');practices.append(assessmentSummary); type PracticeControl={radios:HTMLInputElement[];reason:HTMLTextAreaElement;notes:HTMLDetailsElement;update():void;choice():SetupChoice}; const controls=new Map(); function updatePracticeSummary(){const all=[...controls.values()],assessed=all.filter(c=>c.choice()!=='unknown').length,notes=all.filter(c=>c.reason.value.trim()).length;assessmentSummary.textContent=`${assessed} yes/no assessment${assessed===1?'':'s'} · ${all.length-assessed} not sure · ${notes} supporting note${notes===1?'':'s'}`;} const groups:[string,SetupQuestionId[]][]=[['Data and tools',['personal-data','optional-storage','embeds','processing-opt-outs','transfers']],['Customers and contracts',['terms-agreement','consumer-payments','consumer-distance-contracts','consumer-continuing-contracts']],['Sensitive contexts',['children','sensitive-data','high-risk-profiling','regulated-sector']],['Evidence and publishing',['receipts','authored-content']]]; for(const [groupTitle,ids] of groups){const group=node('section');group.className='nb-setup-practice-group';group.append(node('h2',groupTitle));const grid=node('div');grid.className='nb-setup-practice-grid';group.append(grid);practices.append(group); for(const id of ids){const q=setupQuestions.find(question=>question.id===id)!;const card=node('section');card.className='nb-setup-question nb-setup-practice-card';card.dataset.question=id; const heading=node('h3',q.title);heading.id=instance+'-question-'+id;const detail=node('p',q.detail);detail.id=heading.id+'-help';card.append(heading,detail); const choices=node('div');choices.className='nb-setup-choices';choices.setAttribute('role','radiogroup');choices.setAttribute('aria-labelledby',heading.id);choices.setAttribute('aria-describedby',detail.id);card.append(choices);const radios:HTMLInputElement[]=[]; for(const [value,label] of [['yes','Yes'],['no','No'],['unknown','Not sure']] as const){const choiceLabel=node('label');choiceLabel.className='nb-setup-choice';const radio=node('input');radio.type='radio';radio.name=instance+'-assessment-'+id;radio.value=value;radio.checked=value===(initial.answers[id]?.choice??'unknown');choiceLabel.append(radio,node('span',label));choices.append(choiceLabel);radios.push(radio);} const notes=node('details');notes.className='nb-setup-practice-notes';const noteSummary=node('summary');noteSummary.setAttribute('aria-label',q.title+' — Supporting facts (private)');noteSummary.append(node('span','Supporting facts'),node('span','Private'));notes.append(noteSummary);card.append(notes); const reason=node('textarea');reason.rows=3;reason.maxLength=4000;reason.value=initial.answers[id]?.justification??'';reason.setAttribute('aria-label',q.title+' — Supporting facts / justification (private)');reason.placeholder='What supports this assessment? Add facts, settings or a reference.';field(notes,'Supporting facts / justification (private)',reason);notes.open=!!reason.value.trim(); const choice=()=>radios.find(r=>r.checked)?.value as SetupChoice??'unknown'; const update=()=>{card.dataset.choice=choice();noteSummary.lastChild!.textContent=reason.value.trim()?'Private · Note added':'Private';updatePracticeSummary();}; controls.set(id,{radios,reason,notes,update,choice});for(const radio of radios)radio.addEventListener('change',update);reason.addEventListener('input',update);update();grid.append(card); } } updatePracticeSummary(); const necessary=fieldset('Necessary storage');const justifications=new Map();const savedJustifications=new Map(Object.entries(initial.storageJustifications??{})); function preserveNotes(){for(const [id,input] of justifications)savedJustifications.set(id,input.value);} function fillStorage(){preserveNotes();necessary.replaceChildren(node('legend','Necessary storage'));justifications.clear();const items=(base as {storage?:{id:string;name:string;classification:string}[]}|undefined)?.storage?.filter(s=>s.classification==='necessary')??[];necessary.append(node('p',items.length?'Record the specific exception, purpose, minimum scope and lifetime. These private notes do not rewrite public necessityReason fields.':'Import an inventory to review its necessary storage items. Inventory classification is a separate reviewed decision. Saved notes for removed items remain in the private report.'));for(const item of items){const input=node('textarea');input.rows=3;input.maxLength=4000;input.value=savedJustifications.get(item.id)??'';field(necessary,`${item.id} · ${item.name}`,input);justifications.set(item.id,input);}} fillStorage(); makeStep(5,instance+'-review'); results.id=instance+'-recommendations'; const reviewIntro=node('section');reviewIntro.append(node('h2','Review and download'),node('p','Prepare a draft from the facts you have recorded. Unknown practices stay visible as review items. You can revisit any section before downloading.'));reviewIntro.id=instance+'-review';reviewIntro.className='nb-setup-review-intro';form.append(reviewIntro); const navigation=node('div');navigation.className='nb-setup-navigation';const previous=node('button','← Previous');previous.type='button';previous.className='nb-choice-button';const next=node('button','Continue →');next.type='button';next.className='nb-choice-button nb-primary';const progress=node('span');navigation.append(previous,progress,next);form.append(navigation); previous.addEventListener('click',()=>showStage(activeStage-1,true));next.addEventListener('click',()=>{const invalid=activeStage===1?validatePublicFacts():undefined;if(invalid){invalid.focus();status.textContent='Check the highlighted public details before continuing.';return;}showStage(activeStage+1,true);}); form.addEventListener('keydown',event=>{if(event.key==='Enter'&&event.target instanceof view!.HTMLInputElement&&event.target.type==='radio'){event.preventDefault();return;}if(event.key==='Enter'&&!event.isComposing&&!event.ctrlKey&&!event.metaKey&&!event.altKey&&event.target instanceof view!.HTMLInputElement&&!['checkbox','file'].includes(event.target.type)&&activeStage<5){event.preventDefault();next.click();}}); function showStage(index:number,moveFocus=false){activeStage=Math.max(0,Math.min(5,index));stages.forEach((stage,i)=>stage.hidden=i!==activeStage);stepButtons.forEach((button,i)=>{if(i===activeStage)button.setAttribute('aria-current','step');else button.removeAttribute('aria-current');});previous.hidden=activeStage===0;next.hidden=activeStage===5;reviewIntro.hidden=activeStage!==5;review.hidden=activeStage!==5;results.hidden=activeStage!==5||!plan;progress.textContent='Step '+(activeStage+1)+' of 6';next.setAttribute('aria-label','Continue to '+stageNames[Math.min(5,activeStage+1)]);previous.setAttribute('aria-label','Back to '+stageNames[Math.max(0,activeStage-1)]);if(moveFocus){const target=activeStage===5?reviewIntro:stages[activeStage]!.querySelector('legend')!;target.tabIndex=-1;target.focus();outline.scrollIntoView?.({block:'start'});}} const review=node('button','Prepare recommendations');review.type='submit';review.className='nb-choice-button nb-primary';form.append(review);showStage(0); let urls:string[]=[];function clear(){revision++;plan=null;results.hidden=true;results.replaceChildren();for(const url of urls)view!.URL.revokeObjectURL(url);urls=[];status.textContent='Draft changed. Prepare recommendations to refresh the exports.';} function answers():SetupAnswers{return {schemaVersion:1,markets:[...marketInputs].filter(([,input])=>input.checked).map(([id])=>id),publicFacts:Object.fromEntries([...facts].map(([id,input])=>[id,input.value])),answers:Object.fromEntries([...controls].map(([id,control])=>[id,{choice:control.choice(),justification:control.reason.value}])),storageJustifications:Object.fromEntries([...savedJustifications,...[...justifications].map(([id,input])=>[id,input.value] as const)])};} reportFile.addEventListener('change',async()=>{clear();const token=++importEpoch,selected=reportFile.files?.[0];importing=true;invalidImport=true;review.disabled=true; try{if(!selected||selected.size>512*1024)throw new Error();const raw:unknown=JSON.parse(await selected.text());const resumed=readSetupAnswers(raw),resumedSelections=readSetupCatalogSelections(raw),resumedRevisions=readSetupCatalogRevisions(raw);if(destroyed||importEpoch!==token)return; for(const [id,control] of facts)control.value=resumed.publicFacts?.[id]??'';for(const [id,control] of marketInputs)control.checked=resumed.markets.includes(id);for(const [id,control] of controls){for(const radio of control.radios)radio.checked=radio.value===(resumed.answers[id]?.choice??'unknown');control.reason.value=resumed.answers[id]?.justification??'';control.notes.open=!!control.reason.value.trim();control.update();} catalogSelections=resumedSelections;catalogSelectionRevisions=resumedRevisions;selectionList();justifications.clear();savedJustifications.clear();for(const [id,value] of Object.entries(resumed.storageJustifications??{}))savedJustifications.set(id,value);fillStorage();invalidImport=false;status.textContent='Private answers resumed locally. Import the matching site config separately; recommendations will be regenerated.'; }catch{if(!destroyed&&importEpoch===token)status.textContent='Report import failed. Use version 1 answers or a private report up to 512 KiB.';} finally{if(!destroyed&&importEpoch===token){importing=false;review.disabled=invalidImport;}} }); form.addEventListener('input',event=>{clear();if(event.target instanceof view!.HTMLInputElement&&event.target.hasAttribute('aria-invalid'))validatePublicFacts();});form.addEventListener('change',event=>{if(event.target!==file&&event.target!==reportFile)clear();}); file.addEventListener('change',async()=>{clear();const token=++importEpoch,selected=file.files?.[0];importing=true;invalidImport=true;review.disabled=true; try{if(!selected||selected.size>512*1024)throw new Error();const input:unknown=JSON.parse(await selected.text());createSetupPlan(answers(),{config:input});if(destroyed||importEpoch!==token)return;base=input;invalidImport=false;importing=false;purposeEditor.refresh({discardDraft:true});selectionList();showInheritedFacts();fillStorage();status.textContent='Config imported locally. Any unsaved purpose edit was closed; existing config facts will be preserved in a new draft.';} catch{if(!destroyed&&importEpoch===token)status.textContent='Import failed. Use valid JSON config up to 512 KiB, or start without an import.';} finally{if(!destroyed&&importEpoch===token){importing=false;review.disabled=invalidImport;}} }); reset.addEventListener('click',()=>{importEpoch++;clear();base=undefined;catalogSelections=[];catalogSelectionRevisions=[];selectionList();showInheritedFacts();file.value='';importing=false;invalidImport=false;review.disabled=false;purposeEditor.refresh({discardDraft:true});fillStorage();status.textContent='Starting from a draft starter config.';}); form.addEventListener('submit',event=>{event.preventDefault();if(destroyed||importing||invalidImport)return;const invalid=validatePublicFacts();if(invalid){showStage(1,true);invalid.focus();status.textContent='Check the highlighted public details before preparing recommendations.';return;}clear(); try{plan=createSetupPlan(answers(),{...(base===undefined?{}:{config:base}),catalogSelections,catalogSelectionRevisions});const current=plan;results.append(node('h2','Configuration review'),node('p','Download the draft configuration and private report generated from your answers. Resolve the listed items before publishing.')); results.append(projectDiscoveryGuide()); const downloads=node('p');downloads.className='nb-setup-downloads';for(const [label,name,value] of [['Download draft config','notabenda.config.json',current.config],['Download private setup report','setup.private.json',current.report]] as const){const link=node('a',label);link.className='nb-choice-button';const url=view!.URL.createObjectURL(new Blob([JSON.stringify(value,null,2)+'\n'],{type:'application/json'}));urls.push(url);link.href=url;link.download=name;downloads.append(link,doc.createTextNode(' '));}results.append(downloads,node('p','Keep setup.private.json outside public assets and add it to your own ignore rules. Review the draft config before replacing any existing file.')); const blockers=current.report.diagnostics.filter(d=>d.severity==='error'); if(blockers.length){const section=node('section');section.className='nb-setup-blockers';section.append(node('h3',blockers.length+' details to fix before publishing'),node('p','You can download this draft to continue working. Resolve these details and review the content before publishing.'));const list=node('ul');section.append(list);const item=(diagnostic:typeof blockers[number])=>{const li=node('li');li.append(node('strong',diagnostic.path),node('p',diagnostic.message));for(const id of diagnostic.requirementIds){const a=node('a',id);a.href=docs+'/requirements#'+id.toLowerCase();li.append(a,doc.createTextNode(' '));}return li;};for(const diagnostic of blockers.slice(0,3))list.append(item(diagnostic));if(blockers.length>3){const more=node('details');more.append(node('summary','Show '+(blockers.length-3)+' more required fixes'));const rest=node('ul');for(const diagnostic of blockers.slice(3))rest.append(item(diagnostic));more.append(rest);section.append(more);}results.append(section);} const reviewItems=node('details');reviewItems.className='nb-setup-review-items';reviewItems.append(node('summary',current.report.reviewItems.length+' items for your review'));results.append(reviewItems);const list=node('ul');for(const item of current.report.reviewItems)list.append(node('li',item));reviewItems.append(list); const recommendations=node('div');recommendations.className='nb-setup-recommendations';results.append(recommendations); for(const recommendation of current.report.recommendations){const card=node('section');card.className='nb-setup-recommendation';card.dataset.status=recommendation.status;card.append(node('p',recommendation.status.replace('-',' ')),node('h3',recommendation.title),node('p',recommendation.reason),node('p','Config / integration: '+recommendation.configPaths.join(', ')));const links=node('p');for(const id of recommendation.requirementIds){const a=node('a',id);a.href=docs+'/requirements#'+id.toLowerCase();links.append(a,doc.createTextNode(' '));}const guide=node('a','Implementation guide');guide.href=docs+'/'+recommendation.guide;links.append(guide);card.append(links);recommendations.append(card);} showStage(5);const reviewHeading=results.querySelector('h2')!;reviewHeading.tabIndex=-1;reviewHeading.focus();results.scrollIntoView?.({block:'start'}); status.textContent=`Prepared a draft with ${current.report.reviewItems.length} review items and ${current.report.diagnostics.filter(d=>d.severity==='error').length} details to fix before publishing. Downloads are ready.`; }catch{plan=null;for(const url of urls)view!.URL.revokeObjectURL(url);urls=[];results.replaceChildren();status.textContent='Could not prepare this draft. Check public facts, answer lengths and that resumed catalog notes have their matching config imported.';} }); return {getPlan:()=>plan?structuredClone(plan):null,destroy(){if(destroyed)return;destroyed=true;revision++;importEpoch++;for(const url of urls)view!.URL.revokeObjectURL(url);urls=[];plan=null;picker?.destroy();activeCatalogReview?.destroy();purposeEditor.destroy();root.remove();}}; }