DoView® Planning — Dr Paul Duignan — doviewplanning.org ·
Info for developers ·
Acknowledgments ·
Disclaimer ·
Sources
Some sources may be AI generated, may not work, and click at your own risk. You can add, edit or remove sources below.
Enter the full URL, including https://
Info for developers
DoView boards are a proven approach to planning that can be adapted to any topic, context, or scale, and are shaped by thousands of real-world applications. Behind the simplicity of the This–Then layout is a robust conceptual foundation in outcomes theory, helping ensure that what gets built is not just visually clear but analytically sound.
The main website for DoView Boards and DoView Planning is DoViewPlanning.org. You can find information about the approach and many examples of boards there. You can find our GitHub at DoViewPlanning.org/DoViewBoards. It includes the DoView Board minimum specification, developer integration guidance, configuration reference material, and other resources for building DoView-compatible boards, apps, platforms, and systems.
Developers are encouraged to implement, extend, and build DoView-compatible boards within any app, platform, or system, with acknowledgment. Details are at DoViewPlanning.org/trademarkuse.
The GitHub also includes the example reference JavaScript engine that created this DoView Board, a prompt for building PowerPoint DoViews, our Gartner Cool Vendor award-winning DoView legacy app, examples of DoView Boards, and other resources.
Thinking more broadly, DoView Boards could potentially be used as a GUI “intent engineering” interface for building and controlling agentic systems, and perhaps by agentic systems themselves to plan and coordinate their activity if you want to develop them further in this direction.
DoViews are drawn to a minimum specification so they are fit for purpose. Please follow this minimum specification for how DoView Boards are structured and presented, and innovate as much as you like on top of it. If you put DoView Boards into any app, platform, or system, please let us know if you are able.
The Official DoView® Badge: Standards-Compliant Board Structure is separate from general DoView-compatible use. If you want your app, platform, or system to use the Official DoView® Badge, please contact us at DoViewPlanning.org/contact.
If you want conceptual help implementing or extending DoView Boards, please contact us through DoViewPlanning.org. You can also see projects where we would like to collaborate with developers at DoViewPlanning.org/collaborate.
${CANONICAL_DISCLAIMER_TITLE}
${renderCanonicalDisclaimerHtml(false)}
Prototype DoView Board
This is a prototype DoView Board.
Do not include confidential, regulated, personal, commercially sensitive, or legally privileged information unless the host environment has suitable data-handling, access-control, retention, and review controls.
Saving, copying, printing, or sharing a board can disclose its notes, sources, measures, evaluation questions, strategy information, and other content.
For higher-risk, sensitive, confidential, regulated, public, multi-user, enterprise, or production environments, put in place whatever security, privacy, compliance, hosting, access-control, audit, data-handling, integration, or deployment arrangements are appropriate for the environment.
DoView Boards may be enhanced, adapted, and developed further with appropriate acknowledgment and subject to the DoView trademark-use guidance.
Acknowledgments
DoView Planning methodology, outcomes theory and DoView Boards were developed by Dr Paul Duignan, software engineering of the DoView legacy app by Richard Procter and earlier management by Jennifer Parker. Involvement of Dr Matthew Duignan and others.
Thank you to the thousands of clients and organizations worked with over the years using DoView Boards for planning, performance measurement, evaluation and other outcomes work.
Thank you to Auckland and Massey Universities and in particular Dr Sally Casswell and team, where Dr Duignan worked on projects that influenced his thinking about outcomes and planning. And to his time as a Fulbright Senior Scholar at the Urban Institute in Washington D.C. working on outcomes theory.
A special thank you to the Duignan family and others for protracted discussions about, and testing of, DoViewing over multiple years.
Update board changes to Main AI Chat
Paste this into the main AI chat to update it with the current board changes. The main AI chat can then answer questions about the up-to-date board, and can rebuild the latest version later. You should usually do this just before closing the chat window.
Copy HTML Board
Paste it into a text file and save it with a .html extension in case Save / Download Board does not work reliably.
Save your DoView Board
Because a DoView Board is a single HTML file, saving works somewhat differently from a traditional app. Use one of the options below depending on your browser.
Do not use your browser’s File > Save Page As…
Save to a File You Choose
(if supported by your browser, e.g. Chrome or Edge)
This saves to the .html file you choose. It is essential that you check you are choosing the right folder and using the right filename.
Download a New Copy
This works in supported browsers. It downloads a new saved .html copy, usually to your Downloads folder or to another folder chosen by your browser settings.
After the download finishes, you must open the newly downloaded .html file before you continue editing. Then close this tab so you do not accidentally work in two separate copies of the board.
When you finish, find the most recent saved .html file, for example in Downloads, and move it to the folder where you want to keep and open the board next time.
Paste page from this or another DoView Board
Board info
Put notes here about the board as a whole, including overall assumptions, cross-page links, evidence, caveats, definitions, and URLs.
${escHtml(builderValidationStatusText())}
Page info
Put notes here about this page as a whole, including evidence about links between boxes on this page or between this page and other pages.
Connected boxes
Add a new page
This\u2013Then Page
Causal logic: what leads to what
How Page
Actions being taken to make it happen
Documentation Page
Supporting material kept inside the board
Cancel
What type of How Page do you want to create?
Vertical Link How Page
Use this for How Boxes that sit in the main Level 1 / Level 2 / Level 3 structure. Boxes on this page can connect up or down using Vertical Links.
Already-used numbered levels are disabled. There is only one vertical How Page hierarchy.
Level 1 How Boxes can link up to This\u2013Then Boxes and down to Level 2 How Boxes.
Level 2 or deeper How Boxes can link up to the level above and down to the level below.
Cross-Link How Page
Use this for How Boxes that are not part of the main vertical level structure. Boxes on this page can connect to other How Boxes or This\u2013Then Boxes using Cross-Links where allowed.
No level is assigned to this page.
Cancel
Measure
Evaluation Question
All Measures
All Evaluation Questions
View settings
Linked boxes
Insert clone
Select a source item to clone onto this Documentation page. The clone updates automatically when the source changes.
Tags
User-defined visual labels for supported board elements. Tags are not provenance, permissions, locks, verification, official status, or authorship proof.
Search board
Link
All Links
Create and save a new empty DoView Board
This will create a separate new empty DoView Board as a standalone .html file. Choose how you want to save the new board.
Do not use your browser’s File > Save Page As…
Save to a File You Choose
(if supported by your browser, e.g. Chrome or Edge)
This saves the new empty board to the .html file you choose. It is essential that you check you are choosing the right folder and using the right filename.
Download a New Empty Board
This works in supported browsers. It downloads a separate new empty saved .html board, usually to your Downloads folder or to another folder chosen by your browser settings.
After the download finishes, open the newly downloaded .html file when you want to start editing the new board. Your current board can stay open if you still need it.
Display Text
Soft-wrapped; text saved as typed.
Link Display Text
Soft-wrapped; text saved as typed.
Display Text
Soft-wrapped; text saved as typed.
Top right text
A short plain-text annotation or disclaimer (e.g. Draft, Illustrative only, Confidential, Version for contractor review, Not yet approved). It is shown right-justified in the page-info bar across all pages. Plain text only \u2014 no rich text, no links.
Official DoView® Badge Standards-Compliant Board Structure
Official DoView® Badge Standards-Compliant Board Structure
This badge identifies this official DoView Boards package/source and standards-compliant board structure. It does not mean the user-created content of this board has been reviewed, endorsed, certified, approved, or quality-assured.
DoView Boards are an open methodology, so anyone can build DoView-compatible board apps, platforms, systems, or tools with appropriate acknowledgment. See DoViewPlanning.Org/DoViewBoards.
The “Official DoView® Badge Standards-Compliant Board Structure” and official-status wording may not be used for non-official, forked, rebranded, or modified tools, boards, apps, platforms, systems, or collections unless authorised in writing by Dr Paul Duignan or the DoView® trademark owner.
Board Chat is optional. The board works without it. Sending a Board Chat message may send board content to the custom AI endpoint you configure, using the API key or session credential you enter for this session. If privacy, confidentiality, security, compliance, API-key handling, or external data sharing is a concern, do not use Board Chat.
API keys are sensitive, session-only, and never saved in board state or localStorage. Remembered settings apply only to endpoint/model. If you do not want to configure Board Chat, you can still use the board manually in a normal Claude chat or another AI chat by putting the board content into that chat and asking questions there. In enterprise, public-sector, regulated, sensitive, or multi-user deployments, disable or govern Board Chat unless an approved custom AI endpoint, backend or proxy, logging, audit, retention, data-handling, and organisational approvals are in place.
← Back to chat
`;
}
// ─── GLOBALS (set by init) ───
let TITLE, SLUG, SP, FO_DATA, B={}, DEPS={}, currentPage='overview', openBox=null, entryMode='sofar', editingType=null;
let revealBox=null, revealType=null;
let inspectHome=null, inspectLinkedKeys=[];
let _clickTimer=null;
let sources=[], chatMsgs=[], aiSettings={}, chatSettingsOpen=false, undoStack=[], pendingStructural=[];
// sourcesInitialized tracks whether the Sources list has ever had its default seed applied. The seed (a single { title: "Information on DoView Boards", url: "https://doviewplanning.org/doviewboards" } entry) is applied once on init when sources is empty AND sourcesInitialized is false. After seeding the flag is set true and persisted in saveState, so the user's later edits/removals of the default source are respected and the default is NOT re-added on reload. Defaults to false so older saved boards (which lack the flag) still get the seed once when first opened, and only when they have no sources at that time. The flag is part of the saved state going forward; older boards read it as false by default which is backward-compatible.
let sourcesInitialized=false;
// pendingStateActions queues state-actions when a chat response also includes structural-actions, so a mixed response is presented as ONE OK/Cancel transaction instead of partially applying state-actions before the user has confirmed. State-only responses still apply directly (preserving existing safe behavior). Cancel rolls back via doUndo() against the snapshot taken before any apply.
let pendingStateActions=[];
// the default Source seeded on init when a board has no sources and sourcesInitialized is false. Title is shown in the source title field; URL is shown in the source URL field; the existing Sources popup behavior (Open link, edit, save, remove) handles it from there. (the seed title is updated from "Information on DoView Boards" to "Information about DoView Boards" so the default first source for newly-created boards reads "Information about DoView Boards". The URL is unchanged. The seed-once mechanism (sourcesInitialized flag) is unchanged: existing saved boards' Sources are preserved and not overwritten; the default is never re-seeded onto a board the user has already edited or removed it from.)
const DEFAULT_SOURCE={title:'Information about DoView Boards',url:'https://doviewplanning.org/doviewboards'};
const CANONICAL_DISCLAIMER_TITLE='Using DoView Boards and Disclaimer';
const CANONICAL_DISCLAIMER_TEXT=`Using DoView Boards and Disclaimer
See the disclaimer section below.
The DoView Board prompt and the DoView Board built within this DoView Board prototype app are provided free of charge so that anyone can experiment with DoView Boards and explore for themselves how they can answer the 20 key questions that anyone running or overseeing an organization needs to answer. See [https://doviewplanning.org/doviewboards](https://doviewplanning.org/doviewboards).
Walk-Through
To get a walk-through for using DoView Boards, see [https://doviewplanning.org/walkthrough](https://doviewplanning.org/walkthrough).
Accessing Consulting or Training
We can provide consulting or training on integrating the use of DoView Boards into your existing planning, implementation and reporting workflows in business, government, or nonprofit settings. See [https://doviewplanning.org/offerings](https://doviewplanning.org/offerings).
Adapting the DoView Board Prototype App to your setting
This DoView Board is being used within the DoView Board prototype app. It is a free of charge app that runs inside any browser window and can be used for piloting, testing, proof-of-concept and for use where confidential information is not being included and where there is a low security risk.
The DoView Board app is an open-source developer project under an Apache-2.0 open-source license. See [https://github.com/doviewplanning/official-doview-boards](https://github.com/doviewplanning/official-doview-boards). The wider DoView Planning methodology and DoView® trademarks are governed separately. See [https://doviewplanning.org/trademarkuse](https://doviewplanning.org/trademarkuse).
Because it is an open-source project, any developer can extend, harden, adapt, or include features of the DoView Board app in other planning products or systems to suit specific deployment requirements. For example, adding enhanced stability, scalability, security, authentication, data handling, or integration features needed for production use, sensitive content, or higher-security environments. If you are looking to deploy DoView Boards in a live operational setting, you can work with any developer to tailor the app to your needs. You can also get in touch with us for advice and methodological input into the best way for you to adapt and extend your use of DoView Boards in your particular setting. [https://doviewplanning.org/contact](https://doviewplanning.org/contact).
Disclaimer
DoView Boards are provided for planning and illustrative purposes only. The content of any DoView Board does not constitute professional advice of any kind, including but not limited to legal, financial, medical, strategic, or organizational advice. No warranty is given as to the accuracy, completeness, or fitness for purpose of any content. The usual precautions for AI-generated material need to be taken. Dr Paul Duignan, DoViewPlanning.org, The Ideas Web Ltd, DoView Corporation Ltd and any associated parties accept no liability whatsoever for any loss, damage, or adverse outcome arising directly or indirectly from the use of, or reliance on, any DoView Board or its contents. Use entirely at your own risk.
Please note that this DoView Board is in an interactive HTML file and contains active JavaScript so the board can work. Only open boards from sources you trust. Read-only copies disable editing through the board interface but are not security protection.`;
let boardInfo='', pageInfo={}, docContent={}, createdDate='', boardInstanceId='';
// Additive builder metadata for traceability. Older and directly embedded boards omit it and continue to open normally.
let builderValidation=null;
function builderValidationStatusText(){return builderValidation&&builderValidation.passed===true?'Builder validation: confirmed':'Builder validation: not confirmed';}
function builderValidationForExport(){return builderValidation&&typeof builderValidation==='object'?JSON.parse(JSON.stringify(builderValidation)):undefined;}
// boardUid is the additive stable unique ID for the board itself, separate from the existing boardInstanceId. Generated once on init() if missing, persists through save / load / Save HTML Board / Copy HTML Board / Create Read-Only Copy / localStorage / DOVIEW-STATE snapshots, and does NOT change when the board is renamed, edited, or copied. Read-only copies preserve the same boardUid as their source (least-disruptive choice). Defaults to '' for older boards; _backfillUids assigns one on first load if missing.
let boardUid='';
// board-level Top right text — a short plain-text annotation/disclaimer (e.g. Draft, Illustrative only, Confidential) shown right-justified in the page-info bar across all pages. Defaults to '' for backward compatibility with older saved boards. Plain text only; no rich text; no links. In read-only copies the text is visible but not editable. Persisted with the board via savedState/EMBEDDED_STATE/localStorage and included in DOVIEW-STATE snapshots.
let topRightText='';
// board-level display mode. Defaults to Normal View for older boards; persisted as a small additive saved-state field.
let presentationView=false;
let ttLinks=[], ttLinkNextId=1;
let docEditMode={}, docEditBackup={};
let howLinks=[], howLinkNextId=1;
let runtimeLinkIndex=null, runtimeLinkIndexDirty=true;
let measures=[], measureNextId=1;
let evalQuestions=[], eqNextId=1;
// board-level user-defined visual tags registry. Tags apply through tagIds references; the editable link-tag UI is limited to This–Then links in this build.
let tags=[];
let tagManagerDraft=null;
let registryShowTags={measures:true,eq:true,links:true};
// Overview navigation uses a stop-propagating wrapper so selected-box inspect context is not cleared by the Overview board click handler before page navigation.
// Overview display preference is session-local only to avoid saved-state churn.
let overviewMode='cards';
// transient Overview reorder mode. Session-local only; no saved-state/schema change.
let overviewReorderMode=false;
// initial viewSettings for newly-created boards now match the simple cleared/all-false Page View state (the same state as pressing Clear all, including tags hidden). Existing saved boards continue to load with their saved viewSettings unchanged \u2014 the savedState restore path uses Object.assign over these initial values, so saved settings always win. The View menu Clear all and Default options remain available with their existing meaning. New pages of a given page type continue to follow the currently operating viewSettings for that page type. The separate Final Outcomes View settings behavior is preserved.
let viewSettings={thisThen:{showCounts:false,showTrafficLights:false,showPriorities:false,showHowCounts:false,showMeasures:false,showEvalQuestions:false,showMainText:false,showFullMainText:false,showMainTextCodeStyle:false,showLateralHow:false,showTags:false,showLinkInfoOnHover:false,showLinkInfoCodeStyle:false},how:{showNumbering:false,showTrafficLights:false,showPriorities:false,showWhyCounts:false,showLateralHow:false,showMeasures:false,showEvalQuestions:false,showMainText:false,showFullMainText:false,showMainTextCodeStyle:false,showTags:false},finalOutcomes:{showTrafficLights:false,showPriorities:false,showMeasures:false,showEvalQuestions:false,showMainText:false,showFullMainText:false,showMainTextCodeStyle:false,showTags:false},overview:{showTrafficLights:false,showPriorities:false,showTags:false,showCardColorControls:false}};
// INITIAL_NEW_BOARD_VIEW_SETTINGS is the simple cleared/all-false Page View state used to initialise viewSettings for newly-created boards (and used by resetToStarterBoard). Kept separate from DEFAULT_VIEW_SETTINGS \u2014 DEFAULT_VIEW_SETTINGS is the working defaults that the View menu's Default button still restores. Both constants exist deliberately: Clear all == INITIAL_NEW_BOARD_VIEW_SETTINGS (everything off), Default == DEFAULT_VIEW_SETTINGS (working defaults with Traffic Lights / priorities / etc. on).
const INITIAL_NEW_BOARD_VIEW_SETTINGS={thisThen:{showCounts:false,showTrafficLights:false,showPriorities:false,showHowCounts:false,showMeasures:false,showEvalQuestions:false,showMainText:false,showFullMainText:false,showMainTextCodeStyle:false,showLateralHow:false,showTags:false,showLinkInfoOnHover:false,showLinkInfoCodeStyle:false},how:{showNumbering:false,showTrafficLights:false,showPriorities:false,showWhyCounts:false,showLateralHow:false,showMeasures:false,showEvalQuestions:false,showMainText:false,showFullMainText:false,showMainTextCodeStyle:false,showTags:false},finalOutcomes:{showTrafficLights:false,showPriorities:false,showMeasures:false,showEvalQuestions:false,showMainText:false,showFullMainText:false,showMainTextCodeStyle:false,showTags:false},overview:{showTrafficLights:false,showPriorities:false,showTags:false,showCardColorControls:false}};
// defaults used by Restore Defaults
// Restore Defaults: How-page Vertical Link counts (showWhyCounts) are ON by default; TT-page Vertical Link counts (showHowCounts) remain ON by default; TT-page This\u2013Then Link counts (showCounts) remain OFF by default; both page types have Cross-Links (showLateralHow) OFF by default; Measures, Evaluation Questions, and Display Text under Boxes remain OFF by default. The TT-page Vertical Link toggle reads "Show Vertical Link counts from How Boxes (to check alignment)" and the TT-page Cross-Link toggle reads "Show Cross-Links to How Boxes"; the How-page Vertical Link toggle reads "Show Vertical Link counts to This\u2013Then Boxes or How Boxes (to check alignment)" and the How-page Cross-Link toggle reads "Show Cross-Links to This\u2013Then Boxes or How Boxes". (DEFAULT_VIEW_SETTINGS is unchanged \u2014 it is still the working defaults that the Default button restores. The initial viewSettings for newly-created boards now uses INITIAL_NEW_BOARD_VIEW_SETTINGS (above) instead of DEFAULT_VIEW_SETTINGS, so new boards open with everything cleared.)
const DEFAULT_VIEW_SETTINGS={thisThen:{showCounts:false,showTrafficLights:true,showPriorities:true,showHowCounts:true,showMeasures:false,showEvalQuestions:false,showMainText:false,showFullMainText:false,showMainTextCodeStyle:false,showLateralHow:false,showTags:true,showLinkInfoOnHover:false,showLinkInfoCodeStyle:false},how:{showNumbering:true,showTrafficLights:true,showPriorities:true,showWhyCounts:true,showLateralHow:false,showMeasures:false,showEvalQuestions:false,showMainText:false,showFullMainText:false,showMainTextCodeStyle:false,showTags:true},finalOutcomes:{showTrafficLights:true,showPriorities:true,showMeasures:false,showEvalQuestions:false,showMainText:false,showFullMainText:false,showMainTextCodeStyle:false,showTags:true},overview:{showTrafficLights:false,showPriorities:false,showTags:false,showCardColorControls:false}};
// temporary source-reference box state — transient, not persisted
let sourceRefBox=null;
// lightweight save-state tracking for the bottom-right status area. Tracks whether the in-browser board has unsaved edits since the last explicit HTML save/export/copy, the time of the last successful HTML save/export/copy, and whether a save is currently in progress. These are transient (not persisted) — after reload the indicator starts from a clean baseline. Edits set isDirty=true via saveState(); a successful current-board HTML save/export/copy clears it and stamps lastSavedTime.
let lastSavedTime=null, isDirty=false, isSaving=false, unsavedWarningInstalled=false;
let chosenSaveFileHandle=null;
// page-navigation history stack for the back/forward arrows in the page info bar.
// These are browser-style navigation arrows only; they do not affect page order. Page reordering is a
// separate control ("Change page order"). The history is transient and is not persisted with the board.
let navHistory=[], navFuture=[];
const MAX_NAV_HIST=30;
const MAX_UNDO=20;
const COL_COLORS=[{bg:'#dbeafe',bdr:'#93c5fd',tab:'#3b82f6'},{bg:'#f3e8ff',bdr:'#d8b4fe',tab:'#a855f8'},{bg:'#ccfbf1',bdr:'#5eead4',tab:'#0d9488'},{bg:'#fef3c7',bdr:'#fde68a',tab:'#f59e0b'},{bg:'#fce7f3',bdr:'#f9a8d4',tab:'#db2777'},{bg:'#dcfce7',bdr:'#86efac',tab:'#16a34a'},{bg:'#e0e7ff',bdr:'#a5b4fc',tab:'#6366f1'},{bg:'#f1f5f9',bdr:'#cbd5e1',tab:'#64748b'},{bg:'#ffe4e6',bdr:'#fecdd3',tab:'#e11d48'},{bg:'#cffafe',bdr:'#67e8f9',tab:'#0891b2'}];
const DEFAULT_COLOR={bg:'#f1f5f9',bdr:'#cbd5e1',tab:'#64748b'};
const PAGE_TYPE_COLORS={this_then:'#F5A623',how:'#60a5fa',documentation:'#8b5cf6'};
function sanitizeColorValue(value,fallback){var fb=(fallback===undefined||fallback===null)?'':String(fallback);if(value===null||value===undefined)return fb;var s=String(value).trim();if(!s)return fb;var low=s.toLowerCase();if(/[;"'<>\\]/.test(s)||low.indexOf('url(')>=0||low.indexOf('expression(')>=0||low.indexOf('@import')>=0||low.indexOf('javascript:')>=0||low.indexOf('data:')>=0)return fb;if(/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(s))return s;if(/^[a-zA-Z][a-zA-Z0-9-]{0,31}$/.test(s))return s;if(/^(?:rgb|rgba|hsl|hsla)\(\s*[-+0-9.%,\s/]+\)$/i.test(s))return s;return fb;}
function sanitizeOptionalColorValue(value){return sanitizeColorValue(value,'');}
function sanitizeColorObject(value,fallback){var fb=fallback||DEFAULT_COLOR;var v=(value&&typeof value==='object')?value:{};return {bg:sanitizeColorValue(v.bg,fb.bg),bdr:sanitizeColorValue(v.bdr,fb.bdr),tab:sanitizeColorValue(v.tab,fb.tab)};}
function sanitizeCustomColorsInMemory(){if(typeof SP!=='undefined'&&Array.isArray(SP)){SP.forEach(function(sp){if(!sp)return;if(sp.color)sp.color=sanitizeColorObject(sp.color,DEFAULT_COLOR);if(sp.overviewCard&&typeof sp.overviewCard.boxColor==='string')sp.overviewCard.boxColor=sanitizeOptionalColorValue(sp.overviewCard.boxColor);});}if(typeof B!=='undefined'&&B){Object.keys(B).forEach(function(k){var b=B[k];if(!b)return;b.borderColor=sanitizeOptionalColorValue(b.borderColor);b.boxColor=sanitizeOptionalColorValue(b.boxColor);});}}
function escapeScriptJson(json){return String(json).replace(//g,'\\u003E').replace(/&/g,'\\u0026').replace(/\u2028/g,'\\u2028').replace(/\u2029/g,'\\u2029');}
function standaloneBoardTitleHtmlFor(title,suffix){return escHtml(String(title||'DoView Board')+(suffix||''));}
function standaloneBoardTitleHtml(suffix){return standaloneBoardTitleHtmlFor(TITLE,suffix);}
function defaultNewBoardTitle(){return 'Untitled DoView Board';}
function defaultNewBoardSlug(){return 'untitled_doview_board_'+Date.now().toString(36);}
function cleanNewBoardTitleInput(value){var title=String(value||'').trim().replace(/\.html$/i,'').trim();return title||defaultNewBoardTitle();}
function slugFromBoardName(value){var slug=String(value||'').toLowerCase().replace(/[^a-z0-9]+/g,'_').replace(/^_+|_+$/g,'');return slug;}
function timestampedBoardFilename(slug){var d=new Date();var ds=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');var hr=d.getHours();var mn=String(d.getMinutes()).padStart(2,'0');var ampm=hr>=12?'pm':'am';var hr12=hr%12||12;return String(slug||defaultNewBoardSlug())+'_'+ds+'_'+hr12+'-'+mn+ampm+'.html';}
function spColor(sp){return sanitizeColorObject(sp&&sp.color,DEFAULT_COLOR);}
function _paletteBgKey(bg){return String(bg||'').trim().toLowerCase();}
function _paletteColorForBg(bg){var key=_paletteBgKey(bg);return COL_COLORS.find(function(c){return _paletteBgKey(c.bg)===key;})||null;}
function _noteUsedPaletteBg(used,bg){var c=_paletteColorForBg(bg);if(c)used[_paletteBgKey(c.bg)]=true;}
function _usedPageCardPaletteBgs(){var used={};(SP||[]).forEach(function(sp){if(!sp)return;_noteUsedPaletteBg(used,spColor(sp).bg);if(sp.overviewCard&&sp.overviewCard.boxColor)_noteUsedPaletteBg(used,sanitizeOptionalColorValue(sp.overviewCard.boxColor));});return used;}
function nextAutoPageColor(){var used=_usedPageCardPaletteBgs();var avail=COL_COLORS.find(function(c){return!used[_paletteBgKey(c.bg)];});if(avail)return {bg:avail.bg,bdr:avail.bdr,tab:avail.tab};var idx=((SP&&SP.length)||0)%COL_COLORS.length;var c=COL_COLORS[idx]||DEFAULT_COLOR;return {bg:c.bg,bdr:c.bdr,tab:c.tab};}
function ensureOverviewViewSettings(){if(!viewSettings.overview)viewSettings.overview={};var d=INITIAL_NEW_BOARD_VIEW_SETTINGS.overview;Object.keys(d).forEach(function(k){if(typeof viewSettings.overview[k]!=='boolean')viewSettings.overview[k]=d[k];});return viewSettings.overview;}
function findPageById(pid){return (SP||[]).find(function(s){return s&&s.id===pid;});}
function nextUnusedPageId(extraUsed){var used={};(SP||[]).forEach(function(s){if(s&&s.id)used[s.id]=true;});if(extraUsed){Object.keys(extraUsed).forEach(function(id){if(id)used[id]=true;});}var n=((SP&&SP.length)||0)+1;while(used['p'+n])n++;return 'p'+n;}
function hasOverviewPage(){return true;}
function isCurrentPageSubpage(){return !!findPageById(currentPage);}
function currentPageSupportsPageView(){if(currentPage==='overview'||currentPage==='final')return true;var sp=findPageById(currentPage);if(!sp)return false;var pt=sp.pageType||'this_then';return pt==='this_then'||pt==='how';}
function displayIdKey(value){return String(value||'').trim().toLowerCase();}
function visibleDisplayId(value,fallback){var v=String(value||'').trim();return v||String(fallback||'').trim();}
function findHowBoxRecordByKey(key){var m=String(key||'').match(/^(p\d+)-(H\d+)$/);if(!m)return null;var sp=findPageById(m[1]);if(!sp||sp.pageType!=='how'||!Array.isArray(sp.howBoxes))return null;var hb=sp.howBoxes.find(function(x){return x&&x.id===m[2];});return hb?{sp:sp,hb:hb}:null;}
function howBoxDisplayId(hb){return visibleDisplayId(hb&&hb.displayId,hb&&hb.id);}
function howBoxDisplayIdForKey(key){var rec=findHowBoxRecordByKey(key);return rec?howBoxDisplayId(rec.hb):'';}
function duplicateHowDisplayId(sp,hb,nextId){var want=displayIdKey(nextId);return !!(sp&&Array.isArray(sp.howBoxes)&&want&&sp.howBoxes.some(function(other){return other&&other!==hb&&displayIdKey(howBoxDisplayId(other))===want;}));}
function measureDisplayId(m){return visibleDisplayId(m&&m.displayId,displayMeasureId(m&&m.id));}
function eqDisplayId(q){return visibleDisplayId(q&&q.displayId,displayEQId(q&&q.id));}
function duplicateMeasureDisplayId(m,nextId){var want=displayIdKey(nextId);return !!(want&&measures.some(function(other){return other&&other!==m&&displayIdKey(measureDisplayId(other))===want;}));}
function duplicateEQDisplayId(q,nextId){var want=displayIdKey(nextId);return !!(want&&evalQuestions.some(function(other){return other&&other!==q&&displayIdKey(eqDisplayId(other))===want;}));}
function shouldShowBackToOverview(){return hasOverviewPage()&¤tPage!=='overview';}
function backToOverviewButtonHTML(){return (!presentationView&&shouldShowBackToOverview())?'':'';}
function presentationBackToOverviewHTML(){return shouldShowBackToOverview()?'':'';}
function canonicalDisclaimerInlineHtml(text){var safe=escHtml(text||'');return safe.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,function(match,label,url){var href=_safeUrl(url);if(!href)return label;return ''+label+'';});}
function renderCanonicalDisclaimerHtml(includeTitle){var sectionHeads={'Walk-Through':true,'Accessing Consulting or Training':true,'Adapting the DoView Board Prototype App to your setting':true,Disclaimer:true};return CANONICAL_DISCLAIMER_TEXT.split(/\n{2,}/).map(function(block){var text=block.trim();if(!text)return '';var html=canonicalDisclaimerInlineHtml(text).replace(/\n/g,' ');if(text===CANONICAL_DISCLAIMER_TITLE){return includeTitle?'
'+html+'
':'';}if(sectionHeads[text])return '
'+html+'
';return '
'+html+'
';}).join('');}
function overviewDefaultCardColor(sp){if(!sp)return DEFAULT_COLOR;var pt=sp.pageType||'this_then';if(pt==='this_then')return spColor(sp);var base=spColor(sp);return {bg:'#fff',bdr:'#d1d5db',tab:base.tab||DEFAULT_COLOR.tab};}
function overviewCardBorderFor(bg,def){var safeBg=sanitizeColorValue(bg,'');var key=String(safeBg||'').toLowerCase();var match=COL_COLORS.find(function(c){return c.bg.toLowerCase()===key;});return match?match.bdr:sanitizeColorValue((def&&def.bdr),DEFAULT_COLOR.bdr);}
const PRIORITY_VALUES=['A','B','C','D','E','BAU'];
function normalizePriorityValue(value){var p=(value===null||value===undefined)?'':String(value).trim().toUpperCase();return PRIORITY_VALUES.indexOf(p)>=0?p:'';}
function serializedPriority(value){return normalizePriorityValue(value);}
function finalOutcomeLabelValue(value){if(typeof value==='string')return value;if(value&&typeof value==='object'&&!Array.isArray(value)&&typeof value.label==='string')return value.label;if(value===null||value===undefined)return '';if(typeof value==='object')return '';return String(value);}
function normalizedFinalOutcomes(list){return Array.isArray(list)?list.map(finalOutcomeLabelValue):[];}
function normalizeFinalOutcomesInMemory(){if(!Array.isArray(FO_DATA))FO_DATA=[];FO_DATA=normalizedFinalOutcomes(FO_DATA);if(typeof B!=='undefined'&&B){FO_DATA.forEach(function(label,i){var b=B['final-b'+i];if(b){var boxLabel=finalOutcomeLabelValue(b.label);b.label=boxLabel||label;}});}}
function normalizePrioritiesInMemory(){if(typeof B!=='undefined'&&B){Object.keys(B).forEach(function(k){if(B[k])B[k].priority=normalizePriorityValue(B[k].priority);});}if(typeof SP!=='undefined'&&Array.isArray(SP)){SP.forEach(function(sp){if(sp&&sp.overviewCard)sp.overviewCard.priority=normalizePriorityValue(sp.overviewCard.priority);});}}
function normalizeTrafficLightsInMemory(){if(typeof B!=='undefined'&&B){Object.keys(B).forEach(function(k){if(B[k])B[k].light=normalizeTrafficLightValue(B[k].light);});}if(typeof ttLinks!=='undefined'&&Array.isArray(ttLinks)){ttLinks.forEach(function(l){if(l)l.light=normalizeTrafficLightValue(l.light);});}if(typeof measures!=='undefined'&&Array.isArray(measures)){measures.forEach(function(m){if(m&&m.trafficLight!==undefined)m.trafficLight=normalizeTrafficLightValue(m.trafficLight);});}if(typeof evalQuestions!=='undefined'&&Array.isArray(evalQuestions)){evalQuestions.forEach(function(q){if(q&&q.trafficLight!==undefined)q.trafficLight=normalizeTrafficLightValue(q.trafficLight);});}}
function ensureOverviewCardMeta(sp){if(!sp)return {light:'grey',priority:'',tagIds:[],boxColor:''};if(!sp.overviewCard)sp.overviewCard={};var m=sp.overviewCard;if(!m.light)m.light='grey';m.priority=normalizePriorityValue(m.priority);if(!Array.isArray(m.tagIds))m.tagIds=[];if(typeof m.boxColor!=='string')m.boxColor='';return m;}
function overviewCardMeta(sp){var m=(sp&&sp.overviewCard)||{};return {light:m.light||'grey',priority:normalizePriorityValue(m.priority),tagIds:Array.isArray(m.tagIds)?m.tagIds:[],boxColor:typeof m.boxColor==='string'?sanitizeOptionalColorValue(m.boxColor):''};}
function overviewCardColor(sp){var def=overviewDefaultCardColor(sp);var m=overviewCardMeta(sp);if(m.boxColor)return {bg:m.boxColor,bdr:overviewCardBorderFor(m.boxColor,def),tab:def.tab};return def;}
const PRI_COLORS={A:'#dc2626',B:'#ea580c',C:'#ca8a04',D:'#2563eb',E:'#4f46e5',BAU:'#334155'};
const STRUCTURAL_CMDS=['addBox','removeBox','renameBox','addColumn','removeColumn','renameColumn','moveColumn','addSubpage','removeSubpage','addFinalOutcome','removeFinalOutcome','deleteMeasure','deleteEQ'];
// SUPPORTED_CMDS is the source of truth used by _validateActionSet to reject unsupported action names clearly. Keep it in sync with execAction.
const SUPPORTED_CMDS=['setLight','setAllLights','setSubpageLights','addSofar','addNote','addNote1','addNote2','addNote3','setPriority','clearEntries','setDetail','clearDetail','setBoxBorder','clearBoxBorder','setSubpageBorders','clearSubpageBorders','setBoardInfo','setPageInfo','addBox','removeBox','renameBox','addColumn','removeColumn','renameColumn','moveColumn','addSubpage','removeSubpage','addFinalOutcome','removeFinalOutcome','addTTLink','removeTTLink','flipTTLink','addHowLink','removeHowLink','createMeasure','createEQ','linkMeasure','unlinkMeasure','linkEQ','unlinkEQ','deleteMeasure','deleteEQ'];
// Minimum argument counts for each supported command, including the command name in parts.
const CMD_MIN_PARTS={setLight:3,setAllLights:2,setSubpageLights:3,addSofar:3,addNote:3,addNote1:3,addNote2:3,addNote3:3,setPriority:3,clearEntries:3,setDetail:3,clearDetail:2,setBoxBorder:3,clearBoxBorder:2,setSubpageBorders:3,clearSubpageBorders:2,setBoardInfo:2,setPageInfo:3,addBox:4,removeBox:2,renameBox:3,addColumn:4,removeColumn:3,renameColumn:4,moveColumn:4,addSubpage:2,removeSubpage:2,addFinalOutcome:2,removeFinalOutcome:2,addTTLink:3,removeTTLink:2,flipTTLink:2,addHowLink:3,removeHowLink:2,createMeasure:2,createEQ:2,linkMeasure:3,unlinkMeasure:3,linkEQ:3,unlinkEQ:3,deleteMeasure:2,deleteEQ:2};
// ─── CORE ───
// _genUid returns a stable unique ID with a short type prefix and no network dependency.
function _genUid(prefix){var p=prefix||'id';var body;try{if(typeof crypto!=='undefined'&&crypto&&typeof crypto.randomUUID==='function'){body=crypto.randomUUID();}else{body=Date.now().toString(36)+'_'+Math.random().toString(36).slice(2,10);}}catch(_e){body=Date.now().toString(36)+'_'+Math.random().toString(36).slice(2,10);}return p+'_'+body;}
// _backfillUids assigns missing stable IDs additively and preserves existing IDs.
function _backfillUids(){if(!boardUid)boardUid=_genUid('board');if(typeof SP!=='undefined'&&Array.isArray(SP)){SP.forEach(function(sp){if(!sp)return;if(!sp.uid)sp.uid=_genUid('page');if(sp.overviewCard&&!Array.isArray(sp.overviewCard.tagIds))sp.overviewCard.tagIds=[];if(Array.isArray(sp.cols)){sp.cols.forEach(function(col){if(col&&!col.uid)col.uid=_genUid('col');});}if(Array.isArray(sp.howBoxes)){sp.howBoxes.forEach(function(hb){if(hb&&!hb.uid)hb.uid=_genUid('box');});}});}if(typeof B!=='undefined'&&B){Object.keys(B).forEach(function(k){var b=B[k];if(!b)return;if(!b.uid){b.uid=k.indexOf('final-')===0?_genUid('final'):_genUid('box');}if(!Array.isArray(b.tagIds))b.tagIds=[];});}if(typeof tags!=='undefined'&&Array.isArray(tags)){tags.forEach(function(t){if(t&&!t.id)t.id=_genUid('tag');});}if(typeof ttLinks!=='undefined'&&Array.isArray(ttLinks)){ttLinks.forEach(function(l){if(l&&!l.uid)l.uid=_genUid('link');});}if(typeof howLinks!=='undefined'&&Array.isArray(howLinks)){howLinks.forEach(function(l){if(l&&!l.uid)l.uid=_genUid('link');});}if(typeof measures!=='undefined'&&Array.isArray(measures)){measures.forEach(function(m){if(m&&!m.uid)m.uid=_genUid('measure');});}if(typeof evalQuestions!=='undefined'&&Array.isArray(evalQuestions)){evalQuestions.forEach(function(q){if(q&&!q.uid)q.uid=_genUid('eq');});}}
function buildInitialState(){normalizeFinalOutcomesInMemory();SP.forEach(sp=>{if(!sp.uid)sp.uid=_genUid('page');if(Array.isArray(sp.cols)){sp.cols.forEach(function(col){if(!col.uid)col.uid=_genUid('col');});}sp.cols.forEach((col,ci)=>{col.boxes.forEach((label,bi)=>{B[`${sp.id}-c${ci}-b${bi}`]={label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',measures:[],evalQuestions:[],tagIds:[],uid:_genUid('box')};});});if(sp.howBoxes){sp.howBoxes.forEach(hb=>{if(!hb.uid)hb.uid=_genUid('box');const k=`${sp.id}-${hb.id}`;B[k]={label:hb.label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',tagIds:[],uid:hb.uid};});}});FO_DATA.forEach((label,i)=>{B[`final-b${i}`]={label:label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',measures:[],evalQuestions:[],tagIds:[],uid:_genUid('final')};});}
function buildDepsMap(){DEPS={};SP.forEach(sp=>{sp.cols.forEach((col,ci)=>{if(ci===0)return;col.boxes.forEach((_,bi)=>{const k=`${sp.id}-c${ci}-b${bi}`;DEPS[k]=sp.cols[ci-1].boxes.map((_,pbi)=>`${sp.id}-c${ci-1}-b${pbi}`);});});});}
function cascade(){return;}
function markRuntimeLinkIndexDirty(){runtimeLinkIndexDirty=true;}
function _idxNewMap(){return Object.create(null);}
function _idxPush(map,key,item){if(!key)return;(map[key]||(map[key]=[])).push(item);}
function _idxPair(a,b){return String(a)+'\u0001'+String(b);}
function rebuildRuntimeLinkIndex(){var idx={pageById:_idxNewMap(),pageByKey:_idxNewMap(),boxTypeByKey:_idxNewMap(),howLevelByKey:_idxNewMap(),visibleKeysByPage:_idxNewMap(),howRecordByKey:_idxNewMap(),ttByFrom:_idxNewMap(),ttByTo:_idxNewMap(),ttByTouch:_idxNewMap(),ttByPair:_idxNewMap(),ttById:_idxNewMap(),howByFrom:_idxNewMap(),howByTo:_idxNewMap(),howByTouch:_idxNewMap(),howByPair:_idxNewMap(),howById:_idxNewMap(),ttLen:Array.isArray(ttLinks)?ttLinks.length:0,howLen:Array.isArray(howLinks)?howLinks.length:0,ttRef:ttLinks,howRef:howLinks};var pages=Array.isArray(SP)?SP:[];pages.forEach(function(sp){if(!sp||!sp.id)return;idx.pageById[sp.id]=sp;if(!idx.visibleKeysByPage[sp.id])idx.visibleKeysByPage[sp.id]=[];var pt=sp.pageType||'this_then';if(pt==='how'&&Array.isArray(sp.howBoxes)){sp.howBoxes.forEach(function(hb){if(!hb||!hb.id)return;var k=sp.id+'-'+hb.id;idx.pageByKey[k]=sp;idx.boxTypeByKey[k]='how';idx.howLevelByKey[k]=(sp.howLevel!==undefined)?sp.howLevel:null;idx.howRecordByKey[k]={sp:sp,hb:hb};if(B&&B[k])idx.visibleKeysByPage[sp.id].push(k);});}else if(pt==='this_then'&&Array.isArray(sp.cols)){sp.cols.forEach(function(col,ci){(col.boxes||[]).forEach(function(_,bi){var k=sp.id+'-c'+ci+'-b'+bi;idx.pageByKey[k]=sp;idx.boxTypeByKey[k]='this_then';if(B&&B[k])idx.visibleKeysByPage[sp.id].push(k);});});}});idx.visibleKeysByPage.final=[];if(Array.isArray(FO_DATA)){FO_DATA.forEach(function(_,i){var k='final-b'+i;idx.boxTypeByKey[k]='final';if(B&&B[k])idx.visibleKeysByPage.final.push(k);});}(Array.isArray(ttLinks)?ttLinks:[]).forEach(function(l){if(!l||!l.from||!l.to)return;_idxPush(idx.ttByFrom,l.from,l);_idxPush(idx.ttByTo,l.to,l);_idxPush(idx.ttByTouch,l.from,l);_idxPush(idx.ttByTouch,l.to,l);idx.ttByPair[_idxPair(l.from,l.to)]=l;if(l.id)idx.ttById[l.id]=l;});(Array.isArray(howLinks)?howLinks:[]).forEach(function(l){if(!l||!l.from||!l.to)return;_idxPush(idx.howByFrom,l.from,l);_idxPush(idx.howByTo,l.to,l);_idxPush(idx.howByTouch,l.from,l);_idxPush(idx.howByTouch,l.to,l);idx.howByPair[_idxPair(l.from,l.to)]=l;if(l.id)idx.howById[l.id]=l;});runtimeLinkIndex=idx;runtimeLinkIndexDirty=false;return idx;}
function getRuntimeLinkIndex(){if(runtimeLinkIndex&&!runtimeLinkIndexDirty&&runtimeLinkIndex.ttRef===ttLinks&&runtimeLinkIndex.howRef===howLinks&&runtimeLinkIndex.ttLen===(Array.isArray(ttLinks)?ttLinks.length:0)&&runtimeLinkIndex.howLen===(Array.isArray(howLinks)?howLinks.length:0))return runtimeLinkIndex;return rebuildRuntimeLinkIndex();}
function _idxList(map,key){return getRuntimeLinkIndex()[map][key]||[];}
function getTTLinksFrom(key){return _idxList('ttByFrom',key);}
function getTTLinksTo(key){return _idxList('ttByTo',key);}
function getTTLinksTouching(key){return _idxList('ttByTouch',key);}
function getHowLinksFrom(key){return _idxList('howByFrom',key);}
function getHowLinksTo(key){return _idxList('howByTo',key);}
function getHowLinksTouching(key){return _idxList('howByTouch',key);}
function getHowLinkExact(from,to){return getRuntimeLinkIndex().howByPair[_idxPair(from,to)]||null;}
function _pushUniqueKey(list,key){if(key&&list.indexOf(key)<0)list.push(key);}
function linkedKeysForSource(sourceKey){var out=[];if(!sourceKey||!B[sourceKey])return out;if(isTTBox(sourceKey)){getTTLinksFrom(sourceKey).forEach(function(l){_pushUniqueKey(out,l.to);});getTTLinksTo(sourceKey).forEach(function(l){_pushUniqueKey(out,l.from);});getHowLinksTo(sourceKey).forEach(function(l){_pushUniqueKey(out,l.from);});}else if(isHowBox(sourceKey)){getHowLinksFrom(sourceKey).forEach(function(l){_pushUniqueKey(out,l.to);});getHowLinksTo(sourceKey).forEach(function(l){_pushUniqueKey(out,l.from);});}return out;}
function parseTTBoxKey(key){var m=String(key||'').match(/^(.+)-c(\d+)-b(\d+)$/);return m?{spId:m[1],ci:parseInt(m[2],10),bi:parseInt(m[3],10)}:null;}
function parseHowBoxKey(key){var m=String(key||'').match(/^(.+)-(H\d+)$/);return m?{spId:m[1],howId:m[2]}:null;}
function saveState(){
// Read-only copies do not persist edits through the board interface.
if(IS_READONLY)return;
_backfillUids();ensureTagIdsOnSupportedElements();
normalizePrioritiesInMemory();normalizeTrafficLightsInMemory();normalizeFinalOutcomesInMemory();normalizeBoxMEQRefsInMemory();sanitizeCustomColorsInMemory();
try{const cleanB={};Object.entries(B).forEach(([k,v])=>{cleanB[k]={label:v.label,light:v.light,entries:[...v.entries],priority:serializedPriority(v.priority),hasSubpage:v.hasSubpage,detailText:v.detailText||'',borderColor:v.borderColor||'',boxColor:v.boxColor||'',measures:[...(v.measures||[])],evalQuestions:[...(v.evalQuestions||[])],tagIds:[...(v.tagIds||[])],jumpToPage:v.jumpToPage||'',/* persist box uid + optional provenance fields so save/load is uid-stable. */uid:v.uid||'',sourceUid:v.sourceUid||'',sourceBoardUid:v.sourceBoardUid||'',sourceBoardTitle:v.sourceBoardTitle||'',sourceObjectType:v.sourceObjectType||'',sourceObjectLabelAtCopy:v.sourceObjectLabelAtCopy||'',copiedAt:v.copiedAt||''};});const state={B:cleanB,SP:JSON.parse(JSON.stringify(SP)),FO:[...FO_DATA],boardInfo:boardInfo,pageInfo:JSON.parse(JSON.stringify(pageInfo)),docContent:sanitizedDocContentState(),createdDate:createdDate,boardInstanceId:boardInstanceId,/* persist boardUid alongside boardInstanceId. boardUid is the additive stable unique board ID; boardInstanceId continues to play its existing role unchanged. */boardUid:boardUid||'',ttLinks:JSON.parse(JSON.stringify(ttLinks)),ttLinkNextId:ttLinkNextId,howLinks:JSON.parse(JSON.stringify(howLinks)),howLinkNextId:howLinkNextId,measures:JSON.parse(JSON.stringify(measures)),measureNextId:measureNextId,evalQuestions:JSON.parse(JSON.stringify(evalQuestions)),eqNextId:eqNextId,tags:JSON.parse(JSON.stringify(tags||[])),viewSettings:JSON.parse(JSON.stringify(viewSettings)),topRightText:topRightText||'',presentationView:!!presentationView,/* persist sourcesInitialized so that a user who removed the default source is not re-seeded on reload. */sourcesInitialized:!!sourcesInitialized};localStorage.setItem(`doview-${SLUG}-v1`,JSON.stringify(state));}catch(e){}
// Browser-local state is separate from explicit HTML save/export/copy flows.
isDirty=true;updateSaveStateDisplay();
}
function markBoardCleanAfterExport(){isSaving=false;isDirty=false;lastSavedTime=Date.now();updateSaveStateDisplay();}
function resetDirtyTrackingAfterLoad(){isSaving=false;isDirty=false;lastSavedTime=EMBEDDED_STATE?Date.now():null;updateSaveStateDisplay();}
function installUnsavedChangesWarning(){if(unsavedWarningInstalled||typeof window==='undefined')return;unsavedWarningInstalled=true;window.addEventListener('beforeunload',function(e){if(IS_READONLY||!isDirty)return;e.preventDefault();e.returnValue='';return '';});}
// ─── LINKIFY ───
/* Low-level linkification helper for text that is already HTML-safe. Dangerous schemes are not matched. */
function linkify(text){return text.replace(/(https?:\/\/[^\s<>"')\]]+)/g,'$1').replace(/(mailto:[^\s<>"')\]]+)/g,'$1').replace(/(?"')\]]*)?)/g,'$1');}
/* Small central safe-URL helper. It allows ordinary http(s) URLs and bare domains, rejects dangerous schemes, and does not rewrite stored user text. */
function _safeUrl(s){if(s===null||s===undefined)return '';var raw=String(s);if(!raw)return '';/* trim surrounding whitespace including control characters */ var v=raw.replace(/^[\s\u0000-\u001F\u007F]+|[\s\u0000-\u001F\u007F]+$/g,'');if(!v)return '';/* normalise the scheme prefix for danger detection: lowercase + strip embedded whitespace/control chars between scheme letters (defeats e.g. "java\tscript:" or "java\nscript:" tricks). */var lc=v.toLowerCase();var schemePart=lc.split(':')[0]||'';var schemeNoWs=schemePart.replace(/[\s\u0000-\u001F\u007F]+/g,'');var DANGEROUS=['JavaScript','data','vbscript','file','about'];if(DANGEROUS.indexOf(schemeNoWs)>=0)return '';/* if the value already starts with http:// or https://, keep it as-is (after trim). Be lenient about case in the scheme. */if(/^https?:\/\//i.test(v))return v;/* otherwise treat it as a bare domain. Accept things like "example.org", "www.example.org", "example.org/path?q=1". Reject anything that still contains a colon at this point (e.g. "ftp:foo", "weird:thing") because we only want to upgrade plain domains. */if(v.indexOf(':')>=0)return '';/* require something that looks like a host token (letters/digits/dots/hyphens) followed optionally by a path/query/fragment. */if(!/^[A-Za-z0-9._~%-]+(?:\/[^\s<>"']*)?$/.test(v))return '';/* require at least one dot in the host portion so we do not promote single-word junk to a URL. */var hostPart=v.split('/')[0]||'';if(hostPart.indexOf('.')<0)return '';return 'https://'+v;}
/* Documentation rich-text sanitizer: narrow internal sanitizer for saved/imported/embedded docContent. */
const DOC_ALLOWED_TAGS={P:1,BR:1,B:1,STRONG:1,I:1,EM:1,U:1,UL:1,OL:1,LI:1,H1:1,H2:1,H3:1,BLOCKQUOTE:1,A:1,DIV:1,SPAN:1};
const DOC_DROP_TAGS={SCRIPT:1,IFRAME:1,OBJECT:1,EMBED:1,STYLE:1,IMG:1,VIDEO:1,AUDIO:1,SOURCE:1,CANVAS:1,LINK:1,META:1,BASE:1,SVG:1,MATH:1};
const DOC_UNWRAP_TAGS={FORM:1,BUTTON:1};
const DOC_CLONE_TYPES={page_title:1,box_title:1,box_main_text:1,measure:1,eval_question:1,link:1};
function isSafeDocCloneType(type){return !!DOC_CLONE_TYPES[String(type||'')];}
function isSafeDocCloneKey(key){return /^[A-Za-z0-9_-]+(?:-[A-Za-z0-9_-]+)*$/.test(String(key||''));}
function isSafeDocClone(type,key){return isSafeDocCloneType(type)&&isSafeDocCloneKey(key);}
function unwrapDocNode(node){var parent=node&&node.parentNode;if(!parent)return;while(node.firstChild){parent.insertBefore(node.firstChild,node);}parent.removeChild(node);}
function sanitizeDocElementAttrs(el){
var tag=String(el.tagName||'').toUpperCase();
var href=tag==='A'?el.getAttribute('href'):'';
var cls=el.getAttribute('class')||'';
var cloneType=el.getAttribute('data-clone-type')||'';
var cloneKey=el.getAttribute('data-clone-key')||'';
Array.from(el.attributes||[]).forEach(function(attr){el.removeAttribute(attr.name);});
if(tag==='A'){
var safeHref=_safeUrl(href||'');
if(safeHref){el.setAttribute('href',safeHref);if(/^https?:\/\//i.test(safeHref)){el.setAttribute('target','_blank');el.setAttribute('rel','noopener noreferrer');}}
return;
}
if(tag==='DIV'&&/\bdoc-clone\b/.test(cls)&&isSafeDocClone(cloneType,cloneKey)){
el.setAttribute('class','doc-clone');
el.setAttribute('data-clone-type',cloneType);
el.setAttribute('data-clone-key',cloneKey);
while(el.firstChild){el.removeChild(el.firstChild);}
return;
}
}
function sanitizeDocHtml(html){
if(html===null||html===undefined)return '';
var src=String(html);
if(!src)return '';
var tpl=document.createElement('template');
try{tpl.innerHTML=src;}catch(e){return escHtml(src);}
function cleanTree(parent){
Array.from(parent.childNodes).forEach(function(node){
if(node.nodeType===8){node.remove();return;}
if(node.nodeType!==1)return;
var tag=String(node.tagName||'').toUpperCase();
if(DOC_DROP_TAGS[tag]){node.remove();return;}
if(DOC_UNWRAP_TAGS[tag]){cleanTree(node);unwrapDocNode(node);return;}
if(!DOC_ALLOWED_TAGS[tag]){cleanTree(node);unwrapDocNode(node);return;}
sanitizeDocElementAttrs(node);
cleanTree(node);
});
}
cleanTree(tpl.content);
return tpl.innerHTML;
}
function sanitizeDocContentObject(obj){var out={};if(!obj||typeof obj!=='object')return out;Object.keys(obj).forEach(function(pid){out[pid]=sanitizeDocHtml(obj[pid]);});return out;}
function sanitizedDocContentState(){return sanitizeDocContentObject(docContent);}
/* safe-linkify helper used by popup display blocks. Escapes raw user text first (so any < > & in the saved value renders literally rather than as HTML), then runs the existing linkify() to mark URLs as clickable. Mirrors the existing pattern used in buildUnderBoxDisplay and renderMd; kept as a small named helper so each display call site reads clearly. Does not alter the stored text — only how it is rendered. */
function linkifyDisplay(text){if(text===null||text===undefined)return '';var s=String(text);if(!s)return '';return linkify(s.replace(/&/g,'&').replace(//g,'>'));}
function displayTextForView(text,codeStyle,allowLinks){var s=(text===null||text===undefined)?'':String(text);if(!s.trim())return '';return codeStyle?escHtml(s):(allowLinks?linkifyDisplay(s):escHtml(s));}
function getViewForBoxKey(key){if(!key)return null;if(String(key).indexOf('final-b')===0)return viewSettings.finalOutcomes;var pg=getBoxPage(key);if(!pg)return null;return (pg.pageType==='how')?viewSettings.how:viewSettings.thisThen;}
function boxDisplayCodeStyleActive(key){var vs=getViewForBoxKey(key);return !!(vs&&vs.showMainTextCodeStyle);}
function linkDisplayCodeStyleActive(){return !!(viewSettings.thisThen&&viewSettings.thisThen.showLinkInfoCodeStyle);}
function setDisplayTextNoticeAndStyle(textareaId,noticeId,active){var ta=document.getElementById(textareaId);var notice=document.getElementById(noticeId);if(ta)ta.classList.toggle('code-style-active',!!active);if(notice)notice.classList.toggle('on',!!active);}
/* build the small read-only display block placed above an editable textarea in the Measure / EQ / Link / Board info / Page info popups. Returns '' when the saved value is empty so the popup stays clean for never-filled-in fields. The label is optional and rendered subtly. */
function buildPopupDisplayBlock(text,label){var s=(text===null||text===undefined)?'':String(text);if(!s.trim())return '';var lbl=label?'
'+escHtml(label)+'
':'';return '
'+lbl+linkifyDisplay(s)+'
';}
// ─── RENDER ───
/* tlColor returns a single solid color for a traffic-light state, used wherever a flat fill is appropriate (e.g. AI chat state-text color codes). For the two new mixed states ('greenYellow', 'yellowRed') it returns the dominant left-half color as a sensible fallback when a flat fill is rendered; the visually correct vertically-split rendering is produced by tlSplitClass below, which is applied to the dot/picker/chooser elements. Older bare values 'green'/'yellow'/'red'/'grey' are unchanged. */
function tlColor(l){return{yellow:'#F5A623',green:'#2ECC71',red:'#E74C3C',grey:'#94A3B8',greenYellow:'#2ECC71',yellowRed:'#F5A623'}[l]||'#94A3B8';}
/* tlSplitClass returns the CSS class name for the vertically-split rendering of the two new mixed traffic-light states. Returns '' for the four original states so the existing solid-fill rendering continues to apply. The class is appended to dot/picker/chooser elements alongside their inline background, and the .tl-split-gy / .tl-split-yr CSS rules use !important to override the inline solid background with the half-and-half gradient. */
function tlSplitClass(l){return l==='greenYellow'?'tl-split-gy':(l==='yellowRed'?'tl-split-yr':'');}
/* tlLabel returns the user-facing label for a traffic-light state, used in tooltips and accessible titles in the picker/chooser. */
function tlLabel(l){return{green:'Green',greenYellow:'Green/yellow',yellow:'Yellow',yellowRed:'Yellow/red',red:'Red',grey:'Grey',unset:'Unset'}[l]||'Unset';}
const TRAFFIC_LIGHT_VALUES=['green','greenYellow','yellow','yellowRed','red','grey'];
function normalizeTrafficLightValue(value){var v=value===null||value===undefined?'':String(value).trim();return TRAFFIC_LIGHT_VALUES.indexOf(v)>=0?v:'';}
function trafficLightIsSet(value){return normalizeTrafficLightValue(value)!=='';}
function trafficLightDotHTML(key,value,show,onClick,pageType){var state=normalizeTrafficLightValue(value);var title=state?('Click to change traffic light: '+tlLabel(state)):'Click to set traffic light';var click=onClick||"openTLChooser(event,'"+key+"')";if(!show){return '';}if(state){var split=tlSplitClass(state);return '';}var hidden=!pageShouldShowTrafficPlaceholder(pageType);return '';}
function pageBoxKeysForType(pageType){if(pageType==='finalOutcomes'){return FO_DATA.map(function(_,i){return 'final-b'+i;}).filter(function(k){return !!B[k];});}var sp=findPageById(currentPage);if(!sp)return [];if(pageType==='how'){return (sp.howBoxes||[]).map(function(hb){return sp.id+'-'+hb.id;}).filter(function(k){return !!B[k];});}return Object.keys(B||{}).filter(function(k){return k.indexOf(sp.id+'-c')===0;});}
function pageHasSelectedTrafficLight(pageType){return pageBoxKeysForType(pageType).some(function(k){return B[k]&&trafficLightIsSet(B[k].light);});}
function pageShouldShowTrafficPlaceholder(pageType){return !pageHasSelectedTrafficLight(pageType);}
function pageHasSelectedPriority(pageType){return pageBoxKeysForType(pageType).some(function(k){return B[k]&&normalizePriorityValue(B[k].priority);});}
function pageShouldShowPriorityPlaceholder(pageType){return !pageHasSelectedPriority(pageType);}
function linkTrafficValue(l){return l?normalizeTrafficLightValue(l.light):'';}
function linkTrafficDotHTML(l){var state=linkTrafficValue(l);if(!state)return '';var split=tlSplitClass(state);return '';}
function linkHoverTrafficIconHTML(l){var state=linkTrafficValue(l);if(!state)return '';var split=tlSplitClass(state);return '';}
function linkHoverPopupHTML(l){var raw=(l&&typeof l.mainText==='string')?l.mainText:'';if(!raw.trim())return '';var codeStyle=linkDisplayCodeStyleActive();var text=codeStyle?escHtml(raw):escHtml(raw.trim());return ''+linkHoverTrafficIconHTML(l)+''+text+'';}
function linkRevealInnerHTML(l){if(!l||!viewSettings.thisThen.showLinkInfoOnHover)return '';return linkTrafficDotHTML(l)+linkHoverPopupHTML(l);}
function linkRevealTitle(defaultTitle,l){return (viewSettings.thisThen.showLinkInfoOnHover&&l&&l.mainText&&String(l.mainText).trim())?'':defaultTitle;}
var linkHoverHideTimer=null,linkHoverShowTimer=null,linkHoverAnchor=null;
function clearLinkHoverShowTimer(){if(linkHoverShowTimer){clearTimeout(linkHoverShowTimer);linkHoverShowTimer=null;}}
function ensureLinkHoverPortal(){var p=document.getElementById('linkHoverPortal');if(p)return p;p=document.createElement('div');p.id='linkHoverPortal';p.className='link-hover-portal';p.onmouseenter=function(){if(linkHoverHideTimer){clearTimeout(linkHoverHideTimer);linkHoverHideTimer=null;}};p.onmouseleave=function(){scheduleHideLinkHoverPopup();};p.onclick=function(ev){if(ev){ev.stopPropagation();ev.preventDefault();}var id=p.dataset.linkId||'';hideLinkHoverPopup();if(id)openLinkDetail(id);};document.body.appendChild(p);return p;}
function linkHoverVisibleBounds(){var vw=window.innerWidth||document.documentElement.clientWidth||0;var vh=window.innerHeight||document.documentElement.clientHeight||0;var margin=8;var b={left:margin,top:margin,right:Math.max(margin,vw-margin),bottom:Math.max(margin,vh-margin)};var main=document.getElementById('main');if(main&&main.getBoundingClientRect){var r=main.getBoundingClientRect();var mb={left:Math.max(margin,r.left+margin),top:Math.max(margin,r.top+margin),right:Math.min(vw-margin,r.right-margin),bottom:Math.min(vh-margin,r.bottom-margin)};if(mb.right-mb.left>=70&&mb.bottom-mb.top>=40)b=mb;}return b;}
function linkHoverRelationshipRect(anchor,linkId){var ar=anchor.getBoundingClientRect();var rect={left:ar.left,top:ar.top,right:ar.right,bottom:ar.bottom,width:ar.width,height:ar.height};var found=linkId?findLink(linkId):null;if(found&&found.link){['from','to'].forEach(function(k){var box=document.getElementById('bx-'+found.link[k]);if(box&&box.getBoundingClientRect){var br=box.getBoundingClientRect();rect.left=Math.min(rect.left,br.left);rect.top=Math.min(rect.top,br.top);rect.right=Math.max(rect.right,br.right);rect.bottom=Math.max(rect.bottom,br.bottom);}});rect.width=rect.right-rect.left;rect.height=rect.bottom-rect.top;}return rect;}
function syncLinkHoverTypography(anchor,portal){if(!portal)return;var boxEl=null;var id=portal.dataset?portal.dataset.linkId:'';var found=id?findLink(id):null;if(found&&found.link){boxEl=document.getElementById('bx-'+found.link.from)||document.getElementById('bx-'+found.link.to);}if(!boxEl&&anchor&&anchor.closest)boxEl=anchor.closest('.box,.how-box,.fo-box');var txtEl=boxEl&&boxEl.querySelector?boxEl.querySelector('.box-txt'):null;if(!txtEl)return;var cs=window.getComputedStyle?window.getComputedStyle(txtEl):null;if(!cs)return;var fontPx=parseFloat(cs.fontSize)||12;var linePx=parseFloat(cs.lineHeight);var ratio=(linePx&&fontPx)?(linePx/fontPx):1.45;var scale=1;if(boxEl.offsetWidth&&boxEl.getBoundingClientRect){var br=boxEl.getBoundingClientRect();scale=br.width/boxEl.offsetWidth;}if(!isFinite(scale)||scale<=0){var main=document.getElementById('main');var mz=main&&window.getComputedStyle?parseFloat(window.getComputedStyle(main).zoom):1;scale=(isFinite(mz)&&mz>0)?mz:1;}scale=Math.max(0.5,Math.min(3,scale));portal.style.fontSize=(fontPx*scale).toFixed(2)+'px';portal.style.lineHeight=String(Math.max(1.1,Math.min(1.8,ratio)));}
function positionLinkHoverPopup(){var anchor=linkHoverAnchor;if(!anchor)return;var portal=document.getElementById('linkHoverPortal');if(!portal||!portal.classList.contains('open'))return;var bounds=linkHoverVisibleBounds();var maxW=Math.max(90,Math.min(360,bounds.right-bounds.left));portal.style.maxWidth=maxW+'px';portal.style.visibility='hidden';portal.style.left='0px';portal.style.top='0px';var ar=anchor.getBoundingClientRect();var lr=linkHoverRelationshipRect(anchor,portal.dataset.linkId||'');var pr=portal.getBoundingClientRect();var gap=8;var left=ar.left+(ar.width/2)-(pr.width/2);if(leftbounds.right)left=bounds.right-pr.width;if(leftbounds.bottom){top=aboveTop>=bounds.top?aboveTop:bounds.bottom-pr.height;}if(topbounds.bottom)top=Math.max(bounds.top,bounds.bottom-pr.height);portal.style.left=Math.round(left)+'px';portal.style.top=Math.round(top)+'px';portal.style.visibility='visible';}
function showLinkHoverPopup(ev){var anchor=ev&&ev.currentTarget?ev.currentTarget:null;if(!anchor)return;var src=anchor.querySelector('.link-hover-pop');if(!src){hideLinkHoverPopup();return;}clearLinkHoverShowTimer();if(linkHoverHideTimer){clearTimeout(linkHoverHideTimer);linkHoverHideTimer=null;}linkHoverAnchor=anchor;var portal=ensureLinkHoverPortal();portal.innerHTML=src.innerHTML;portal.dataset.linkId=src.dataset.linkId||'';portal.classList.add('open');syncLinkHoverTypography(anchor,portal);if(window.__doviewHideTip)window.__doviewHideTip();positionLinkHoverPopup();}
function scheduleShowLinkHoverPopup(ev){var anchor=ev&&ev.currentTarget?ev.currentTarget:null;if(!anchor)return;if(!anchor.querySelector('.link-hover-pop')){hideLinkHoverPopup();return;}if(linkHoverHideTimer){clearTimeout(linkHoverHideTimer);linkHoverHideTimer=null;}if(linkHoverAnchor===anchor){positionLinkHoverPopup();return;}clearLinkHoverShowTimer();linkHoverShowTimer=setTimeout(function(){showLinkHoverPopup({currentTarget:anchor});},125);}
function scheduleHideLinkHoverPopup(){clearLinkHoverShowTimer();if(linkHoverHideTimer)clearTimeout(linkHoverHideTimer);linkHoverHideTimer=setTimeout(hideLinkHoverPopup,120);}
function hideLinkHoverPopup(){clearLinkHoverShowTimer();if(linkHoverHideTimer){clearTimeout(linkHoverHideTimer);linkHoverHideTimer=null;}var portal=document.getElementById('linkHoverPortal');if(portal){portal.classList.remove('open');portal.innerHTML='';portal.dataset.linkId='';}linkHoverAnchor=null;}
function wireLinkHoverPopups(){document.querySelectorAll('.reveal-arrow-hover-out,.reveal-arrow-hover-in').forEach(function(el){el.onmouseenter=scheduleShowLinkHoverPopup;el.onmouseover=function(ev){if(!ev||!ev.relatedTarget||!el.contains(ev.relatedTarget))scheduleShowLinkHoverPopup({currentTarget:el});};el.onmousemove=positionLinkHoverPopup;el.onmouseleave=scheduleHideLinkHoverPopup;el.onmouseout=function(ev){if(!ev||!ev.relatedTarget||!el.contains(ev.relatedTarget))scheduleHideLinkHoverPopup();};el.onfocus=scheduleShowLinkHoverPopup;el.onblur=scheduleHideLinkHoverPopup;});}
function priColor(p){p=normalizePriorityValue(p);return {A:'#ffffff',B:'#ffffff',C:'#ffffff',D:'#1e293b',E:'#334155'}[p]||'#334155';}
function priBadgeClass(p){p=normalizePriorityValue(p);return p?' pri-'+String(p).toLowerCase().replace(/[^a-z0-9]/g,''):' pri-empty';}
function priorityBadgeHTML(key,value,show,pageType){if(!show)return '';var pri=normalizePriorityValue(value);var hidden=!pri&&!pageShouldShowPriorityPlaceholder(pageType);return '
'+escHtml(pri)+'
';}
function getRevealClasses(key){function _neg(from,to){var l=ttLinkBetween(from,to);return l&&ttLinkPolarity(l)==='negative';}if(inspectHome){if(key===inspectHome)return ' inspect-home';if(inspectLinkedKeys.indexOf(key)>=0){let cls=' inspect-linked';if(ttLinkBetween(inspectHome,key)){cls+=' reveal-out';if(_neg(inspectHome,key))cls+=' reveal-out-neg';}if(ttLinkBetween(key,inspectHome)){cls+=' reveal-in';if(_neg(key,inspectHome))cls+=' reveal-in-neg';}return cls;}return '';}if(!revealBox)return '';if(revealType==='tt'){if(key===revealBox)return ' reveal-active';let cls='';if(ttLinkBetween(revealBox,key)){cls+=' reveal-out';if(_neg(revealBox,key))cls+=' reveal-out-neg';}if(ttLinkBetween(key,revealBox)){cls+=' reveal-in';if(_neg(key,revealBox))cls+=' reveal-in-neg';}if(cls)cls+=' reveal-active';return cls;}if(revealType==='how'){const rb=revealBox;if(isTTBox(key)){if(getHowLinkExact(rb,key))return ' box-selected';}return '';}return '';}
function ttLinkPolarity(l){return l&&l.polarity==='negative'?'negative':'positive';}
function ttPolarityRel(l){return ttLinkPolarity(l)==='negative'?'negatively affects':'positively affects';}
function ttRelHTML(l){return ttLinkPolarity(l)==='negative'?'negatively affects':'positively affects';}
function ttCountInfo(key,direction){var links=direction==='left'?getTTLinksTo(key):getTTLinksFrom(key);var pos=0,neg=0;links.forEach(function(l){if(ttLinkPolarity(l)==='negative')neg++;else pos++;});return {total:links.length,pos:pos,neg:neg,cls:pos&&neg?'tt-pol-mixed':(neg?'tt-pol-negative':'tt-pol-positive')};}
function ttCountBadgeHTML(key,direction,showCounts){var info=ttCountInfo(key,direction);var side=direction==='left'?'tt-cnt-l':'tt-cnt-r';var kind=direction==='left'?'tt-left':'tt-right';var title=info.total?(info.total+' '+(direction==='left'?'incoming':'outgoing')+' This–Then links'):(direction==='left'?'No incoming links':'No outgoing links');var click="if(linkDragClickSuppress(event))return;event.stopPropagation();showTTLinksList('"+key+"','"+direction+"')";if(showCounts){if(!info.total)return '';return ''+info.total+'';}return ''; }
function ttLinkBetween(from,to){return getRuntimeLinkIndex().ttByPair[_idxPair(from,to)]||null;}
function flickBoxes(keys){keys=(keys||[]).filter(Boolean);if(sourceRefBox&&sourceRefBox.sourceKey&&keys.indexOf(sourceRefBox.sourceKey)>=0){var sr=document.getElementById('srcRefBox');if(sr){sr.classList.remove('tt-flick');void sr.offsetWidth;sr.classList.add('tt-flick');}}keys.forEach(function(k){var el=document.getElementById('bx-'+k);if(el){el.classList.remove('tt-flick');void el.offsetWidth;el.classList.add('tt-flick');}});}
function getHowRevealClass(key){if(inspectHome){if(key===inspectHome)return ' inspect-home';if(inspectLinkedKeys.indexOf(key)>=0)return ' inspect-linked';return '';}if(!revealBox)return '';if(revealType==='how'){if(key===revealBox)return '';if(getHowLinkExact(revealBox,key)||getHowLinkExact(key,revealBox))return ' reveal-highlight';return '';}if(revealType==='tt'){if(getHowLinkExact(key,revealBox))return ' reveal-highlight';return '';}return '';}
function boxHTML(key,boxData,isLast,spColor){const{label,light,priority,hasSubpage,detailText,borderColor,jumpToPage}=boxData;const entries=Array.isArray(boxData.entries)?boxData.entries:[];const hasSofar=entries.some(e=>e.type==='sofar');const editLbl=light==='green'&&hasSofar?'edit':'';const priTxt=normalizePriorityValue(priority);const vs=viewSettings.thisThen;const showPri=vs.showPriorities;const priBadge=priorityBadgeHTML(key,priTxt,showPri,'this_then');const drillHTML=hasSubpage?'':'';const jumpHTML=jumpToPage?(hasSubpage?``:``):'';const greyClass=light==='grey'?' grey-state':'';const lastClass=isLast?' last-col':'';const selClass=key===openBox?' box-selected':'';const revCls=getRevealClasses(key);const customFill=sanitizeOptionalColorValue(boxData.boxColor);const customBdr=sanitizeOptionalColorValue(borderColor);const fillColor=customFill||spColor.bg;const bdrColor=customBdr||spColor.bdr;const bdrWidth=customBdr?'2px':'1px';const showTL=vs.showTrafficLights;const tlDot=trafficLightDotHTML(key,light,showTL,"openTLChooser(event,'"+key+"')",'this_then');const showCnts=vs.showCounts;const lBadge=ttCountBadgeHTML(key,'left',showCnts);const rBadge=ttCountBadgeHTML(key,'right',showCnts);const howCnt=getHowCountForTTBox(key);const showHC=vs.showHowCounts;const howBadge=showHC?(howCnt?`${howCnt}`:``):'';const ttLatCnt=getLateralHowCountForTTBox(key);const showTTLat=vs.showLateralHow===true;const ttLatSq=showTTLat?``:'';const underBox=buildUnderBoxDisplay(key,'this_then');/* tooltip-bearing overlays for the small black reveal/link arrows. A box with class reveal-out has a LEFT-side arrow (this box results from the selected box). A box with class reveal-in has a RIGHT-side arrow (this box makes the selected box happen). The overlays carry side-specific tooltips via the DV_TIPS interceptor. */const hasRevealOut=revCls.indexOf('reveal-out')>=0;const hasRevealIn=revCls.indexOf('reveal-in')>=0;const outLink=inspectHome?ttLinkBetween(inspectHome,key):(revealBox?ttLinkBetween(revealBox,key):null);const inLink=inspectHome?ttLinkBetween(key,inspectHome):(revealBox?ttLinkBetween(key,revealBox):null);const revealArrowHovers=(hasRevealOut?''+linkRevealInnerHTML(outLink)+'':'')+(hasRevealIn?''+linkRevealInnerHTML(inLink)+'':'');return `
`;}
function renderTabBar(){/* persistent horizontal page-name/tab strip removed from Normal View; Overview remains the main page-navigation surface. The hidden tabBar element is left in the DOM for compatibility but is intentionally not populated. */var el=document.getElementById('tabBar');if(el)el.innerHTML='';}
function navTo(id){if(document.activeElement&&document.activeElement.isContentEditable)return;
// if an explicit relationship-navigation context (inspectHome) is active and the user is crossing to a different page, create the ghost/context box consistently via the shared helper. This covers border-click reveal continuation across pages and count-affordance cancel/continue cross-page continuation. Ordinary manual tab switching with NO inspect context is unaffected \u2014 this block is gated on inspectHome being set. The heading is the link-inspection heading ('Box on other page linking to highlighted boxes below'); jumps still flow through navFromSourceBox and carry their own 'jump' navType.
if(inspectHome&&id!==currentPage){var _ihSrcPid=getSourcePageId(inspectHome);if(_ihSrcPid&&_ihSrcPid!==id){ensureSourceRefBoxForCrossPage(inspectHome,id,'inspect');}}
// manual navigation away from a 'returned' source-ref context clears the context box \u2014 the return-to-source highlight is not meant to persist across ordinary manual page switching.
if(sourceRefBox&&sourceRefBox.returned&&id!==currentPage){sourceRefBox=null;inspectHome=null;inspectLinkedKeys=[];}
// push current page onto the navigation history stack (and clear the forward stack) when explicitly moving to a different page. Used by the back/forward arrows in the page info bar.
if(id!==currentPage){navHistory.push(currentPage);if(navHistory.length>MAX_NAV_HIST)navHistory.shift();navFuture=[];}
if(currentPage==='overview'&&id!=='overview')overviewReorderMode=false;
currentPage=id;openBox=null;editingType=null;document.getElementById('epWrap').style.display='none';render();}
// back/forward page-navigation arrows. These are browser-style navigation history only and do NOT affect page order. They do not push to navHistory themselves (they only consume/push through the stacks).
function pageOrderIds(){var ids=['overview'];if(SP.length!==1)ids.push('final');SP.forEach(function(s){ids.push(s.id);});return ids;}
function currentPageOrderIndex(){return pageOrderIds().indexOf(currentPage);}
function pageStepNavHTML(){var ids=pageOrderIds();var idx=ids.indexOf(currentPage);var canPrev=idx>0;var canNext=idx>=0&&idx‹ Previous|Next ›'; }
function pageStepNav(ev,dir){if(ev&&ev.stopPropagation)ev.stopPropagation();var ids=pageOrderIds();var idx=ids.indexOf(currentPage);if(idx<0)return;var nextIdx=idx+(dir<0?-1:1);if(nextIdx<0||nextIdx>=ids.length)return;navTo(ids[nextIdx]);}
function navBack(ev){if(ev&&ev.stopPropagation)ev.stopPropagation();if(!navHistory.length)return;const prev=navHistory.pop();navFuture.push(currentPage);if(navFuture.length>MAX_NAV_HIST)navFuture.shift();if(inspectHome&&prev!==currentPage){var _ihSrcPid=getSourcePageId(inspectHome);if(_ihSrcPid&&_ihSrcPid!==prev){ensureSourceRefBoxForCrossPage(inspectHome,prev,'inspect');}}currentPage=prev;openBox=null;editingType=null;var _ep=document.getElementById('epWrap');if(_ep)_ep.style.display='none';render();}
function navForward(ev){if(ev&&ev.stopPropagation)ev.stopPropagation();if(!navFuture.length)return;const nxt=navFuture.pop();navHistory.push(currentPage);if(navHistory.length>MAX_NAV_HIST)navHistory.shift();if(inspectHome&&nxt!==currentPage){var _ihSrcPid=getSourcePageId(inspectHome);if(_ihSrcPid&&_ihSrcPid!==nxt){ensureSourceRefBoxForCrossPage(inspectHome,nxt,'inspect');}}currentPage=nxt;openBox=null;editingType=null;var _ep=document.getElementById('epWrap');if(_ep)_ep.style.display='none';render();}
function tabClick(id){if(_clickTimer){clearTimeout(_clickTimer);_clickTimer=null;}_clickTimer=setTimeout(function(){_clickTimer=null;navTo(id);},250);}
function setOverviewMode(mode,ev){if(ev&&ev.stopPropagation)ev.stopPropagation();overviewMode=(mode==='compact')?'compact':'cards';render();}
function overviewNavTo(ev,id){if(ev&&ev.stopPropagation)ev.stopPropagation();navTo(id);}
function changePageOrderToggle(ev){if(ev&&ev.stopPropagation)ev.stopPropagation();if(IS_READONLY)return;if(overviewReorderMode){overviewReorderMode=false;render();return;}overviewReorderMode=true;if(currentPage!=='overview')navTo('overview');else render();}
function renderOverview(){
var isCompact=overviewMode==='compact';
var reorderActive=overviewReorderMode&&!IS_READONLY;
var ovs=ensureOverviewViewSettings();
var mode='';
var addPill=(IS_READONLY||presentationView)?'':'';
var topActions='
'+mode+addPill+'
';
var plus=IS_READONLY?'':'';
var tiles=SP.map(function(sp,idx){
var meta=overviewCardMeta(sp);
var tileColor=overviewCardColor(sp);
var cue=sp.pageType==='how'?'HOW':sp.pageType==='documentation'?'DOC':'THIS\u2013THEN';
var levelCue=sp.pageType==='how'?(sp.howLevel?'Level '+sp.howLevel:'No level'):'';
var canLeft=idx>0, canRight=idxMove':'';
var cueHtml=(isCompact&&!reorderActive)?'
'+cue+''+(levelCue?''+levelCue+'':'')+'
':'';
if(isCompact){return '
'+escHtml(sp.label)+'
'+cueHtml+''+moveBtn+'
';}
var showTL=ovs.showTrafficLights===true;
var showPri=ovs.showPriorities===true;
var showTags=ovs.showTags===true;
var showColor=ovs.showCardColorControls===true&&!IS_READONLY;
var hasMeta=showTL||showPri||showTags||showColor;
var tlSplit=tlSplitClass(meta.light);
var tlDot=showTL?'':'';
var pri=showPri?'
'+(meta.priority||'')+'
':'';
var tagContent=tagHTMLForElement(meta)||(!IS_READONLY?'Tags':'');
var tagClick=IS_READONLY?'':' onclick="openOverviewCardTags(event,\''+sp.id+'\')" title="Edit tags"';
var tagBlock=showTags?'
'+tagContent+'
':'';
var colorBtn=showColor?'':'';
return '
'+pri+tlDot+'
'+escHtml(sp.label)+'
'+tagBlock+''+colorBtn+moveBtn+'
';
}).join('')+plus;
return '
Final Outcomes
The high-level outcomes — click to explore
'+topActions+'
'+tiles+'
'+(isCompact?'':'
This–Then pages show steps and outcomes in the world. How pages show the actions being taken to change them.
');
}
function renderFinalOutcomes(){const vs=viewSettings.finalOutcomes;const backBtn=backToOverviewButtonHTML();const addBtn=IS_READONLY?'':'';return backBtn+`
';}
function docStartEdit(pid){if(IS_READONLY)return;docEditBackup[pid]=docContent[pid]||'';docEditMode[pid]=true;render();}
function docSaveEdit(pid){if(IS_READONLY)return;docEditMode[pid]=false;delete docEditBackup[pid];saveState();render();}
function docCancelEdit(pid){if(IS_READONLY)return;docContent[pid]=docEditBackup[pid]||'';docEditMode[pid]=false;delete docEditBackup[pid];saveState();render();}
function docExec(cmd,val){document.execCommand(cmd,false,val||null);var el=document.getElementById('docEditor');if(el)el.focus();}
function docExecBlock(tag){if(!tag)return;document.execCommand('formatBlock',false,tag);var el=document.getElementById('docEditor');if(el)el.focus();}
/* docInsertLink previously took whatever the user typed in the prompt() and passed it directly to document.execCommand('createLink'); a user typing JavaScript:alert(1) would have produced a clickable JavaScript: anchor in the saved Documentation Page content. The entered URL is now resolved through the _safeUrl helper and the createLink command is only run when _safeUrl returns a safe URL. Unsafe / blank URLs are rejected with a small alert so the user knows the link was not created. The contenteditable HTML editor's safe display path (linkifyDocHtml, which walks text nodes, escapes, then linkifies) is unchanged. Stored Documentation Page content is not rewritten. */
function docInsertLink(){var url=prompt('Enter URL:');if(url){var safe=_safeUrl(url);if(safe){document.execCommand('createLink',false,safe);var sel=window.getSelection&&window.getSelection();var node=sel&&sel.anchorNode;var a=node&&node.nodeType===1?node:(node?node.parentElement:null);while(a&&a.tagName!=='A')a=a.parentElement;if(a&&a.tagName==='A'){a.setAttribute('target','_blank');a.setAttribute('rel','noopener noreferrer');}}else{try{alert('That URL was not added because it could not be opened safely. Please use an https:// or http:// URL, or a plain domain like example.org.');}catch(_){}}}var el=document.getElementById('docEditor');if(el)el.focus();}
function docSave(pid){if(IS_READONLY)return;var el=document.getElementById('docEditor');if(el){var html=el.innerHTML;html=sanitizeDocHtml(sanitizeDocClones(html));docContent[pid]=html;}saveState();}
function docPaste(e){e.preventDefault();var text=e.clipboardData.getData('text/plain');document.execCommand('insertText',false,text);}
// ─── DOCUMENTATION PAGE CLONES ───
function resolveCloneText(type,key){
if(type==='page_title'){if(key==='final')return 'Final Outcomes';const sp=SP.find(s=>s.id===key);return sp?sp.label:null;}
if(type==='box_title'){const b=B[key];return b?b.label:null;}
if(type==='box_main_text'){const b=B[key];return b?(b.label+': '+(b.detailText||'(no display text)')):null;}
if(type==='measure'){const m=getMeasure(key);return m?measureDisplayId(m)+' '+m.title:null;}
if(type==='eval_question'){const q=getEQ(key);return q?eqDisplayId(q)+' '+q.questionText:null;}
if(type==='link'){const l=ttLinks.find(x=>x.id===key)||howLinks.find(x=>x.id===key);if(!l)return null;const fb=B[l.from],tb=B[l.to];if(!fb||!tb)return null;return (fb.label)+' \u2192 '+(tb.label);}
return null;
}
function cloneTypeLabel(type){
if(type==='page_title')return 'Page title';if(type==='box_title')return 'Box title';if(type==='box_main_text')return 'Box Display Text';if(type==='measure')return 'Measure';if(type==='eval_question')return 'Evaluation Question';if(type==='link')return 'Link';return 'Clone';
}
function sanitizeDocClones(html){
if(!html)return html;
return html.replace(/
]*)>[\s\S]*?<\/div>/g,function(match,attrs){
var typeM=attrs.match(/data-clone-type="([^"]*)"/i);var keyM=attrs.match(/data-clone-key="([^"]*)"/i);
if(!typeM||!keyM||!isSafeDocClone(typeM[1],keyM[1]))return '';
return '';
});
}
function ensureBlankTargetRel(root){if(!root||!root.querySelectorAll)return;try{root.querySelectorAll('a[target="_blank"]').forEach(function(a){var rel=(a.getAttribute('rel')||'').toLowerCase().split(/\s+/).filter(Boolean);if(rel.indexOf('noopener')<0)rel.push('noopener');if(rel.indexOf('noreferrer')<0)rel.push('noreferrer');a.setAttribute('rel',rel.join(' '));});}catch(e){}}
function linkifyDocHtml(html){if(!html)return html;var d=document.createElement('div');d.innerHTML=html;function walkText(node){if(node.nodeType===3){var t=node.textContent;var linked=linkify(t.replace(/&/g,'&').replace(//g,'>'));if(linked!==t.replace(/&/g,'&').replace(//g,'>')){var span=document.createElement('span');span.innerHTML=linked;node.parentNode.replaceChild(span,node);}}else if(node.nodeType===1&&node.tagName!=='A'&&!node.classList.contains('doc-clone')){var children=Array.from(node.childNodes);children.forEach(walkText);}}walkText(d);ensureBlankTargetRel(d);return d.innerHTML;}
function resolveDocClones(html){
if(!html)return html;
return html.replace(/
]*)>[\s\S]*?<\/div>/g,function(match,attrs){
var typeM=attrs.match(/data-clone-type="([^"]*)"/);var keyM=attrs.match(/data-clone-key="([^"]*)"/);
if(!typeM||!keyM)return match;
var type=typeM[1],key=keyM[1];var text=resolveCloneText(type,key);
var label=cloneTypeLabel(type);
if(text===null)return '
'+escHtml(label)+' (missing) [Source deleted]
';
var clickAction=cloneClickAction(type,key);
return '
'+escHtml(label)+' '+escHtml(text)+'
';
});
}
function resolveDocClonesForEdit(html){
if(!html)return html;
return html.replace(/
]*)>[\s\S]*?<\/div>/g,function(match,attrs){
var typeM=attrs.match(/data-clone-type="([^"]*)"/);var keyM=attrs.match(/data-clone-key="([^"]*)"/);
if(!typeM||!keyM)return match;
var type=typeM[1],key=keyM[1];var text=resolveCloneText(type,key);
var label=cloneTypeLabel(type);
if(text===null)return '
';
});
}
function cloneClickAction(type,key){
if(type==='page_title'){return "navTo('"+key+"')";}
if(type==='box_title'||type==='box_main_text'){return "navToBox('"+key+"')";}
if(type==='measure'){return "openMeasureDetail('"+key+"')";}
if(type==='eval_question'){return "openEQDetail('"+key+"')";}
if(type==='link'){return "openLinkDetail('"+key+"')";}
return '';
}
function removeCloneBlock(el){if(IS_READONLY)return;if(!el)return;var pid=currentPage;el.remove();var editor=document.getElementById('docEditor');if(editor){var html=editor.innerHTML;docContent[pid]=sanitizeDocHtml(sanitizeDocClones(html));saveState();}}
var _cloneTargetPage=null;
function openCloneChooser(pid){_cloneTargetPage=pid;document.getElementById('cloneSourceType').value='';document.getElementById('cloneStep2').innerHTML='';document.getElementById('cloneInsertBtn').disabled=true;openModal('cloneChooserModal');}
function updateCloneChooserStep2(){
var type=document.getElementById('cloneSourceType').value;var s2=document.getElementById('cloneStep2');document.getElementById('cloneInsertBtn').disabled=true;
if(!type){s2.innerHTML='';return;}
var html='';
if(type==='page_title'){
html='';
}else if(type==='box_title'||type==='box_main_text'){
html='';
}else if(type==='measure'){
html='';
}else if(type==='eval_question'){
html='';
}else if(type==='link'){
html='';
}
s2.innerHTML=html;cloneKeyChanged();
}
function cloneKeyChanged(){var sel=document.getElementById('cloneSourceKey');document.getElementById('cloneInsertBtn').disabled=!sel||!sel.value;}
function doInsertClone(){if(IS_READONLY)return;
var type=document.getElementById('cloneSourceType').value;
var sel=document.getElementById('cloneSourceKey');
if(!type||!sel||!sel.value)return;
var key=sel.value;
if(key==='__create_new__'){
closeModal('cloneChooserModal');
if(type==='measure'){var title=prompt('New measure title:');if(!title)return;var id='M'+String(measureNextId).padStart(3,'0');measureNextId++;/* assign stable uid to a Measure created via the clone chooser. sourceUid is intentionally NOT set here — doInsertClone creates a brand-new Measure, not a clone-by-reference. */measures.push({id:id,title:title,mainText:'',notes1:'',notes2:'',notes3:'',trafficLight:'',uid:_genUid('measure')});saveState();key=id;}
else if(type==='eval_question'){var qtext=prompt('New evaluation question:');if(!qtext)return;var id='EQ'+String(eqNextId).padStart(3,'0');eqNextId++;/* assign stable uid to an Evaluation Question created via the clone chooser. sourceUid is intentionally NOT set here — doInsertClone creates a brand-new EQ, not a clone-by-reference. */evalQuestions.push({id:id,questionText:qtext,mainText:'',notes1:'',notes2:'',notes3:'',trafficLight:'',uid:_genUid('eq')});saveState();key=id;}
else return;
}else{closeModal('cloneChooserModal');}
var cloneHtml='';
var editor=document.getElementById('docEditor');
if(editor){
editor.focus();
var temp=document.createElement('div');temp.innerHTML=cloneHtml;var cloneEl=temp.firstChild;
var selection=window.getSelection();
if(selection.rangeCount){var range=selection.getRangeAt(0);range.collapse(false);range.insertNode(cloneEl);}else{editor.appendChild(cloneEl);}
var html=editor.innerHTML;docContent[_cloneTargetPage]=sanitizeDocHtml(sanitizeDocClones(html));saveState();render();
}
}
function currentPresentationTitle(){if(currentPage==='overview')return TITLE||'Overview';if(currentPage==='final')return 'Final Outcomes';var sp=SP.find(function(s){return s.id===currentPage;});return sp?(sp.label||'Untitled page'):'Overview';}
function presentationContextActionHTML(){if(currentPage==='overview'){return '';}var sp=SP.find(function(s){return s.id===currentPage;});if(sp&&((sp.pageType||'this_then')==='this_then'))return selectedTTBoxActionsHTML(currentPage);if(sp&&sp.pageType==='how')return selectedHowBoxActionsHTML(currentPage);return '';}
function updatePresentationBar(){var title=document.getElementById('presentationTitle');if(title)title.textContent=currentPresentationTitle();var row=document.getElementById('presentationStatementRow');var st=document.getElementById('presentationStatementText');if(row&&st){var txt=(topRightText||'').trim();var back=presentationBackToOverviewHTML();st.textContent=txt;st.title=txt;st.style.display=txt?'inline':'none';var old=row.querySelector('.presentation-overview-slot');if(old)old.remove();if(back){var slot=document.createElement('span');slot.className='presentation-overview-slot';slot.innerHTML=back;row.insertBefore(slot,row.firstChild);}row.classList.toggle('has-content',!!txt||!!IS_READONLY||!!back);}var left=document.getElementById('presentationLeft');if(!left)return;var backCls=navHistory.length?'pi-move':'pi-move pi-disabled';var fwdCls=navFuture.length?'pi-move':'pi-move pi-disabled';var navBackBtn='←';var navFwdBtn='→';var undoClass=undoStack.length?'pi-undo':'pi-undo pi-disabled';var undoBtn='↩';var viewBtn=currentPageSupportsPageView()?'Page View':'';var stepNav=pageStepNavHTML();left.innerHTML=navBackBtn+navFwdBtn+stepNav+viewBtn+undoBtn+presentationContextActionHTML();}
function clearSelectedBoxContext(){if(!openBox)return;closeEntry();revealBox=null;revealType=null;inspectHome=null;inspectLinkedKeys=[];sourceRefBox=null;render();}
function presentationDeselectBox(){if(!presentationView||!openBox)return;clearSelectedBoxContext();}
function normalDeselectBox(){if(presentationView||!openBox)return;clearSelectedBoxContext();}
/* Normal View genuine empty-canvas deselect preserves the current board scroll position while closing the selected-box details pane. */
function normalDeselectBoxPreserveScroll(){if(presentationView||!openBox)return;var main=document.getElementById('main');var sx=main?main.scrollLeft:0;var sy=main?main.scrollTop:0;clearSelectedBoxContext();if(main){var restore=function(){main.scrollLeft=sx;main.scrollTop=sy;};restore();if(typeof queueMicrotask==='function')queueMicrotask(restore);else setTimeout(restore,0);if(typeof requestAnimationFrame==='function')requestAnimationFrame(restore);}}
function boardClickAway(ev){if(!openBox)return;if(!ev||!ev.target)return;var t=ev.target;if(t.isContentEditable)return;var interactive='#epWrap,.box,.how-box,.fo-box,.box-chooser,.modal-ov,.modal,.ptc-ov,.chat-panel,.chat-fab,button,a,select,input,textarea,[contenteditable=true],.box-jump,.box-jump-tri,.tl-dot,.pri-badge,.tt-aff,.how-aff,.tt-cnt,.tt-cnt-empty,.tt-cnt-hotspot,.how-cnt-bottom,.how-cnt-bottom-empty,.why-cnt-top,.why-cnt-top-empty,.how-how-cnt-bottom,.how-lateral-sq,.tt-lateral-sq,.link-info-btn,.tt-link-abtn,.ttl-item,.view-btn,.pi-pill,.pi-link,.pi-move,.pi-undo,.presentation-bar,.presentation-statement-row,.presentation-btn,.hdr,.page-info-bar,.control-bar,.src-ref-box,.tag-pill,.tag-chip,.meq-under-box,.box-under-block';if(t.closest&&t.closest(interactive))return;var main=document.getElementById('main');if(main&&(t===main||(main.contains&&main.contains(t)))){if(presentationView)presentationDeselectBox();else normalDeselectBoxPreserveScroll();}}
/* Genuine empty-canvas clicks clear selected-box context in Normal View as well as Presentation View; Escape remains Presentation View-only. */
document.addEventListener('keydown',function(ev){if(!presentationView||!openBox)return;if(ev.key!=='Escape')return;var t=ev.target;if(t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.tagName==='SELECT'||t.isContentEditable))return;presentationDeselectBox();});
function applyPresentationView(){var on=!!presentationView;if(document&&document.body){document.body.classList.toggle('presentation-view',on);}updatePresentationBar();}
function setPresentationView(on){presentationView=!!on;applyPresentationView();saveState();render();}
function render(){markRuntimeLinkIndexDirty();getRuntimeLinkIndex();applyPresentationView();renderTabBar();renderPageInfoBar();const b=document.getElementById('board');if(currentPage==='overview')b.innerHTML=renderOverview();else if(currentPage==='final')b.innerHTML=renderFinalOutcomes();else{const sp=SP.find(s=>s.id===currentPage);if(sp&&sp.pageType==='how')b.innerHTML=renderHowPage(currentPage);else if(sp&&sp.pageType==='documentation')b.innerHTML=renderDocPage(currentPage);else b.innerHTML=renderSubpage(currentPage);}updatePresentationBar();updateSaveStateDisplay();if(openBox)renderEntryPanel();else document.getElementById('epWrap').style.display='none';renderSourceRefBox();wireLinkHoverPopups();}
// save-state status display replaces the previous complete/yellow/blockers line. States: Saving... (while an explicit HTML save is actively running, if that path is hit), Last saved: [time] (clean state after a successful HTML save), Not saved since changes (dirty state after any edit since the last HTML save), or Not yet saved (initial state). Kept lightweight and unobtrusive \u2014 no toasts, no banners, no modal warnings are added by this fix. Time format uses local locale short time.
function updateSaveStateDisplay(){var el=document.getElementById('saveStateTxt');if(!el)return;var txt;
// in a read-only copy, the save-state wording must not misleadingly suggest editable unsaved changes. Editing is disabled through the UI, so the indicator shows a plain static label instead of the normal dirty/saved/saving states. No new warning banner or modal is added by this fix \u2014 it stays lightweight.
if(IS_READONLY){el.textContent=' \u00B7 Read-only copy';return;}
if(isSaving){txt=' \u00B7 Saving\u2026';}else if(isDirty){txt=' \u00B7 Not saved since changes';}else if(lastSavedTime){try{var d=new Date(lastSavedTime);var hr=d.getHours();var mn=String(d.getMinutes()).padStart(2,'0');var ampm=hr>=12?'pm':'am';var hr12=hr%12||12;var today=new Date();var sameDay=d.toDateString()===today.toDateString();var whenStr=sameDay?(hr12+':'+mn+ampm):(d.toLocaleDateString()+' '+hr12+':'+mn+ampm);txt=' \u00B7 Last saved: '+whenStr;}catch(e){txt=' \u00B7 Last saved';}}else{txt=' \u00B7 Not yet saved';}el.textContent=txt;}
// kept as a no-op alias so any residual internal callers of updateStatus() remain safe. The complete/yellow/blockers line it used to write is gone by design \u2014 that data is still visible per-box via traffic-light dots, and is still included in the DOVIEW-STATE snapshot summary line and in the board-chat system prompt.
function updateStatus(){updateSaveStateDisplay();}
// ─── ENTRY PANEL ───
function clickBox(ev,key){if(!ev){ev=window.event;}if(ev&&ev.target&&ev.target.isContentEditable)return;
// if the ghost/context box is currently showing and the user clicks a box that is NOT part of the current linked/highlighted navigation context, dismiss it \u2014 it is a temporary navigation-context aid, not a pinned banner. Treated as same context: the source box itself (so the user can click it without losing the banner) and any currently inspectLinkedKeys (so clicking a highlighted linked target does not dismiss). Anything else counts as focus change / new unrelated inspect and clears sourceRefBox and the stale inspect state together so the new click can establish its own reveal/inspect context cleanly. Manual close via the \u2715 control still works via dismissSourceRef.
if(sourceRefBox){var _srcRefKey=sourceRefBox.sourceKey;var _keyInCtx=(key===_srcRefKey)||(Array.isArray(inspectLinkedKeys)&&inspectLinkedKeys.indexOf(key)>=0);if(!_keyInCtx){sourceRefBox=null;inspectHome=null;inspectLinkedKeys=[];}}
if(_clickTimer){clearTimeout(_clickTimer);_clickTimer=null;}const isTT=isTTBox(key);const isHow=isHowBox(key);if(isTT&&ev&&ev.target){const el=document.getElementById('bx-'+key);if(el){const r=el.getBoundingClientRect();const x=ev.clientX-r.left,y=ev.clientY-r.top;
// enlarged, clearly-separated TT reveal-arrow click zone. Middle-left and middle-right regions only. X within 22px of the edge, Y between ~35% and ~60% of the box height, so it stays clearly separate from the top Cross-Link link affordance, the Vertical Link count affordance, the jump triangle zone, and the right-edge outgoing-count circle (each of which absorbs its own clicks via stopPropagation). This check runs BEFORE the border-zone check so a middle-band click in inspect/reveal state reliably opens the link popup instead of entering a new reveal or falling through to the entry panel.
const _arrowX=22;const _arrowYMin=r.height*0.35;const _arrowYMax=r.height*0.60;const _inArrowBand=(y>=_arrowYMin&&y<=_arrowYMax);
if(_inArrowBand){
if(inspectHome&&inspectLinkedKeys.indexOf(key)>=0&&key!==inspectHome){const outLink=ttLinkBetween(inspectHome,key);const inLink=ttLinkBetween(key,inspectHome);if(outLink&&x<_arrowX){ev.stopPropagation();openLinkDetail(outLink.id);return;}if(inLink&&x>r.width-_arrowX){ev.stopPropagation();openLinkDetail(inLink.id);return;}}
if(revealBox&&revealType==='tt'&&key!==revealBox){const outLink=ttLinkBetween(revealBox,key);const inLink=ttLinkBetween(key,revealBox);if(outLink&&x<_arrowX){ev.stopPropagation();openLinkDetail(outLink.id);return;}if(inLink&&x>r.width-_arrowX){ev.stopPropagation();openLinkDetail(inLink.id);return;}}
}
const bz=10;if(xr.width-bz||yr.height-bz){ev.stopPropagation();
// narrow (bz=10) border zone remains the entry point for border-click reveal mode. Arrow-zone openLinkDetail cases already handled above.
// Border click enters inspect mode showing both directions with greyed connected boxes
revealBox=null;revealType=null;inspectHome=key;inspectLinkedKeys=linkedKeysForSource(key);openBox=null;editingType=null;document.getElementById('epWrap').style.display='none';/* border-click activation of inspect mode immediately pins the ghost/context box on the source page with the heading 'Showing links for this box on this page'. */setSourceRefBox(key,'inspect');refreshGhostLinkContext(key);render();return;}}}if(isHow&&ev&&ev.target){const el=document.getElementById('bx-'+key);if(el){const r=el.getBoundingClientRect();const x=ev.clientX-r.left,y=ev.clientY-r.top;const bz=10;if(xr.width-bz||yr.height-bz){ev.stopPropagation();revealBox=null;revealType=null;inspectHome=key;inspectLinkedKeys=linkedKeysForSource(key);openBox=null;editingType=null;document.getElementById('epWrap').style.display='none';setSourceRefBox(key,'inspect');refreshGhostLinkContext(key);render();return;}}}_clickTimer=setTimeout(function(){_clickTimer=null;if(isHow){inspectHome=null;inspectLinkedKeys=[];revealBox=null;revealType=null;sourceRefBox=null;}else{inspectHome=null;inspectLinkedKeys=[];revealBox=null;revealType=null;}if(openBox===key){closeEntry();return;}openBox=key;entryMode='sofar';editingType=null;render();},250);}
function closeEntry(){openBox=null;editingType=null;document.getElementById('epWrap').style.display='none';}
function dblEditBox(event,key){if(IS_READONLY){event.stopPropagation();event.preventDefault();return;}event.stopPropagation();event.preventDefault();if(_clickTimer){clearTimeout(_clickTimer);_clickTimer=null;}const box=B[key];if(!box)return;const el=document.querySelector(`#bx-${key} .box-txt`);if(!el)return;el.style.display='block';el.style.webkitLineClamp='unset';el.style.overflow='visible';el.contentEditable='true';el.focus();const sel=window.getSelection();sel.selectAllChildren(el);el.style.outline='2px solid #F5A623';el.style.borderRadius='3px';el.style.background='#fff';const finish=function(){el.contentEditable='false';el.style.outline='';el.style.background='';el.style.display='';el.style.webkitLineClamp='';el.style.overflow='';const newLabel=el.textContent.trim();if(newLabel!==box.label||newLabel===''){takeSnapshot();box.label=newLabel;const m=parseTTBoxKey(key);if(m){const sp=SP.find(s=>s.id===m.spId);if(sp&&sp.cols[m.ci])sp.cols[m.ci].boxes[m.bi]=newLabel;}const hm=parseHowBoxKey(key);if(hm){const sp=SP.find(s=>s.id===hm.spId);if(sp&&sp.howBoxes){const hb=sp.howBoxes.find(h=>h.id===hm.howId);if(hb)hb.label=newLabel;}}saveState();}render();};el.addEventListener('blur',finish,{once:true});el.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();el.blur();}if(e.key==='Escape'){el.textContent=box.label;el.blur();}});}
function dblEditColH(event,spId,ci){if(IS_READONLY){event.stopPropagation();event.preventDefault();return;}event.stopPropagation();event.preventDefault();const sp=SP.find(s=>s.id===spId);if(!sp||!sp.cols[ci])return;const el=document.getElementById(`ch-${spId}-c${ci}`);if(!el)return;el.contentEditable='true';el.style.outline='2px solid #F5A623';el.style.borderRadius='3px';el.style.background='#fff';el.style.textTransform='none';el.focus();const sel=window.getSelection();sel.selectAllChildren(el);const oldH=sp.cols[ci].h;const finish=function(){el.contentEditable='false';el.style.outline='';el.style.background='';el.style.textTransform='';const newH=el.textContent.trim();if(newH&&newH!==oldH){takeSnapshot();sp.cols[ci].h=newH;saveState();}render();};el.addEventListener('blur',finish,{once:true});el.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();el.blur();}if(e.key==='Escape'){el.textContent=oldH;el.blur();}});}
function confirmDeleteBox(key){if(IS_READONLY)return;const box=B[key];if(!box)return;if(!confirm('Delete this box: "'+box.label+'"?'))return;takeSnapshot();const m=parseTTBoxKey(key);if(m){const spId=m.spId,ci=m.ci,bi=m.bi;const sp=SP.find(s=>s.id===spId);if(sp&&sp.cols[ci]&&sp.cols[ci].boxes[bi]!==undefined){/* Preserve box state and links while positional keys shift. */const oldColLen=sp.cols[ci].boxes.length;const keyMap={};keyMap[`${spId}-c${ci}-b${bi}`]='';for(let j=bi+1;js.id===spId);if(sp&&sp.cols[ci]){sp.cols[ci].boxes.push('New box');const bi=sp.cols[ci].boxes.length-1;const k=`${spId}-c${ci}-b${bi}`;B[k]={label:'New box',light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',/* stable uid at creation. */uid:_genUid('box')};buildDepsMap();cascade();saveState();render();}}
function confirmDeleteColumn(key){if(IS_READONLY)return;const m=parseTTBoxKey(key);if(!m)return;const spId=m.spId,ci=m.ci;const sp=SP.find(s=>s.id===spId);if(!sp||!sp.cols[ci])return;if(!confirm('Delete column "'+sp.cols[ci].h+'" and all its boxes?'))return;takeSnapshot();/* Preserve box state and links while positional keys shift. */const oldColCount=sp.cols.length;const keyMap={};for(let cj=0;cjci){keyMap[oldK]=`${spId}-c${cj-1}-b${bi}`;}}}sp.cols.splice(ci,1);rebuildBoxKeys(spId,keyMap);buildDepsMap();cascade();saveState();closeEntry();render();}
function promptAddColumn(key){if(IS_READONLY)return;const m=parseTTBoxKey(key);if(!m)return;const spId=m.spId,ci=m.ci;takeSnapshot();const sp=SP.find(s=>s.id===spId);if(sp){const newCi=ci+1;/* Preserve box state and links while positional keys shift. */const oldColCount=sp.cols.length;const keyMap={};for(let cj=newCi;cjs.id===spId);if(!sp||!sp.cols[ci])return;takeSnapshot();/* Preserve box state and links while positional keys shift. */const keyMap={};keyMap[`${spId}-c${ci}-b${bi-1}`]=`${spId}-c${ci}-b${bi}`;keyMap[`${spId}-c${ci}-b${bi}`]=`${spId}-c${ci}-b${bi-1}`;const boxes=sp.cols[ci].boxes;const tmp=boxes[bi];boxes[bi]=boxes[bi-1];boxes[bi-1]=tmp;rebuildBoxKeys(spId,keyMap);buildDepsMap();cascade();saveState();openBox=`${spId}-c${ci}-b${bi-1}`;render();}
function moveBoxDown(key){if(IS_READONLY)return;const m=parseTTBoxKey(key);if(!m)return;const spId=m.spId,ci=m.ci,bi=m.bi;const sp=SP.find(s=>s.id===spId);if(!sp||!sp.cols[ci])return;if(bi>=sp.cols[ci].boxes.length-1)return;takeSnapshot();/* Preserve box state and links while positional keys shift. */const keyMap={};keyMap[`${spId}-c${ci}-b${bi}`]=`${spId}-c${ci}-b${bi+1}`;keyMap[`${spId}-c${ci}-b${bi+1}`]=`${spId}-c${ci}-b${bi}`;const boxes=sp.cols[ci].boxes;const tmp=boxes[bi];boxes[bi]=boxes[bi+1];boxes[bi+1]=tmp;rebuildBoxKeys(spId,keyMap);buildDepsMap();cascade();saveState();openBox=`${spId}-c${ci}-b${bi+1}`;render();}
function moveTTBoxHorizontal(key,direction){if(IS_READONLY)return;const m=parseTTBoxKey(key);if(!m)return;const sp=SP.find(s=>s.id===m.spId);if(!sp||!Array.isArray(sp.cols)||!sp.cols[m.ci])return;const targetCi=m.ci+(direction==='left'?-1:direction==='right'?1:0);if(targetCi<0||targetCi>=sp.cols.length||targetCi===m.ci)return;const sourceBoxes=sp.cols[m.ci].boxes||[];const targetBoxes=sp.cols[targetCi].boxes||[];if(m.bi<0||m.bi>=sourceBoxes.length)return;const insertBi=Math.min(m.bi,targetBoxes.length);const keyMap={};keyMap[`${m.spId}-c${m.ci}-b${m.bi}`]=`${m.spId}-c${targetCi}-b${insertBi}`;for(let i=m.bi+1;is.id===spId);if(!sp||sp.pageType!=='how')return;takeSnapshot();if(!sp.howBoxes)sp.howBoxes=[];const num=sp.nextHowNum||(sp.howBoxes.length+1);const id='H'+String(num).padStart(3,'0');sp.nextHowNum=num+1;const label='How (action, person, team, unit, organization, capability, \u2026)';/* stable uid on the new How Box record + its B[k] entry. */const _hbUid=_genUid('box');sp.howBoxes.push({id,label,uid:_hbUid});B[`${spId}-${id}`]={label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',uid:_hbUid}; saveState();render();}
function addHowBoxAfter(spId,howId){if(IS_READONLY)return;const sp=SP.find(s=>s.id===spId);if(!sp||sp.pageType!=='how'||!sp.howBoxes)return;const idx=sp.howBoxes.findIndex(h=>h.id===howId);if(idx<0)return;takeSnapshot();const num=sp.nextHowNum||(sp.howBoxes.length+1);const newId='H'+String(num).padStart(3,'0');sp.nextHowNum=num+1;const label='How (action, person, team, unit, organization, capability, \u2026)';/* stable uid on the new How Box record + its B[k] entry. */const _hbUid=_genUid('box');sp.howBoxes.splice(idx+1,0,{id:newId,label,uid:_hbUid});B[`${spId}-${newId}`]={label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',uid:_hbUid}; saveState();render();}
function deleteHowBox(spId,howId){if(IS_READONLY)return;const sp=SP.find(s=>s.id===spId);if(!sp||!sp.howBoxes)return;const key=`${spId}-${howId}`;if(!confirm('Delete this box: "'+( B[key]?.label||howId)+'"?'))return;takeSnapshot();const idx=sp.howBoxes.findIndex(h=>h.id===howId);if(idx>=0)sp.howBoxes.splice(idx,1);delete B[key];cleanupHowLinks();saveState();closeEntry();render();}
function moveHowBox(spId,howId,direction){if(IS_READONLY)return;const sp=SP.find(s=>s.id===spId);if(!sp||!sp.howBoxes)return;const idx=sp.howBoxes.findIndex(h=>h.id===howId);if(idx<0)return;let newIdx;if(direction==='earlier')newIdx=idx-1;else if(direction==='later')newIdx=idx+1;else if(direction==='first')newIdx=0;else if(direction==='last')newIdx=sp.howBoxes.length-1;else return;if(newIdx<0||newIdx>=sp.howBoxes.length||newIdx===idx)return;takeSnapshot();const item=sp.howBoxes.splice(idx,1)[0];sp.howBoxes.splice(newIdx,0,item);saveState();render();}
function moveHowBoxVertical(spId,howId,direction){if(IS_READONLY||direction!=='up'&&direction!=='down')return;const sp=SP.find(s=>s.id===spId);if(!sp||!Array.isArray(sp.howBoxes))return;const grid=document.querySelector('.how-grid');if(!grid)return;const positions=Array.from(grid.children).map(function(wrap){const box=wrap.querySelector&&wrap.querySelector('.how-box[id^="bx-"]');if(!box)return null;const m=parseHowBoxKey(box.id.slice(3));if(!m||m.spId!==spId)return null;const rect=wrap.getBoundingClientRect();return {howId:m.howId,left:Math.round(rect.left),top:Math.round(rect.top)};}).filter(Boolean);const current=positions.find(function(p){return p.howId===howId;});if(!current)return;const sameColumn=positions.filter(function(p){return Math.abs(p.left-current.left)<=2;}).sort(function(a,b){return a.top-b.top;});const visualIdx=sameColumn.findIndex(function(p){return p.howId===howId;});const targetVisualIdx=visualIdx+(direction==='up'?-1:1);if(visualIdx<0||targetVisualIdx<0||targetVisualIdx>=sameColumn.length)return;const idx=sp.howBoxes.findIndex(function(h){return h.id===howId;});const targetIdx=sp.howBoxes.findIndex(function(h){return h.id===sameColumn[targetVisualIdx].howId;});if(idx<0||targetIdx<0||idx===targetIdx)return;takeSnapshot();const tmp=sp.howBoxes[idx];sp.howBoxes[idx]=sp.howBoxes[targetIdx];sp.howBoxes[targetIdx]=tmp;saveState();render();}
function finalOutcomeIndexFromKey(key){var m=key&&key.match(/^final-b(\d+)$/);if(!m)return -1;var idx=parseInt(m[1]);return idx>=0&&idx=FO_DATA.length)return;takeSnapshot();const tmp=FO_DATA[i];FO_DATA[i]=FO_DATA[i-1];FO_DATA[i-1]=tmp;const keyMap={};keyMap['final-b'+i]='final-b'+(i-1);keyMap['final-b'+(i-1)]='final-b'+i;rebuildFinalKeysWithMap(keyMap);saveState();openBox='final-b'+(i-1);render();}
function moveFinalDown(i){if(IS_READONLY)return;if(i<0||i>=FO_DATA.length-1)return;takeSnapshot();const tmp=FO_DATA[i];FO_DATA[i]=FO_DATA[i+1];FO_DATA[i+1]=tmp;const keyMap={};keyMap['final-b'+i]='final-b'+(i+1);keyMap['final-b'+(i+1)]='final-b'+i;rebuildFinalKeysWithMap(keyMap);saveState();openBox='final-b'+(i+1);render();}
function confirmDeleteFinal(i){if(IS_READONLY)return;if(i<0||i>=FO_DATA.length)return;if(!confirm('Delete final outcome: "'+FO_DATA[i]+'"?'))return;takeSnapshot();var oldLen=FO_DATA.length;var keyMap={};keyMap['final-b'+i]='';for(var j=i+1;j=FO_DATA.length)return;const k='final-b'+i;const el=document.querySelector('#bx-'+k+' .box-txt');if(!el)return;el.contentEditable='true';el.focus();var sel=window.getSelection();sel.selectAllChildren(el);el.style.outline='2px solid #F5A623';el.style.borderRadius='3px';el.style.background='#fff';const oldLabel=FO_DATA[i];const finish=function(){el.contentEditable='false';el.style.outline='';el.style.background='';const newLabel=el.textContent.trim();if(newLabel&&newLabel!==oldLabel){takeSnapshot();FO_DATA[i]=newLabel;if(B[k])B[k].label=newLabel;saveState();}render();};el.addEventListener('blur',finish,{once:true});el.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();el.blur();}if(e.key==='Escape'){el.textContent=oldLabel;el.blur();}});}
function dblEditTab(event,spId){if(IS_READONLY){event.stopPropagation();event.preventDefault();return;}event.stopPropagation();event.preventDefault();if(_clickTimer){clearTimeout(_clickTimer);_clickTimer=null;}const sp=SP.find(function(s){return s.id===spId;});if(!sp)return;var el=document.querySelector('#tabBar [data-spid="'+spId+'"]');if(!el)return;el.contentEditable='true';el.focus();var sel=window.getSelection();sel.selectAllChildren(el);el.style.outline='2px solid #F5A623';el.style.borderRadius='3px';const oldLabel=sp.label;const finish=function(){el.contentEditable='false';el.style.outline='';const newLabel=el.textContent.trim();if(newLabel&&newLabel!==oldLabel){takeSnapshot();sp.label=newLabel;saveState();}render();};el.addEventListener('blur',finish,{once:true});el.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();el.blur();}if(e.key==='Escape'){el.textContent=oldLabel;el.blur();}});}
// inline-edit the page name shown in the top page bar (tt-bar, how-bar, doc-bar). Edits the same underlying sp.label as the tab, so tab and bar stay in sync.
function dblEditPageBarTitle(event,spId){if(IS_READONLY){event.stopPropagation();event.preventDefault();return;}event.stopPropagation();event.preventDefault();if(_clickTimer){clearTimeout(_clickTimer);_clickTimer=null;}const sp=SP.find(function(s){return s.id===spId;});if(!sp)return;var el=event.currentTarget;if(!el)return;el.contentEditable='true';el.focus();var sel=window.getSelection();sel.selectAllChildren(el);el.style.outline='2px solid rgba(255,255,255,0.8)';el.style.background='rgba(255,255,255,0.12)';el.style.borderRadius='3px';el.style.padding='0 4px';const oldLabel=sp.label;const finish=function(){el.contentEditable='false';el.style.outline='';el.style.background='';el.style.padding='';const newLabel=el.textContent.trim();if(newLabel&&newLabel!==oldLabel){takeSnapshot();sp.label=newLabel;saveState();}render();};el.addEventListener('blur',finish,{once:true});el.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();el.blur();}if(e.key==='Escape'){el.textContent=oldLabel;el.blur();}});}
function dblEditTitle(event){if(IS_READONLY){event.stopPropagation();event.preventDefault();return;}event.stopPropagation();event.preventDefault();const el=document.getElementById('hdrTitle');if(!el)return;el.contentEditable='true';el.focus();var sel=window.getSelection();sel.selectAllChildren(el);el.style.outline='2px solid rgba(255,255,255,0.5)';el.style.borderRadius='3px';const oldTitle=TITLE;const finish=function(){el.contentEditable='false';el.style.outline='';const newTitle=el.textContent.trim();if(newTitle&&newTitle!==oldTitle){takeSnapshot();TITLE=newTitle;document.title=TITLE;saveState();}render();};el.addEventListener('blur',finish,{once:true});el.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();el.blur();}if(e.key==='Escape'){el.textContent=oldTitle;el.blur();}});}
function addNewPage(){if(IS_READONLY)return;document.getElementById('pageTypeChooser').classList.add('open');}
function closePageChooser(){document.getElementById('pageTypeChooser').classList.remove('open');}
function closeHowPageTypeChooser(){var el=document.getElementById('howPageTypeChooser');if(el)el.classList.remove('open');}
function normalizedVerticalHowLevel(level){if(level===null||level===undefined)return null;var n=parseInt(level,10);return Number.isFinite(n)&&n>0?n:null;}
function verticalHowPageAtLevel(level,excludeId){var n=normalizedVerticalHowLevel(level);if(n===null)return null;return SP.find(function(s){return s&&s.id!==excludeId&&s.pageType==='how'&&normalizedVerticalHowLevel(s.howLevel)===n;})||null;}
function hasLevelOneHowPage(){return !!verticalHowPageAtLevel(1);}
function nextVerticalHowLevel(){var maxLvl=0;SP.forEach(function(s){var n=s&&s.pageType==='how'?normalizedVerticalHowLevel(s.howLevel):null;if(n!==null&&n>maxLvl)maxLvl=n;});return hasLevelOneHowPage()?Math.max(2,maxLvl+1):1;}
function hasVerticalHowPageLevel(level){return !!verticalHowPageAtLevel(level);}
function hasThisThenPage(){return SP.some(function(s){return s&&(s.pageType||'this_then')==='this_then';});}
function missingPreviousVerticalHowLevel(level){level=parseInt(level,10);if(!level||level<=1)return 0;var previous=level-1;return hasVerticalHowPageLevel(previous)?0:previous;}
function warnMissingPreviousVerticalHowLevel(level){var previous=missingPreviousVerticalHowLevel(level);if(!previous)return false;alert('Create a Level '+previous+' Vertical Link How Page first.');return true;}
function warnDuplicateVerticalHowLevel(level,excludeId){var n=normalizedVerticalHowLevel(level);if(n===null)return false;var existing=verticalHowPageAtLevel(n,excludeId);if(!existing)return false;alert('A Level '+n+' How Page already exists: "'+(existing.label||existing.id)+'". There is only one vertical How Page hierarchy. Use one page per numbered level, or create a Cross-Link How Page.');return true;}
function warnNoSelectedTarget(){alert('Choose a target first.');}
function buildHowLevelOptionsHTML(){var maxLvl=3;SP.forEach(function(s){var n=s&&s.pageType==='how'?normalizedVerticalHowLevel(s.howLevel):null;if(n!==null&&n>=maxLvl)maxLvl=n+1;});var selected=nextVerticalHowLevel();var html='';for(var lvl=1;lvl<=maxLvl;lvl++){var disabled=hasVerticalHowPageLevel(lvl);html+='';}return html;}
function openHowPageTypeChooser(){if(IS_READONLY)return;closePageChooser();var sel=document.getElementById('howPageLevelSelect');if(sel)sel.innerHTML=buildHowLevelOptionsHTML();var notice=document.getElementById('howLevel1Notice');if(notice)notice.style.display=SP.some(function(s){return s&&s.pageType==='how'&&normalizedVerticalHowLevel(s.howLevel)!==null;})?'block':'none';var el=document.getElementById('howPageTypeChooser');if(el)el.classList.add('open');}
function confirmCreateVerticalHowPage(){var sel=document.getElementById('howPageLevelSelect');var lvl=sel?parseInt(sel.value,10):nextVerticalHowLevel();if(warnDuplicateVerticalHowLevel(lvl)){openHowPageTypeChooser();return;}if(warnMissingPreviousVerticalHowLevel(lvl))return;closeHowPageTypeChooser();createPageWithType('how',{howLevel:lvl,label:'Vertical Link How Page'});}
function confirmCreateCrossLinkHowPage(){closeHowPageTypeChooser();createPageWithType('how',{howLevel:null,label:'Cross-Link How Page'});}
function createPageWithType(pageType,opts){if(IS_READONLY)return;opts=opts||{};closePageChooser();const wasSingle=SP.length===1;const avail=nextAutoPageColor();const id=nextUnusedPageId();if(pageType==='how'){var hasExplicitLevel=Object.prototype.hasOwnProperty.call(opts,'howLevel');var requestedLevel=hasExplicitLevel?opts.howLevel:nextVerticalHowLevel();var howLevel=requestedLevel===null?null:normalizedVerticalHowLevel(requestedLevel);if(howLevel===null&&requestedLevel!==null)howLevel=nextVerticalHowLevel();if(warnDuplicateVerticalHowLevel(howLevel))return;if(warnMissingPreviousVerticalHowLevel(howLevel))return;takeSnapshot();const howBoxes=[];for(let i=1;i<=8;i++){const hid='H'+String(i).padStart(3,'0');howBoxes.push({id:hid,label:'How (action, person, team, unit, organization, capability, \u2026)'});}SP.push({id:id,label:opts.label||'How: New page',pageType:'how',color:{bg:avail.bg,bdr:avail.bdr,tab:avail.tab},cols:[],howBoxes:howBoxes,nextHowNum:9,howLevel:howLevel});howBoxes.forEach(hb=>{B[`${id}-${hb.id}`]={label:hb.label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:''};});}else{takeSnapshot();SP.push({id:id,label:(pageType==='documentation'?'Documentation: New page':'This\u2192Then: New page'),pageType:pageType,color:{bg:avail.bg,bdr:avail.bdr,tab:avail.tab},cols:[{h:'New column',boxes:['New box']}]});const k=id+'-c0-b0';B[k]={label:'New box',light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:''};}if(wasSingle){currentPage='overview';}buildDepsMap();saveState();navTo(id);}
function movePageLeft(ev,pid){if(ev&&ev.stopPropagation)ev.stopPropagation();if(IS_READONLY)return;var idx=SP.findIndex(function(s){return s.id===pid;});if(idx<=0)return;takeSnapshot();var tmp=SP[idx];SP[idx]=SP[idx-1];SP[idx-1]=tmp;saveState();render();}
function movePageRight(ev,pid){if(ev&&ev.stopPropagation)ev.stopPropagation();if(IS_READONLY)return;var idx=SP.findIndex(function(s){return s.id===pid;});if(idx<0||idx>=SP.length-1)return;takeSnapshot();var tmp=SP[idx];SP[idx]=SP[idx+1];SP[idx+1]=tmp;saveState();render();}
function toggleHowLevel(pid){if(IS_READONLY)return;var sp=SP.find(function(s){return s.id===pid;});if(!sp||sp.pageType!=='how')return;takeSnapshot();if(sp.howLevel){sp.howLevel=null;}else{var maxLvl=0;SP.forEach(function(s){if(s.pageType==='how'&&s.howLevel&&s.howLevel>maxLvl)maxLvl=s.howLevel;});sp.howLevel=maxLvl+1;}saveState();render();}
function moveHowLevelUp(pid){if(IS_READONLY)return;var sp=SP.find(function(s){return s.id===pid;});if(!sp||sp.pageType!=='how')return;var lvl=sp.howLevel||1;if(lvl<=1)return;var swap=SP.find(function(s){return s.pageType==='how'&&s.howLevel===lvl-1;});takeSnapshot();sp.howLevel=lvl-1;if(swap)swap.howLevel=lvl;saveState();render();}
function moveHowLevelDown(pid){if(IS_READONLY)return;var sp=SP.find(function(s){return s.id===pid;});if(!sp||sp.pageType!=='how')return;var lvl=sp.howLevel||1;var howPages=SP.filter(function(s){return s.pageType==='how';});if(lvl>=howPages.length)return;var swap=SP.find(function(s){return s.pageType==='how'&&s.howLevel===lvl+1;});takeSnapshot();sp.howLevel=lvl+1;if(swap)swap.howLevel=lvl;saveState();render();}
function confirmDeletePage(pid){if(IS_READONLY)return;var sp=SP.find(function(s){return s.id===pid;});if(!sp)return;if(!confirm('Delete page "'+sp.label+'" and all its boxes?'))return;takeSnapshot();Object.keys(B).filter(function(k){return k.startsWith(pid+'-');}).forEach(function(k){delete B[k];});var idx=SP.findIndex(function(s){return s.id===pid;});SP.splice(idx,1);delete pageInfo[pid];delete docContent[pid];cleanupTTLinks();cleanupHowLinks();buildDepsMap();cascade();saveState();if(SP.length===1){currentPage=SP[0].id;render();}else{navTo('overview');}}
function renderEntryPanel(){
const key=openBox;if(!key||!B[key])return;const box=B[key];if(!Array.isArray(box.entries))box.entries=[];const ep=document.getElementById('epWrap');ep.style.display='block';
if(!['note1','note2','note3','note4','note5'].includes(entryMode))entryMode='note1';
const tls=['','green','greenYellow','yellow','yellowRed','red','grey'].map(t=>{const state=normalizeTrafficLightValue(t);const split=tlSplitClass(state);const active=normalizeTrafficLightValue(box.light)===state?' dot-active':'';const cls='tl-dot-btn'+(state?'':' tl-empty')+active+(split?' '+split:'');const bg=state?tlColor(state):'#fff';return ``;}).join('');
const curPri=normalizePriorityValue(box.priority);
const pris=PRIORITY_VALUES.map(p=>``).join('');
function vals(types){return box.entries.filter(e=>types.indexOf(e.type)>=0).map(e=>e.text).filter(Boolean);}
function row(label,types,mode){const arr=vals(types);if(!arr.length)return '';return `
`;
}
async function setMode(mode){if(IS_READONLY)return;editingType=null;const ta=document.getElementById('epTa');const ct=ta?ta.value.trim():'';if(ct){await doSaveEntry(ct,mode);if(mode===entryMode){ta.value='';renderEntryPanel();return;}}entryMode=mode;renderEntryPanel();setTimeout(()=>{const t=document.getElementById('epTa');if(t)t.focus();},50);}
function editEntries(type){const typeMap={note1:['note1','sofar'],note2:['note2','note'],note3:['note3'],note4:['note4'],note5:['note5']};const types=typeMap[type]||[type];editingType=type;entryMode=type;const box=B[openBox];const entries=box.entries.filter(e=>types.indexOf(e.type)>=0).map(e=>e.text);renderEntryPanel();setTimeout(()=>{const ta=document.getElementById('epTa');if(ta){ta.value=entries.join(' | ');ta.focus();}},50);}
// snapshot prior state BEFORE applying a traffic-light change so undo reliably restores the previous value. No-op (and no snapshot) if the value is unchanged.
function setTL(key,state){if(IS_READONLY)return;if(!B[key])return;const next=normalizeTrafficLightValue(state);if(normalizeTrafficLightValue(B[key].light)===next){closeBoxChooser();return;}takeSnapshot();B[key].light=next;cascade();saveState();render();closeBoxChooser();}
/* apply a preset Border color to a box from the details-pane Border color control. Empty string clears the custom border. Snapshot taken before applying so undo restores the previous value. No-op (and no snapshot) if the value is unchanged. The borderColor field is already plumbed through buildInitialState, saveState, boxHTML, howBoxHTML, rebuildBoxKeys, rebuildFinalKeys, and the AI chat state, so this is purely a UI write into existing state. */
function setBoxBorder(key,color){if(IS_READONLY)return;if(!B[key])return;const newColor=sanitizeOptionalColorValue(color);if((sanitizeOptionalColorValue(B[key].borderColor)||'')===newColor)return;takeSnapshot();B[key].borderColor=newColor;saveState();render();}
// snapshot prior state BEFORE applying a priority toggle so undo reliably restores the previous value. No-op (and no snapshot) if the resulting value is unchanged.
function setBoxFillColor(key,color){if(IS_READONLY)return;if(!B[key])return;const newColor=sanitizeOptionalColorValue(color);if((sanitizeOptionalColorValue(B[key].boxColor)||'')===newColor)return;takeSnapshot();B[key].boxColor=newColor;saveState();render();}
function getBoxFillColorPresets(){return[{key:'',label:'Default'}].concat(COL_COLORS.map(function(c,i){return{key:c.bg,label:'Standard soft fill '+(i+1)};}));}
function getBorderColorPresets(){return[{key:'',label:'Default'},{key:'#d97706',label:'Orange'},{key:'#2563eb',label:'Blue'},{key:'#16a34a',label:'Green'},{key:'#ca8a04',label:'Yellow'},{key:'#7c3aed',label:'Purple'},{key:'#dc2626',label:'Red'},{key:'#475569',label:'Slate'}];}
function openColorPalette(ev,key,kind){if(ev){ev.stopPropagation();ev.preventDefault();}closeBoxChooser();var box=B[key];if(!box)return;var anchor=ev&&ev.currentTarget?ev.currentTarget:null;if(!anchor)return;var mainEl=document.getElementById('main');if(!mainEl)return;var div=document.createElement('div');div.id='boxChooser';div.className='box-chooser';var presets=(kind==='box'?getBoxFillColorPresets():getBorderColorPresets());var cur=(kind==='box'?(box.boxColor||''):(box.borderColor||'')).toLowerCase();var opts=presets.map(function(p){var act=(p.key||'').toLowerCase()===cur?' opt-active':'';var sw=p.key?'':'';var fn=kind==='box'?'setBoxFillColor':'setBoxBorder';return '';}).join('');div.innerHTML='
'+(kind==='box'?'Box color':'Border color')+'
'+opts+'
';mainEl.appendChild(div);positionBoxChooser(div,anchor);}
function setOverviewTL(pid,state){if(IS_READONLY)return;var sp=findPageById(pid);if(!sp)return;var meta=ensureOverviewCardMeta(sp);if(meta.light===state){closeBoxChooser();return;}takeSnapshot();meta.light=state;saveState();render();closeBoxChooser();}
function setOverviewPriExact(pid,pri){if(IS_READONLY)return;var sp=findPageById(pid);if(!sp)return;var meta=ensureOverviewCardMeta(sp);var next=normalizePriorityValue(pri);if(meta.priority===next){closeBoxChooser();return;}takeSnapshot();meta.priority=next;saveState();render();closeBoxChooser();}
function setOverviewCardFillColor(pid,color){if(IS_READONLY)return;var sp=findPageById(pid);if(!sp)return;var meta=ensureOverviewCardMeta(sp);var next=sanitizeOptionalColorValue(color);if((sanitizeOptionalColorValue(meta.boxColor)||'')===next)return;takeSnapshot();meta.boxColor=next;saveState();render();closeBoxChooser();}
function openOverviewTLChooser(ev,pid){if(IS_READONLY){if(ev){ev.stopPropagation();ev.preventDefault();}return;}if(ev){ev.stopPropagation();ev.preventDefault();}closeBoxChooser();var sp=findPageById(pid);if(!sp)return;var meta=ensureOverviewCardMeta(sp);var anchor=ev&&ev.currentTarget?ev.currentTarget:document.getElementById('ov-tl-'+pid);if(!anchor)return;var mainEl=document.getElementById('main');if(!mainEl)return;var div=document.createElement('div');div.id='boxChooser';div.className='box-chooser';var states=['green','greenYellow','yellow','yellowRed','red','grey'];var optsHtml=states.map(function(t){var act=meta.light===t?' opt-active':'';var split=tlSplitClass(t);var cls='tl-opt'+act+(split?' '+split:'');return '';}).join('');div.innerHTML='
';mainEl.appendChild(div);positionBoxChooser(div,anchor);}
function openOverviewCardTags(ev,pid){if(ev){ev.stopPropagation();ev.preventDefault();}closeBoxChooser();if(IS_READONLY)return;var sp=findPageById(pid);if(!sp)return;var meta=ensureOverviewCardMeta(sp);var anchor=ev&&ev.currentTarget?ev.currentTarget:null;if(!anchor)return;var mainEl=document.getElementById('main');if(!mainEl)return;var ids=cleanTagIds(meta.tagIds||[]);meta.tagIds=ids;var cur=ids.length?ids.map(function(tid){var t=getTag(tid);if(!t)return '';var sl=normalizeTagShortLabel(t.shortLabel||'');return ''+escHtml(sl)+' ✕';}).join(' '):'No tags on this card';var pickerId='overviewTagPicker_'+String(pid).replace(/[^A-Za-z0-9_]/g,'_');var opts='';(tags||[]).forEach(function(t){if(!ids.includes(t.id)){opts+='';}});var div=document.createElement('div');div.id='boxChooser';div.className='box-chooser';div.innerHTML='
Tags
'+cur+'
Manage tags
'+buildInlineCreateTag('overviewPage',pid,pickerId,'Create and apply')+'
';mainEl.appendChild(div);positionBoxChooser(div,anchor);}
function refreshOverviewCardTagPicker(pid,pickerId){var sel=document.getElementById(pickerId);if(!sel)return;var sp=findPageById(pid);if(!sp)return;var meta=ensureOverviewCardMeta(sp);var ids=cleanTagIds(meta.tagIds||[]);var cur=sel.value;var opts='';(tags||[]).forEach(function(t){if(!ids.includes(t.id)||t.id===cur)opts+='';});sel.innerHTML=opts;if(cur)sel.value=cur;}
function addOverviewCardTagFromPicker(ev,pid,pickerId){if(ev){ev.stopPropagation();ev.preventDefault();}if(IS_READONLY)return;refreshOverviewCardTagPicker(pid,pickerId);var sel=document.getElementById(pickerId);var sp=findPageById(pid);if(!sel||!sel.value||!sp)return;var meta=ensureOverviewCardMeta(sp);if(!meta.tagIds.includes(sel.value)){takeSnapshot();meta.tagIds.push(sel.value);saveState();}render();closeBoxChooser();}
function removeOverviewCardTag(ev,pid,tagId){if(ev){ev.stopPropagation();ev.preventDefault();}if(IS_READONLY)return;var sp=findPageById(pid);if(!sp)return;var meta=ensureOverviewCardMeta(sp);if(!Array.isArray(meta.tagIds))return;if(meta.tagIds.indexOf(tagId)<0)return;takeSnapshot();meta.tagIds=meta.tagIds.filter(function(x){return x!==tagId;});saveState();render();closeBoxChooser();}
function getDoViewColorPresets(){return [{key:'',label:'Default'},{key:'#F5A623',label:'Orange'},{key:'#2563eb',label:'Blue'},{key:'#16a34a',label:'Green'},{key:'#FFFF00',label:'Yellow'},{key:'#dc2626',label:'Red'},{key:'#9333ea',label:'Purple'},{key:'#64748b',label:'Grey'}];}
function colorSwatchHTML(color){var safe=sanitizeOptionalColorValue(color);return safe?'':'';}
function setPri(key,pri){if(IS_READONLY)return;if(!B[key])return;const target=normalizePriorityValue(pri);const cur=normalizePriorityValue(B[key].priority);const newPri=cur===target?'':target;if(cur===newPri){closeBoxChooser();return;}takeSnapshot();B[key].priority=newPri;saveState();render();closeBoxChooser();}
// set priority to an exact value (no toggle) \u2014 used by the on-box priority chooser. Empty pri clears it. Now snapshots before applying so undo reliably restores the previous priority.
function setPriExact(key,pri){if(IS_READONLY)return;if(!B[key])return;const newPri=normalizePriorityValue(pri);if(normalizePriorityValue(B[key].priority)===newPri){closeBoxChooser();return;}takeSnapshot();B[key].priority=newPri;saveState();render();closeBoxChooser();}
// on-box chooser popups for traffic light and priority. Anchored to the affordance, dismissed on outside click. Choosing a value applies it immediately. The existing details-pane editing of these fields still works as an alternative.
function closeBoxChooser(){var existing=document.getElementById('boxChooser');if(existing)existing.remove();}
function positionBoxChooser(div,anchorEl){var ar=anchorEl.getBoundingClientRect();var mainEl=document.getElementById('main');var mr=mainEl?mainEl.getBoundingClientRect():{left:0,top:0};var scale=parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--board-scale'))||1;var leftPx=(ar.left-mr.left)/scale+18;var topPx=(ar.top-mr.top)/scale+18;div.style.position='absolute';div.style.left=leftPx+'px';div.style.top=topPx+'px';}
function openTLChooser(ev,key){if(IS_READONLY){if(ev){ev.stopPropagation();ev.preventDefault();}return;}if(ev){ev.stopPropagation();ev.preventDefault();}closeBoxChooser();var box=B[key];if(!box)return;var anchor=ev&&ev.currentTarget?ev.currentTarget:document.getElementById('tl-'+key);if(!anchor)return;var mainEl=document.getElementById('main');if(!mainEl)return;var div=document.createElement('div');div.id='boxChooser';div.className='box-chooser';/* on-box chooser offers the standard DoView states plus a true unset option. Grey remains a deliberate selected value, not the unset state. */var cur=normalizeTrafficLightValue(box.light);var states=['','green','greenYellow','yellow','yellowRed','red','grey'];var optsHtml=states.map(function(t){var state=normalizeTrafficLightValue(t);var act=cur===state?' opt-active':'';var split=tlSplitClass(state);var cls='tl-opt'+(state?'':' tl-empty')+act+(split?' '+split:'');var bg=state?tlColor(state):'#fff';return '';}).join('');div.innerHTML='
';mainEl.appendChild(div);positionBoxChooser(div,anchor);}
async function saveEntry(){if(IS_READONLY)return;const ta=document.getElementById('epTa');if(!ta)return;const text=ta.value.trim();if(!text)return;await doSaveEntry(text,entryMode);ta.value='';editingType=null;renderEntryPanel();}
async function doSaveEntry(text,mode){const key=openBox;if(!key||!B[key])return;const box=B[key];if(!Array.isArray(box.entries))box.entries=[];const typeMap={note1:'note1',note2:'note2',note3:'note3',note4:'note4',note5:'note5'};const entryType=typeMap[mode]||'note1';const parts=text.split('|').map(s=>s.trim()).filter(Boolean);if(editingType===entryType||editingType===mode){var removeTypes=[entryType];if(entryType==='note1')removeTypes.push('sofar');if(entryType==='note2')removeTypes.push('note');box.entries=box.entries.filter(e=>removeTypes.indexOf(e.type)<0);}parts.forEach(p=>box.entries.push({type:entryType,text:p}));editingType=null;saveState();render();}
// ─── CLASSIFY ───
async function classifyText(text){const lo=text.toLowerCase().trim();if(/\b(not yet|later|leave|defer|hold off|come back|closer to|will do later|for later|at the moment|not sure|unsure|don't know yet|check later|wait|postpone|pending|tbd|yellow|keep yellow|stay yellow|make yellow|leave yellow|leave this|do this later|haven't done|have not done|can't do yet|cannot do yet)\b/.test(lo))return 'defer';if(/\b(problem|issue|blocked|blocker|can't|cannot|won't work|stuck|risk|danger|concern|obstacle|stopping|prevents|failed|fail|broken|error)\b/.test(lo))return 'blocker';return 'green';}
// ─── UNDO ───
function takeSnapshot(){const snap={B:JSON.parse(JSON.stringify(B)),SP:JSON.parse(JSON.stringify(SP)),FO:JSON.parse(JSON.stringify(FO_DATA)),DEPS:JSON.parse(JSON.stringify(DEPS)),ttLinks:JSON.parse(JSON.stringify(ttLinks)),ttLinkNextId:ttLinkNextId,howLinks:JSON.parse(JSON.stringify(howLinks)),howLinkNextId:howLinkNextId,measures:JSON.parse(JSON.stringify(measures)),measureNextId:measureNextId,evalQuestions:JSON.parse(JSON.stringify(evalQuestions)),eqNextId:eqNextId,boardInfo:boardInfo,pageInfo:JSON.parse(JSON.stringify(pageInfo)),docContent:sanitizedDocContentState()};undoStack.push(snap);if(undoStack.length>MAX_UNDO)undoStack.shift();updateUndoBtn();}
// undo is a UI action, not a conversational event \u2014 no chat message is written. The board change itself is enough feedback.
function doUndo(){if(IS_READONLY)return;if(!undoStack.length)return;const snap=undoStack.pop();B=snap.B;SP.length=0;snap.SP.forEach(s=>SP.push(s));FO_DATA.length=0;normalizedFinalOutcomes(snap.FO).forEach(f=>FO_DATA.push(f));normalizeFinalOutcomesInMemory();DEPS=snap.DEPS;if(snap.ttLinks){ttLinks=snap.ttLinks;ttLinkNextId=snap.ttLinkNextId;}if(snap.howLinks){howLinks=snap.howLinks;howLinkNextId=snap.howLinkNextId;}if(snap.measures){measures=snap.measures;measureNextId=snap.measureNextId;}if(snap.evalQuestions){evalQuestions=snap.evalQuestions;eqNextId=snap.eqNextId;}if(snap.boardInfo!==undefined)boardInfo=snap.boardInfo;if(snap.pageInfo)pageInfo=snap.pageInfo;if(snap.docContent)docContent=sanitizeDocContentObject(snap.docContent);markRuntimeLinkIndexDirty();cascade();saveState();render();updateUndoBtn();}
function updateUndoBtn(){const btn=document.getElementById('undoBtn');if(btn)btn.style.display=undoStack.length?'flex':'none';}
// ─── COMMAND PARSER — CRITICAL: Do not modify, replace, or "improve" this function ───
function parseActions(text){const actions=[];const clean=text.replace(/\[ACTION:(.*?)\]/g,(_,m)=>{actions.push(m.split(':'));return '';});return {clean:clean.trim(),actions};}
function execAction(parts){const cmd=parts[0];
if(cmd==='setLight'&&parts.length>=3){const k=parts[1],c=normalizeTrafficLightValue(parts[2]);if(B[k])B[k].light=c;cascade();saveState();render();}
else if(cmd==='setAllLights'&&parts.length>=2){const c=normalizeTrafficLightValue(parts[1]);Object.keys(B).forEach(k=>B[k].light=c);cascade();saveState();render();}
else if(cmd==='setSubpageLights'&&parts.length>=3){const spId=parts[1],c=normalizeTrafficLightValue(parts[2]);Object.keys(B).filter(k=>k.startsWith(spId+'-')).forEach(k=>B[k].light=c);cascade();saveState();render();}
else if(cmd==='addSofar'&&parts.length>=3){const k=parts[1],txt=parts.slice(2).join(':');if(B[k])B[k].entries.push({type:'sofar',text:txt});saveState();render();}
else if(cmd==='addNote'&&parts.length>=3){const k=parts[1],txt=parts.slice(2).join(':');if(B[k])B[k].entries.push({type:'note',text:txt});saveState();render();}
else if(cmd==='setPriority'&&parts.length>=3){const k=parts[1],p=normalizePriorityValue(parts[2]);if(B[k])B[k].priority=p;saveState();render();}
else if(cmd==='clearEntries'&&parts.length>=3){const k=parts[1],t=parts[2];if(B[k]){if(t==='all')B[k].entries=[];else B[k].entries=B[k].entries.filter(e=>e.type!==t);}saveState();render();}
else if(cmd==='addNote1'&&parts.length>=3){const k=parts[1],txt=parts.slice(2).join(':');if(B[k])B[k].entries.push({type:'note1',text:txt});saveState();render();}
else if(cmd==='addNote2'&&parts.length>=3){const k=parts[1],txt=parts.slice(2).join(':');if(B[k])B[k].entries.push({type:'note2',text:txt});saveState();render();}
else if(cmd==='addNote3'&&parts.length>=3){const k=parts[1],txt=parts.slice(2).join(':');if(B[k])B[k].entries.push({type:'note3',text:txt});saveState();render();}
else if(cmd==='setDetail'&&parts.length>=3){const k=parts[1],txt=parts.slice(2).join(':');if(B[k])B[k].detailText=txt;saveState();render();}
else if(cmd==='clearDetail'&&parts.length>=2){const k=parts[1];if(B[k])B[k].detailText='';saveState();render();}
else if(cmd==='setBoxBorder'&&parts.length>=3){const k=parts[1],c=sanitizeOptionalColorValue(parts[2]);if(B[k])B[k].borderColor=c;saveState();render();}
else if(cmd==='clearBoxBorder'&&parts.length>=2){const k=parts[1];if(B[k])B[k].borderColor='';saveState();render();}
else if(cmd==='setSubpageBorders'&&parts.length>=3){const spId=parts[1],c=sanitizeOptionalColorValue(parts[2]);Object.keys(B).filter(k=>k.startsWith(spId+'-')).forEach(k=>B[k].borderColor=c);saveState();render();}
else if(cmd==='clearSubpageBorders'&&parts.length>=2){const spId=parts[1];Object.keys(B).filter(k=>k.startsWith(spId+'-')).forEach(k=>B[k].borderColor='');saveState();render();}
else if(cmd==='setBoardInfo'&&parts.length>=2){boardInfo=parts.slice(1).join(':');saveState();}
else if(cmd==='setPageInfo'&&parts.length>=3){const pid=parts[1];const txt=parts.slice(2).join(':');pageInfo[pid]=txt;saveState();}
else if(cmd==='addBox'&&parts.length>=4){const spId=parts[1],ci=parseInt(parts[2]),label=parts.slice(3).join(':');const sp=SP.find(s=>s.id===spId);if(sp&&sp.cols[ci]){sp.cols[ci].boxes.push(label);const bi=sp.cols[ci].boxes.length-1;const k=`${spId}-c${ci}-b${bi}`;/* stable uid at creation. */B[k]={label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',tagIds:[],uid:_genUid('box')};buildDepsMap();cascade();saveState();render();}}
else if(cmd==='removeBox'&&parts.length>=2){const k=parts[1];const m=k.match(/^(p\d+)-c(\d+)-b(\d+)$/);if(m){const spId=m[1],ci=parseInt(m[2]),bi=parseInt(m[3]);const sp=SP.find(s=>s.id===spId);if(sp&&sp.cols[ci]&&sp.cols[ci].boxes[bi]!==undefined){/* Preserve box state and links while positional keys shift. */const oldColLen=sp.cols[ci].boxes.length;const keyMap={};keyMap[`${spId}-c${ci}-b${bi}`]='';for(let j=bi+1;j=3){const k=parts[1],nl=parts.slice(2).join(':');if(B[k]){B[k].label=nl;const m=k.match(/^(p\d+)-c(\d+)-b(\d+)$/);if(m){const sp=SP.find(s=>s.id===m[1]);if(sp&&sp.cols[parseInt(m[2])])sp.cols[parseInt(m[2])].boxes[parseInt(m[3])]=nl;}saveState();render();}}
else if(cmd==='addColumn'&&parts.length>=4){const spId=parts[1],ci=parseInt(parts[2]),h=parts.slice(3).join(':');const sp=SP.find(s=>s.id===spId);if(sp){/* Preserve box state and links while positional keys shift. */const oldColCount=sp.cols.length;const keyMap={};for(let cj=ci;cj=3){const spId=parts[1],ci=parseInt(parts[2]);const sp=SP.find(s=>s.id===spId);if(sp&&sp.cols[ci]){/* Preserve box state and links while positional keys shift. */const oldColCount=sp.cols.length;const keyMap={};for(let cj=0;cjci){keyMap[oldK]=`${spId}-c${cj-1}-b${bi}`;}}}sp.cols.splice(ci,1);rebuildBoxKeys(spId,keyMap);buildDepsMap();cascade();saveState();render();}}
else if(cmd==='renameColumn'&&parts.length>=4){const spId=parts[1],ci=parseInt(parts[2]),nh=parts.slice(3).join(':');const sp=SP.find(s=>s.id===spId);if(sp&&sp.cols[ci]){sp.cols[ci].h=nh;saveState();render();}}
else if(cmd==='moveColumn'&&parts.length>=4){const spId=parts[1],fromCi=parseInt(parts[2]),toCi=parseInt(parts[3]);const sp=SP.find(s=>s.id===spId);if(sp&&sp.cols[fromCi]&&toCi>=0&&toCi<=sp.cols.length-1&&fromCi!==toCi){/* Preserve box state and links while positional keys shift. */const oldColCount=sp.cols.length;const keyMap={};for(let cj=0;cjfromCi&&cj<=toCi){newCj=cj-1;}else if(fromCi>toCi&&cj>=toCi&&cj=2){const wasSingle=SP.length===1;const label=parts[1];let pType='this_then';if(parts.length>=3&&!parts[2].startsWith('#')&&['this_then','how','documentation'].includes(parts[2])){pType=parts[2];}let bg,bdr,tab;const colorStart=pType!=='this_then'?3:2;if(parts.length>=colorStart+3){const avail=nextAutoPageColor();bg=sanitizeColorValue(parts[colorStart],avail.bg);bdr=sanitizeColorValue(parts[colorStart+1],avail.bdr);tab=sanitizeColorValue(parts[colorStart+2],avail.tab);}else{const avail=nextAutoPageColor();bg=avail.bg;bdr=avail.bdr;tab=avail.tab;}const id=nextUnusedPageId();/* stable uid on the new page. */const _pageUid=_genUid('page');if(pType==='how'){const howBoxes=[];for(let i=1;i<=8;i++){const hid='H'+String(i).padStart(3,'0');/* stable uid on each starter How Box record. */howBoxes.push({id:hid,label:'How (action, person, team, unit, organization, capability, \u2026)',uid:_genUid('box')});}SP.push({id,label,pageType:'how',color:{bg,bdr,tab},cols:[],howBoxes,nextHowNum:9,howLevel:nextVerticalHowLevel(),uid:_pageUid});howBoxes.forEach(hb=>{B[`${id}-${hb.id}`]={label:hb.label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',tagIds:[],uid:hb.uid};});}else{/* stable uid on the default first column of a new this_then / documentation page. */SP.push({id,label,pageType:pType,color:{bg,bdr,tab},cols:[{h:'First Step',boxes:[],uid:_genUid('col')}],uid:_pageUid});}if(wasSingle){addChatMsg('system','This is now a multi-page board. The overview page has been added and the original page now uses a single color scheme to match the overview tiles.');currentPage='overview';}buildDepsMap();saveState();render();}
else if(cmd==='removeSubpage'&&parts.length>=2){const spId=parts[1];const idx=SP.findIndex(s=>s.id===spId);if(idx>=0){Object.keys(B).filter(k=>k.startsWith(spId+'-')).forEach(k=>delete B[k]);SP.splice(idx,1);delete pageInfo[spId];delete docContent[spId];cleanupTTLinks();cleanupHowLinks();buildDepsMap();cascade();saveState();if(SP.length===1){addChatMsg('system','This is now a single-page board. The overview page has been removed and columns now use different colors.');currentPage=SP[0].id;render();}else{render();if(currentPage===spId)navTo('overview');}}}
else if(cmd==='addFinalOutcome'&&parts.length>=2){const label=parts.slice(1).join(':');FO_DATA.push(label);const i=FO_DATA.length-1;/* stable uid on the new Final Outcome box. */B[`final-b${i}`]={label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',measures:[],evalQuestions:[],tagIds:[],uid:_genUid('final')};saveState();render();}
else if(cmd==='removeFinalOutcome'&&parts.length>=2){const i=parseInt(parts[1]);if(i>=0&&i=3){const from=parts[1],to=parts[2];addTTLink(from,to);}
else if(cmd==='removeTTLink'&&parts.length>=2){const id=parts[1];removeTTLink(id);}
else if(cmd==='flipTTLink'&&parts.length>=2){const id=parts[1];flipTTLink(id);}
else if(cmd==='addHowLink'&&parts.length>=3){const from=parts[1],to=parts[2];addHowLink(from,to);}
else if(cmd==='createMeasure'&&parts.length>=2){const title=parts.slice(1).join(':');const id='M'+String(measureNextId).padStart(3,'0');measureNextId++;/* stable uid on the new Measure. */measures.push({id,title,mainText:'',notes1:'',notes2:'',notes3:'',trafficLight:'',uid:_genUid('measure')});saveState();render();}
else if(cmd==='createEQ'&&parts.length>=2){const qtext=parts.slice(1).join(':');const id='EQ'+String(eqNextId).padStart(3,'0');eqNextId++;/* stable uid on the new Evaluation Question. */evalQuestions.push({id,questionText:qtext,mainText:'',notes1:'',notes2:'',notes3:'',trafficLight:'',uid:_genUid('eq')});saveState();render();}
else if(cmd==='linkMeasure'&&parts.length>=3){const mId=parts[1],boxKey=parts[2];linkMeasureToBox(mId,boxKey);}
else if(cmd==='unlinkMeasure'&&parts.length>=3){const mId=parts[1],boxKey=parts[2];unlinkMeasureFromBox(mId,boxKey);}
else if(cmd==='linkEQ'&&parts.length>=3){const eqId=parts[1],boxKey=parts[2];linkEQToBox(eqId,boxKey);}
else if(cmd==='unlinkEQ'&&parts.length>=3){const eqId=parts[1],boxKey=parts[2];unlinkEQFromBox(eqId,boxKey);}
else if(cmd==='deleteMeasure'&&parts.length>=2){const mId=normalizeMeasureIdInput(parts[1]);const m=getMeasure(mId);if(m){const linked=getBoxesLinkedToMeasure(mId);linked.forEach(k=>{if(B[k]&&B[k].measures)B[k].measures=B[k].measures.filter(id=>normalizeMeasureIdInput(id)!==mId);});ttLinks.forEach(function(l){if(l.measures)l.measures=l.measures.filter(function(id){return normalizeMeasureIdInput(id)!==mId;});});measures=measures.filter(x=>x.id!==mId);saveState();render();}}
else if(cmd==='deleteEQ'&&parts.length>=2){const eqId=normalizeEQIdInput(parts[1]);const q=getEQ(eqId);if(q){const linked=getBoxesLinkedToEQ(eqId);linked.forEach(k=>{if(B[k]&&B[k].evalQuestions)B[k].evalQuestions=B[k].evalQuestions.filter(id=>normalizeEQIdInput(id)!==eqId);});ttLinks.forEach(function(l){if(l.evalQuestions)l.evalQuestions=l.evalQuestions.filter(function(id){return normalizeEQIdInput(id)!==eqId;});});evalQuestions=evalQuestions.filter(x=>x.id!==eqId);saveState();render();}}
else if(cmd==='removeHowLink'&&parts.length>=2){const id=parts[1];removeHowLink(id);}
}
/* Preserve box state and links while positional keys shift. */
function rebuildBoxKeys(spId,keyMap){markRuntimeLinkIndexDirty();const sp=SP.find(s=>s.id===spId);if(!sp)return;if(keyMap&&typeof keyMap==='object'){const captured={};Object.keys(B).filter(k=>k.indexOf(spId+'-c')===0).forEach(k=>{captured[k]=B[k];delete B[k];});Object.keys(captured).forEach(oldK=>{let newK;if(Object.prototype.hasOwnProperty.call(keyMap,oldK)){newK=keyMap[oldK];}else{newK=oldK;}if(newK===''||newK==null)return;B[newK]=captured[oldK];});sp.cols.forEach((col,ci)=>{col.boxes.forEach((label,bi)=>{const k=`${spId}-c${ci}-b${bi}`;if(!B[k])B[k]={label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',tagIds:[],uid:_genUid('box')};B[k].label=label;});});function _remap(k){if(k&&typeof k==='string'&&k.indexOf(spId+'-c')===0&&Object.prototype.hasOwnProperty.call(keyMap,k)){return keyMap[k];}return k;}if(typeof ttLinks!=='undefined'&&Array.isArray(ttLinks)){ttLinks.forEach(l=>{if(l&&l.from)l.from=_remap(l.from);if(l&&l.to)l.to=_remap(l.to);});ttLinks=ttLinks.filter(l=>l&&l.from&&l.to);}if(typeof howLinks!=='undefined'&&Array.isArray(howLinks)){howLinks.forEach(l=>{if(l&&l.from)l.from=_remap(l.from);if(l&&l.to)l.to=_remap(l.to);});howLinks=howLinks.filter(l=>l&&l.from&&l.to);}if(typeof openBox!=='undefined'&&openBox){const r=_remap(openBox);openBox=(r==='')?null:r;}if(typeof inspectHome!=='undefined'&&inspectHome){const r=_remap(inspectHome);inspectHome=(r==='')?null:r;}if(typeof revealBox!=='undefined'&&revealBox){const r=_remap(revealBox);revealBox=(r==='')?null:r;}if(typeof inspectLinkedKeys!=='undefined'&&Array.isArray(inspectLinkedKeys)){inspectLinkedKeys=inspectLinkedKeys.map(_remap).filter(k=>k);}if(typeof sourceRefBox!=='undefined'&&sourceRefBox&&sourceRefBox.sourceKey){const r=_remap(sourceRefBox.sourceKey);if(r!=='')sourceRefBox.sourceKey=r;}cleanupTTLinks();cleanupHowLinks();return;}const oldB={};Object.keys(B).filter(k=>k.startsWith(spId+'-')).forEach(k=>{oldB[k]=B[k];delete B[k];});sp.cols.forEach((col,ci)=>{col.boxes.forEach((label,bi)=>{const k=`${spId}-c${ci}-b${bi}`;const old=oldB[k];B[k]=old||{label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',tagIds:[]};B[k].label=label;});});cleanupTTLinks();cleanupHowLinks();}
function rebuildFinalKeys(){normalizeFinalOutcomesInMemory();const oldB={};Object.keys(B).filter(k=>k.startsWith('final-')).forEach(k=>{oldB[k]=B[k];delete B[k];});FO_DATA.forEach((label,i)=>{const k=`final-b${i}`;const old=oldB[k];B[k]=old||{label:label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',measures:[],evalQuestions:[],tagIds:[]};B[k].label=label;});}
function rebuildFinalKeysWithMap(keyMap){markRuntimeLinkIndexDirty();normalizeFinalOutcomesInMemory();const captured={};Object.keys(B).filter(k=>k.startsWith('final-')).forEach(k=>{captured[k]=B[k];delete B[k];});Object.keys(captured).forEach(oldK=>{let newK;if(keyMap&&Object.prototype.hasOwnProperty.call(keyMap,oldK)){newK=keyMap[oldK];}else{newK=oldK;}if(newK===''||newK==null)return;B[newK]=captured[oldK];});FO_DATA.forEach((label,i)=>{const k=`final-b${i}`;if(!B[k])B[k]={label:label,light:'',entries:[],priority:'',hasSubpage:false,detailText:'',borderColor:'',boxColor:'',measures:[],evalQuestions:[],tagIds:[],uid:_genUid('final')};B[k].label=label;if(!Array.isArray(B[k].entries))B[k].entries=[];if(!Array.isArray(B[k].measures))B[k].measures=[];if(!Array.isArray(B[k].evalQuestions))B[k].evalQuestions=[];if(!Array.isArray(B[k].tagIds))B[k].tagIds=[];});function _remap(k){if(k&&typeof k==='string'&&k.startsWith('final-')&&keyMap&&Object.prototype.hasOwnProperty.call(keyMap,k)){return keyMap[k];}return k;}if(typeof openBox!=='undefined'&&openBox){const r=_remap(openBox);openBox=(r==='')?null:r;}if(typeof inspectHome!=='undefined'&&inspectHome){const r=_remap(inspectHome);inspectHome=(r==='')?null:r;}if(typeof revealBox!=='undefined'&&revealBox){const r=_remap(revealBox);revealBox=(r==='')?null:r;}if(typeof inspectLinkedKeys!=='undefined'&&Array.isArray(inspectLinkedKeys)){inspectLinkedKeys=inspectLinkedKeys.map(_remap).filter(k=>k);}if(typeof sourceRefBox!=='undefined'&&sourceRefBox&&sourceRefBox.sourceKey){const r=_remap(sourceRefBox.sourceKey);if(r){sourceRefBox.sourceKey=r;sourceRefBox.sourceLabel=B[r]?B[r].label:sourceRefBox.sourceLabel;}else{sourceRefBox=null;}}}
/* detect explicit blank/starter This\u2013Then page requests in the user's chat message. Returns true when the user clearly asks for a blank/starter/empty/shell page rather than a populated one. Used to bypass the populated-page validation so genuinely-blank requests still create a blank/starter page. */
function isBlankPageRequest(msg){if(!msg)return false;return /\b(blank|starter|empty|shell|skeleton)\b/i.test(msg);}
/* detect populated This\u2013Then page requests in the user's chat message. Returns true when the user is asking for a populated/filled-in This\u2013Then page about a topic. The trigger phrasings cover the common cases: "populate it", "add DoView this-then boxes", "create a this-then logic for X", "add a this-then page", and "add a page and fill it in". This also includes "full DoView", "complete DoView", "full logic", and "full populated page" so phrasings such as "add a new page called X which includes a full DoView on it" reliably propose a populated This\u2013Then page. It also removes the previous broad "add a page about X" matcher (without an explicit this-then keyword or populated trigger) so a bare "add a page about X" is now classified as ambiguous (see isAmbiguousPageCreationRequest) and asks for the page type instead. It also extends phrase coverage further so common informal phrasings reliably propose a populated This\u2013Then page when the intent is clear: "do/make the full map (for X)", "map this out", "build out (a/this) page", "fill out this page", "build the full logic", and "turn this into a full page". Tight phrasings only \u2014 the broad word "map" by itself, or "section" by itself, do NOT trigger; question-only requests are NOT triggered. An explicit blank/starter request short-circuits to false so a clearly-blank request bypasses populated-page validation. Used by processAIResponse to validate that the AI's proposed structural actions actually populate the new page rather than leaving it as a one-box placeholder. */
function isPopulatedThisThenPageRequest(msg){if(!msg)return false;if(isBlankPageRequest(msg))return false;if(/\bpopulate\b/i.test(msg))return true;if(/\bfill\s+(it|in|out|the\s+page|out\s+this\s+page)\b/i.test(msg))return true;/* "full DoView" / "complete DoView" / "full logic" / "full populated page" all trigger populated This\u2013Then page behavior. */if(/\b(full|complete)\s+doview\b/i.test(msg))return true;if(/\bfull\s+(logic|populated\s+page|populated)\b/i.test(msg))return true;/* "build the full logic" (a non-noun-form alternative to "full logic") also triggers populated. */if(/\bbuild\s+(the\s+|a\s+)?full\s+logic\b/i.test(msg))return true;/* "do the full map" / "make the full map" / "the full map for X" trigger populated. The bare word "map" by itself does NOT trigger \u2014 it must be preceded by "do/make/build the full" or be the verb in "map this out". */if(/\b(do|make|build)\s+(the\s+|a\s+)?full\s+map\b/i.test(msg))return true;if(/\bmap\s+(this|it|that)\s+out\b/i.test(msg))return true;/* "build out (a/this/the) page", "fill out this page", "turn this into a full page" trigger populated. These phrasings are about populating a page, not just creating a shell. */if(/\bbuild\s+out\s+(a\s+|an\s+|the\s+|this\s+)?page\b/i.test(msg))return true;if(/\bturn\s+(this|it|that)\s+into\s+(a\s+)?full\s+page\b/i.test(msg))return true;if(/\b(add|create|make)\s+(a\s+|an\s+)?(new\s+)?this[-\u2013\s]*then\s+page\b/i.test(msg))return true;if(/\bcreate\s+(a\s+|an\s+)?this[-\u2013\s]*then\s+(logic|page|flow)\b/i.test(msg))return true;if(/\badd\s+(doview\s+)?(this[-\u2013\s]*then\s+)?boxes\b/i.test(msg))return true;if(/\badd\s+(a\s+|an\s+)?(new\s+)?page\b.*\bfill\b/i.test(msg))return true;return false;}
/* detect generic page-creation phrasings in the user's chat message. Returns true when the user is asking to add/create/make a page (or "add X as a page"). Used together with isExplicitPageType, isBlankPageRequest, and isPopulatedThisThenPageRequest to decide whether a page-creation request is ambiguous and needs the page-type clarification question. Deliberately tight so it does not misfire on rename/delete/edit ("rename this page", "delete this page") or board-level commentary. It also detects "make/add another tab", "set up a section (for X)", "build out (a/this/the) page", and "turn this into a full page" so common informal page-creation phrasings are recognised. Tight phrasings only \u2014 the bare word "section" or "tab" or "map" does NOT trigger; questions like "what does this map say about X" do NOT trigger. */
function isPageCreationRequest(msg){if(!msg)return false;if(/\b(add|create|make)\s+(a\s+|an\s+)([a-z\u2013-]+\s+){0,3}page\b/i.test(msg))return true;if(/\b(add|create|make)\s+(a\s+|an\s+)?new\s+([a-z\u2013-]+\s+){0,3}page\b/i.test(msg))return true;if(/\bnew\s+(this[-\u2013\s]*then|how|documentation|doc)\s+page\b/i.test(msg))return true;if(/\bas\s+a\s+(new\s+)?(this[-\u2013\s]*then|how|documentation|doc)?\s*page\b/i.test(msg))return true;/* "make another tab", "add another tab", "make a new tab" \u2014 page-creation by another name. */if(/\b(make|add|create)\s+(another|a\s+new|a)\s+tab\b/i.test(msg))return true;/* "set up a section (for X)" \u2014 ambiguous page-creation. The bare word "section" without "set up a" does NOT trigger. */if(/\bset\s+up\s+(a\s+|an\s+)?(new\s+)?section\b/i.test(msg))return true;/* "build out a/this/the page", "turn this into a full page" \u2014 also page-creation phrasings (these typically mean populated, but isPopulatedThisThenPageRequest will catch the populated intent and bypass the ambiguity check). */if(/\bbuild\s+out\s+(a\s+|an\s+|the\s+|this\s+)?page\b/i.test(msg))return true;if(/\bturn\s+(this|it|that)\s+into\s+(a\s+)?(full\s+)?page\b/i.test(msg))return true;return false;}
/* detect whether the user has explicitly stated which page type they want (This\u2013Then, How, or Documentation). Used by isAmbiguousPageCreationRequest. Includes common variants like "doc page", "this-then page", "this then page", "how page". */
function isExplicitPageType(msg){if(!msg)return false;if(/\bthis[-\u2013\s]*then\s+page\b/i.test(msg))return true;if(/\bhow\s+page\b/i.test(msg))return true;if(/\b(documentation|doc)\s+page\b/i.test(msg))return true;return false;}
/* detect ambiguous page-creation requests where the user has asked to add/create/make a page but has NOT specified which page type they want and has NOT clearly implied a populated This\u2013Then page (via "full DoView", "populate it", etc.). Examples that should be classified as ambiguous: "add a page called leadership development", "create a new page about leadership development", "make a page for leadership development", "add leadership development as a page". Examples that are NOT ambiguous: "add a How page called X" (explicit type), "add a blank This\u2013Then page" (explicit type with blank qualifier), "add a complete DoView page about X" (populated trigger), "add a full populated page about X" (populated trigger). Note: a blank request without an explicit page type ("add a starter page called X") is still ambiguous because the page type is missing \u2014 the AI must ask which type before creating. Used by processAIResponse to reject any addSubpage emitted for an ambiguous request before queuing pendingStructural so no page is silently created. */
function isAmbiguousPageCreationRequest(msg){if(!msg)return false;if(!isPageCreationRequest(msg))return false;if(isExplicitPageType(msg))return false;if(isPopulatedThisThenPageRequest(msg))return false;return true;}
/* pick the page type from an addSubpage action array. Mirrors the parsing in execAction's addSubpage branch so validation and summary stay consistent with apply behavior. Defaults to this_then. */
function _pageTypeFromAddSubpage(a){if(!a||a[0]!=='addSubpage')return 'this_then';if(a.length>=3 && a[2] && a[2].charAt(0)!=='#' && ['this_then','how','documentation'].indexOf(a[2])>=0)return a[2];return 'this_then';}
/* , walk the structural actions array and predict the new page id assigned by execAction's addSubpage branch. New ids use the next unused pN value, not the current page count alone, so deleted pages do not cause id collisions. Returns an array of {action, predictedId, pageType} entries, one per addSubpage in order. */
function _predictNewPageIds(structActions){var out=[];var used={};(SP||[]).forEach(function(sp){if(sp&&sp.id)used[sp.id]=true;});structActions.forEach(function(a){if(a[0]==='addSubpage'){var pType=_pageTypeFromAddSubpage(a);var id=nextUnusedPageId(used);used[id]=true;out.push({action:a,predictedId:id,pageType:pType});}});return out;}
/* preflight validation for an entire action set before anything is applied. Performs a dry-run over a working "shadow" model that mirrors the parts of execAction's behavior that affect target resolution: addSubpage adds a new page with the next predicted id (and a default first column "First Step" for this_then / documentation, no columns for how pages); addColumn / removeColumn / moveColumn updates the working column list; addBox / removeBox updates the working box list (and renumbers indices because rebuildBoxKeys does the same on apply); addFinalOutcome / removeFinalOutcome updates the working FO list; createMeasure / createEQ / deleteMeasure / deleteEQ updates the working measure / EQ id list; addTTLink / addHowLink / removeTTLink / removeHowLink updates working link id maps. The validator does NOT execute real actions; it only checks that every action references a valid object (existing in the current state OR created earlier in the same set), that page types are supported (this_then, how, documentation), that column indices are valid for the resolved page, that new labels/headings are non-empty, that addBox actions have valid page+column targets, that the action's parts.length meets CMD_MIN_PARTS for that command, and that the command name appears in SUPPORTED_CMDS. Returns {ok:true} or {ok:false, message:'...'}. The message is plain-language; never a stack trace or raw internal error. */
function _validateActionSet(actions){
if(!Array.isArray(actions)||!actions.length)return {ok:true};
// Working shadow state. We model only the parts of execAction that affect target resolution.
var W={
pages:{}, // pid -> {pageType, cols:[{h, boxes:[label,...]}], howIds:[h001,...], finalRm:false}
pageOrder:[], // pids in order
fo:FO_DATA.slice(), // list of labels (positions used as bI)
measures:measures.map(function(m){return m.id;}),
eqs:evalQuestions.map(function(q){return q.id;}),
ttLinkIds:ttLinks.map(function(l){return l.id;}),
howLinkIds:howLinks.map(function(l){return l.id;})
};
// Seed W.pages from current SP
SP.forEach(function(sp){
var entry={pageType:sp.pageType||'this_then',cols:[],howIds:[]};
if(entry.pageType==='how'){if(sp.howBoxes)entry.howIds=sp.howBoxes.map(function(h){return h.id;});}
else{(sp.cols||[]).forEach(function(col){entry.cols.push({h:col.h,boxes:(col.boxes||[]).slice()});});}
W.pages[sp.id]=entry;W.pageOrder.push(sp.id);
});
function pageExists(pid){return !!W.pages[pid];}
function nextShadowPageId(){var n=((SP&&SP.length)||0)+1;while(W.pages['p'+n])n++;return 'p'+n;}
function resolveBoxKey(k){
if(!k)return null;
var m=k.match(/^(p\d+)-c(\d+)-b(\d+)$/);
if(m){var pid=m[1],ci=parseInt(m[2]),bi=parseInt(m[3]);var pg=W.pages[pid];if(!pg||pg.pageType==='how')return null;if(!pg.cols[ci])return null;if(pg.cols[ci].boxes[bi]===undefined)return null;return {kind:'tt',pid:pid,ci:ci,bi:bi};}
var hm=k.match(/^(p\d+)-(H\d{3,})$/);
if(hm){var pid2=hm[1],hid=hm[2];var pg2=W.pages[pid2];if(!pg2||pg2.pageType!=='how')return null;if(pg2.howIds.indexOf(hid)<0)return null;return {kind:'how',pid:pid2,hid:hid};}
var fm=k.match(/^final-b(\d+)$/);
if(fm){var i=parseInt(fm[1]);if(i<0||i>=W.fo.length)return null;return {kind:'final',fi:i};}
return null;
}
for(var idx=0;idxpgC.cols.length)return {ok:false,message:'I could not add that column because the target column index is out of range. No changes were made.'};
if(!hC||!hC.trim())return {ok:false,message:'I could not add that column because the heading was empty. No changes were made.'};
pgC.cols.splice(ciC,0,{h:hC,boxes:[]});
}
else if(cmd==='removeColumn'){
var spIdRc=a[1],ciRc=parseInt(a[2]);var pgRc=W.pages[spIdRc];if(!pgRc)return {ok:false,message:'I could not apply that change because the target page could not be found. No changes were made.'};
if(pgRc.pageType==='how')return {ok:false,message:'I could not apply that change because How pages do not use columns. No changes were made.'};
if(isNaN(ciRc)||!pgRc.cols[ciRc])return {ok:false,message:'I could not apply that change because the target column could not be found. No changes were made.'};
pgRc.cols.splice(ciRc,1);
}
else if(cmd==='renameColumn'){
var spIdRn=a[1],ciRn=parseInt(a[2]),nhRn=a.slice(3).join(':');var pgRn=W.pages[spIdRn];if(!pgRn)return {ok:false,message:'I could not apply that change because the target page could not be found. No changes were made.'};
if(pgRn.pageType==='how')return {ok:false,message:'I could not apply that change because How pages do not use columns. No changes were made.'};
if(isNaN(ciRn)||!pgRn.cols[ciRn])return {ok:false,message:'I could not apply that change because the target column could not be found. No changes were made.'};
if(!nhRn||!nhRn.trim())return {ok:false,message:'I could not apply that change because the new heading was empty. No changes were made.'};
pgRn.cols[ciRn].h=nhRn;
}
else if(cmd==='moveColumn'){
var spIdMc=a[1],fr=parseInt(a[2]),to=parseInt(a[3]);var pgMc=W.pages[spIdMc];if(!pgMc)return {ok:false,message:'I could not apply that change because the target page could not be found. No changes were made.'};
if(pgMc.pageType==='how')return {ok:false,message:'I could not apply that change because How pages do not use columns. No changes were made.'};
if(isNaN(fr)||isNaN(to)||!pgMc.cols[fr]||to<0||to>=pgMc.cols.length)return {ok:false,message:'I could not apply that change because the target column could not be found. No changes were made.'};
var col=pgMc.cols.splice(fr,1)[0];pgMc.cols.splice(to,0,col);
}
else if(cmd==='addSubpage'){
var lblS=a[1];if(!lblS||!lblS.trim())return {ok:false,message:'I could not add that page because the page title was empty. No changes were made.'};
var pType='this_then';if(a.length>=3 && a[2] && a[2].charAt(0)!=='#'){if(['this_then','how','documentation'].indexOf(a[2])<0)return {ok:false,message:'I could not add that page because the page type "'+a[2]+'" is not supported. No changes were made.'};pType=a[2];}
var newId=nextShadowPageId();var entryS={pageType:pType,cols:[],howIds:[]};
if(pType==='how'){for(var hi=1;hi<=8;hi++){entryS.howIds.push('H'+String(hi).padStart(3,'0'));}}
else{entryS.cols.push({h:'First Step',boxes:[]});}
W.pages[newId]=entryS;W.pageOrder.push(newId);
}
else if(cmd==='removeSubpage'){
var rmId=a[1];if(!pageExists(rmId))return {ok:false,message:'I could not apply that change because the target page could not be found. No changes were made.'};
delete W.pages[rmId];var oi=W.pageOrder.indexOf(rmId);if(oi>=0)W.pageOrder.splice(oi,1);
}
else if(cmd==='addFinalOutcome'){
var fl=a.slice(1).join(':');if(!fl||!fl.trim())return {ok:false,message:'I could not add that final outcome because the label was empty. No changes were made.'};
W.fo.push(fl);
}
else if(cmd==='removeFinalOutcome'){
var fi=parseInt(a[1]);if(isNaN(fi)||fi<0||fi>=W.fo.length)return {ok:false,message:'I could not apply that change because the target final outcome could not be found. No changes were made.'};
W.fo.splice(fi,1);
}
else if(cmd==='addTTLink'){
var fk=resolveBoxKey(a[1]);var tk=resolveBoxKey(a[2]);if(!fk||!tk||fk.kind!=='tt'||tk.kind!=='tt')return {ok:false,message:'I could not add that link because one of the linked boxes could not be found. No changes were made.'};
}
else if(cmd==='removeTTLink'||cmd==='flipTTLink'){
if(W.ttLinkIds.indexOf(a[1])<0)return {ok:false,message:'I could not apply that change because the target structural link could not be found. No changes were made.'};
if(cmd==='removeTTLink'){var li=W.ttLinkIds.indexOf(a[1]);W.ttLinkIds.splice(li,1);}
}
else if(cmd==='addHowLink'){
var hfk=resolveBoxKey(a[1]);var htk=resolveBoxKey(a[2]);if(!hfk||!htk)return {ok:false,message:'I could not add that link because one of the linked boxes could not be found. No changes were made.'};
}
else if(cmd==='removeHowLink'){
if(W.howLinkIds.indexOf(a[1])<0)return {ok:false,message:'I could not apply that change because the target structural link could not be found. No changes were made.'};
var hli=W.howLinkIds.indexOf(a[1]);W.howLinkIds.splice(hli,1);
}
else if(cmd==='createMeasure'){
var mt=a.slice(1).join(':');if(!mt||!mt.trim())return {ok:false,message:'I could not create that Measure because the title was empty. No changes were made.'};
W.measures.push('M'+String(W.measures.length+1).padStart(3,'0'));
}
else if(cmd==='createEQ'){
var qt=a.slice(1).join(':');if(!qt||!qt.trim())return {ok:false,message:'I could not create that Evaluation Question because the question text was empty. No changes were made.'};
W.eqs.push('EQ'+String(W.eqs.length+1).padStart(3,'0'));
}
else if(cmd==='linkMeasure'||cmd==='unlinkMeasure'){
if(W.measures.indexOf(a[1])<0)return {ok:false,message:'I could not apply that change because the target Measure could not be found. No changes were made.'};
if(!resolveBoxKey(a[2]))return {ok:false,message:'I could not apply that change because the target box could not be found. No changes were made.'};
}
else if(cmd==='linkEQ'||cmd==='unlinkEQ'){
if(W.eqs.indexOf(a[1])<0)return {ok:false,message:'I could not apply that change because the target Evaluation Question could not be found. No changes were made.'};
if(!resolveBoxKey(a[2]))return {ok:false,message:'I could not apply that change because the target box could not be found. No changes were made.'};
}
else if(cmd==='deleteMeasure'){
if(W.measures.indexOf(a[1])<0)return {ok:false,message:'I could not apply that change because the target Measure could not be found. No changes were made.'};
var dmi=W.measures.indexOf(a[1]);W.measures.splice(dmi,1);
}
else if(cmd==='deleteEQ'){
if(W.eqs.indexOf(a[1])<0)return {ok:false,message:'I could not apply that change because the target Evaluation Question could not be found. No changes were made.'};
var deqi=W.eqs.indexOf(a[1]);W.eqs.splice(deqi,1);
}
// Other state-only commands (setAllLights, setBoardInfo) require no target validation beyond the parts.length check.
}
return {ok:true};
}
/* Validate populated This\u2013Then page proposals. Given the structural actions array, count addColumn / removeColumn / addBox actions targeting each predicted new this_then page id, and report any predicted page where the resulting structure would be a one-box placeholder or sparse: fewer than 3 columns total or fewer than 6 boxes. Engine adds 1 default column ("First Stage") when an addSubpage:this_then runs, so total columns = 1 + addColumn(this id) - removeColumn(this id). Total boxes = addBox(this id). This also flags pages whose column headings are mechanical numbered placeholders such as "Stage 1", "Stage 2", "Column 1", "Step 2", "Phase 3" \u2014 these are rejected because column headings must name real causal stages. It also flags pages whose column headings are dominated by the generic logic-model template Inputs / Activities / Outputs (specifically: 2 or more of those generic words used as headings together) so a mechanical "Inputs / Activities / Outputs" page is rejected unless the AI uses a domain-specific shape; single occurrences of those words are still allowed because a domain may genuinely have one step called "Outputs". The default first-column heading "First Stage" is left alone (this is the engine's default until the AI renames it via renameColumn) \u2014 the AI is expected to renameColumn it to a domain-specific heading; if it does not rename it, the count of generic headings still flags the proposal. Returns array of insufficient predicted page descriptors with totalCols, addBoxes, hasGenericHeadings, and a human-readable reason string. */
function _findInsufficientThisThenPageProposals(structActions){var preds=_predictNewPageIds(structActions);var bad=[];var genericRx=/^(stage|step|column|phase)\s*\d+$/i;var genericTemplateRx=/^(inputs?|activities|outputs?)$/i;preds.forEach(function(p){if(p.pageType!=='this_then')return;var pid=p.predictedId;var addCols=structActions.filter(function(a){return a[0]==='addColumn' && a[1]===pid;}).length;var rmCols=structActions.filter(function(a){return a[0]==='removeColumn' && a[1]===pid;}).length;var totalCols=1+addCols-rmCols;var addBoxes=structActions.filter(function(a){return a[0]==='addBox' && a[1]===pid;}).length;/* scan addColumn / renameColumn headings for mechanical numbered placeholders. If any heading targeting this page id matches the generic pattern, flag the proposal. also count Inputs/Activities/Outputs headings; flag when 2 or more occur together. */var hasGenericHeadings=false;var templateCount=0;for(var k=0;k=2;var reason=[];if(totalCols<3)reason.push('only '+totalCols+' column'+(totalCols===1?'':'s'));if(addBoxes<6)reason.push('only '+addBoxes+' box'+(addBoxes===1?'':'es'));if(hasGenericHeadings)reason.push('mechanical numbered column headings');if(hasGenericTemplate)reason.push('generic Inputs / Activities / Outputs template headings');if(reason.length){bad.push({predictedId:pid,label:p.action[1]||'(untitled)',totalCols:totalCols,addBoxes:addBoxes,hasGenericHeadings:hasGenericHeadings,hasGenericTemplate:hasGenericTemplate,reason:reason.join(', ')});}});return bad;}
function processAIResponse(fullText,userMsg){const{clean,actions}=parseActions(fullText);if(!actions.length)return clean;
/* preflight validation runs first \u2014 before snapshot, before any state-action execution, before showing OK/Cancel. If any action is malformed, has an unsupported command name, or targets a missing object, the WHOLE action set is rejected with a plain-language message and nothing is applied. */
var v=_validateActionSet(actions);
if(!v.ok){addChatMsg('system',v.message);return clean;}
const stateActions=actions.filter(a=>!STRUCTURAL_CMDS.includes(a[0]));const structActions=actions.filter(a=>STRUCTURAL_CMDS.includes(a[0]));
/* if the user's request is an ambiguous page-creation request (no page type specified, no populated trigger), reject any addSubpage actions emitted by the AI. The AI is expected by the prompt to ask the page-type clarification question instead of emitting addSubpage; this engine-side guard is a safety net so a page is never silently created with the wrong type. Other action shapes (state changes, removals, renames, link/measure/EQ actions, etc.) are unaffected by this guard. */
if(structActions.length && userMsg && isAmbiguousPageCreationRequest(userMsg)){var hasAddSubpage=structActions.some(function(a){return a[0]==='addSubpage';});if(hasAddSubpage){addChatMsg('system','Your request did not specify the page type (This\u2013Then page, How page, or Documentation page). The change has not been applied. Please clarify which type of page you want \u2014 see the question above \u2014 and the board chat will then propose the right page.');return clean;}}
/* If the user clearly asked for a populated This\u2013Then page (and did not explicitly ask for a blank/starter page), validate that the AI's proposed structural actions would actually create a properly populated page. Reject sparse proposals (fewer than 3 columns or fewer than 6 boxes) or proposals with mechanical numbered column headings or generic Inputs/Activities/Outputs template headings. The change is rejected before queuing pendingStructural so a one-box placeholder or generic-template page is never silently applied. Other action shapes (How pages, Documentation pages, removals, renames, link/measure/EQ actions, etc.) are unaffected. */
if(structActions.length && userMsg && isPopulatedThisThenPageRequest(userMsg)){var insufficient=_findInsufficientThisThenPageProposals(structActions);if(insufficient.length){var listing=insufficient.map(function(x){return '"'+x.label+'" ('+x.reason+')';}).join('; ');addChatMsg('system','It looks like you asked for a populated This\u2013Then page, but the proposed change would not create a properly populated page: '+listing+'. A populated This\u2013Then page should have at least 3 columns and at least 6 boxes, and the column headings must name real causal stages for the domain (not "Phase 1, Phase 2, Phase 3" or a generic "Inputs / Activities / Outputs" template). The change has not been applied. Please ask again with more domain-specific detail, or say "add a blank/starter page about X" if you really want a shell.');return clean;}}
/* mixed-transaction handling. State-only responses still apply directly with a snapshot (preserving the existing safe direct-apply path). Structural-only responses still queue pendingStructural and show OK/Cancel. Mixed responses queue BOTH state and structural actions and present them together in one OK/Cancel preview, so the user sees the full change set and Cancel rolls back to a clean state without partial application. */
if(structActions.length && stateActions.length){
takeSnapshot();
pendingStateActions=stateActions;
pendingStructural=structActions;
showConfirmation({state:stateActions,structural:structActions});
return clean;
}
if(stateActions.length||structActions.length)takeSnapshot();
stateActions.forEach(a=>execAction(a));
if(structActions.length){pendingStructural=structActions;showConfirmation(structActions);}
return clean;
}
/* showConfirmation now accepts either a string description (legacy) or the structural-actions array. When given the actions array it renders a clean structured summary that groups addSubpage + addColumn / renameColumn / addBox actions by predicted new page id, and shows the page title, the column headings, and the actual box count for each new page. Other structural actions render as one line each. The summary makes it clear what will be added before the user clicks OK. The pendingStructural array (carrying the FULL set of proposed columns and boxes) is unchanged and is still applied in order by confirmStructural on OK. showConfirmation now also accepts a mixed object {state:[...],structural:[...]} so a chat response containing both state and structural actions can be presented in ONE OK/Cancel preview that covers both kinds. */
function showConfirmation(descOrActions){var msgs=document.getElementById('chatMsgs');var div=document.createElement('div');div.className='chat-m sys';var summaryHTML;if(descOrActions && !Array.isArray(descOrActions) && typeof descOrActions==='object' && (descOrActions.state||descOrActions.structural)){summaryHTML=_buildStructuralChangeSummaryHTML(descOrActions.structural||[],descOrActions.state||[]);}else if(Array.isArray(descOrActions)){summaryHTML=_buildStructuralChangeSummaryHTML(descOrActions);}else{summaryHTML='
'+escHtml(String(descOrActions))+'
';}div.innerHTML='\u26a0\ufe0f Change(s) proposed:'+summaryHTML+'';msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;}
/* Build the structured summary HTML for the confirmation message. addSubpage actions are grouped with their related addColumn / renameColumn / addBox actions (by predicted new page id) into a single readable line per new page that shows the page type, title, column headings and box count. The preview adds a small Populated / Blank-starter status pill for This\u2013Then page proposals so the OK / Cancel preview makes obvious whether a full page is being proposed or only a shell. Unrelated structural actions (removals, renames, etc.) render as one bullet line each, escaped. */
function _buildStructuralChangeSummaryHTML(actions,stateActionsForPreview){
/* per-action friendly preview lines for individual structural and state actions. addSubpage groupings (with addColumn / renameColumn / addBox children) are still rendered as the existing rich grouped line. Other actions \u2014 especially destructive ones \u2014 now render with plain-language wording that names the affected object (page, column, box, final outcome, Measure, EQ, link, association) so the user understands what will happen before clicking OK. Targets are resolved from current state SP / B / FO_DATA / measures / evalQuestions / ttLinks / howLinks; resolution failures fall back to a compact tag-only line. By the time we reach this function, _validateActionSet has already run and rejected unresolvable targets, so resolution should normally succeed.
stateActionsForPreview, if provided, lists state-action lines (setLight, setPriority, addSofar, etc.) below the structural lines so a mixed transaction shows ALL changes in ONE preview. State-only responses still apply directly (this function is not used for them). */
function _safe(s){return escHtml(String(s==null?'':s));}
function _pageLabelById(pid){var sp=SP.find(function(s){return s.id===pid;});return sp?sp.label:pid;}
function _resolveTTBoxLabel(k){var b=B[k];if(b)return b.label;var m=k&&k.match(/^(p\d+)-c(\d+)-b(\d+)$/);if(m){var sp=SP.find(function(s){return s.id===m[1];});if(sp&&sp.cols[parseInt(m[2])]&&sp.cols[parseInt(m[2])].boxes[parseInt(m[3])]!==undefined)return sp.cols[parseInt(m[2])].boxes[parseInt(m[3])];}return k;}
function _resolveColumnHeading(spId,ci){var sp=SP.find(function(s){return s.id===spId;});if(sp&&sp.cols[ci])return sp.cols[ci].h;return 'column '+(ci+1);}
function _resolveAnyBoxLabel(k){if(!k)return k;if(k.indexOf('final-b')===0){var i=parseInt(k.replace('final-b',''));if(!isNaN(i)&&i>=0&&i=0&&fi0 || boxesAdded.length>0);
if(isPopulated){statusPill=' POPULATED';}
else{statusPill=' BLANK/STARTER';}
}
var line='
';
i++;
}
}
/* if the response contains state actions alongside structural actions, render them in the same preview so the user confirms the FULL change set in one OK/Cancel. State-only responses still apply directly without ever calling this function. */
if(stateActionsForPreview && stateActionsForPreview.length){
for(var si=0;si\u2022 '+_renderSingleAction(stateActionsForPreview[si])+'
';
}
}
html+='
';
return html;
}
function confirmStructural(ok){
/* mixed-transaction commit. On OK we apply the queued pendingStateActions first (so they execute against the pre-structural state) then the queued pendingStructural; on Cancel we doUndo() to roll back to the snapshot taken in processAIResponse so neither the state changes nor the structural changes are partially applied. Both queues are cleared either way. */
if(ok){
if(pendingStateActions && pendingStateActions.length){pendingStateActions.forEach(function(a){execAction(a);});}
if(pendingStructural && pendingStructural.length){pendingStructural.forEach(function(a){execAction(a);});}
}else{
doUndo();
}
pendingStateActions=[];
pendingStructural=[];
addChatMsg('system',ok?'Changes applied.':'Changes cancelled.');
}
// ─── SOURCES & MODALS ───
// Sources popup is now editable. Each source is rendered as a pair of inputs (title, URL)
// with a remove control. Helper text replaces the older yellow warning-banner (.warn-txt) treatment and now
// uses standard popup-consistent styling (.info-helper). In display mode the user clicks the URL as a link;
// when editing, the row is a plain input. Save persists to the in-memory sources array and to localStorage.
// in editable boards, each non-empty URL also gets a small "Open" link beside the URL input so the user can open the saved URL in a new tab without entering edit mode (the URL input remains editable). In read-only copies, source rows render as view-only — the title appears as plain text, the URL appears as a clickable Open link (or as the bare URL if there is no title), and editable inputs / Add source / Save / Remove are hidden. The Close button on the modal remains so the popup can always be dismissed. The sources data shape and the saveSources/addNewSourceRow/removeSourceRow flow are unchanged for editable boards.
// Sources URL rendering now passes through _safeUrl before opening or rendering as a clickable link. Safe URLs (https:// / http:// / bare domains promoted to https://) become clickable Open links; unsafe URLs (JavaScript:, data:, vbscript:, blank, malformed) are rejected and the row falls back to plain-text display so a dangerous URL never becomes a clickable link. Generated anchors use rel="noopener noreferrer". Stored user text is not rewritten \u2014 the input still holds whatever the user typed.
function openSources(){renderSourcesList();openModal('srcModal');}
function renderSourcesList(){var list=document.getElementById('srcList');if(!list)return;if(!sources.length){list.innerHTML='
';return;}var html='';sources.forEach(function(s,i){var t=(s&&s.title)||'';var u=(s&&s.url)||'';/* resolve the saved URL through the central safe helper. Empty / dangerous / malformed values yield '' and are not rendered as clickable links. */var safeHref=_safeUrl(u);if(IS_READONLY){/* view-only row for read-only copies. Show title as plain text and URL as a clickable Open link. If only the URL is present, show the URL itself as the link text. (Only render the clickable Open affordance when _safeUrl returns a safe URL; otherwise render the URL as plain text.) */var titleHtml=t?''+escHtml(t)+'':'';var urlHtml='';if(u){if(safeHref){var openText=t?'Open':escHtml(u);urlHtml=''+openText+'';}else{urlHtml=''+escHtml(u)+'';}}else if(!t){urlHtml='(no URL)';}html+='
'+titleHtml+urlHtml+'
';}else{/* editable row gains a small Open link beside the URL input when the URL is non-empty, so the saved URL can be opened in a new tab without entering edit mode. The URL itself remains editable in the input. (the Open affordance is rendered only when _safeUrl returns a safe URL so a dangerous URL is never made clickable, but the URL input itself remains editable so the user can still see and correct what they typed.) */var openLink=safeHref?'Open':'';html+='
'
+''
+''
+openLink
+''
+'
';}});list.innerHTML=html;}
function addNewSourceRow(){if(IS_READONLY)return;saveSourcesFromDOM(/*silent*/true);sources.push({title:'',url:''});renderSourcesList();}
function removeSourceRow(idx){if(IS_READONLY)return;saveSourcesFromDOM(/*silent*/true);if(idx<0||idx>=sources.length)return;sources.splice(idx,1);renderSourcesList();}
function saveSourcesFromDOM(silent){var rows=document.querySelectorAll('#srcList .src-edit-row');if(!rows||!rows.length){return;}var next=[];rows.forEach(function(row){var t=(row.querySelector('.src-edit-title')||{}).value||'';var u=(row.querySelector('.src-edit-url')||{}).value||'';t=t.trim();u=u.trim();if(!t&&!u)return;next.push({title:t,url:u});});sources=next;try{localStorage.setItem('doview_sources',JSON.stringify(sources));}catch(e){}if(!silent)saveState();}
function saveSources(){if(IS_READONLY){closeModal('srcModal');return;}saveSourcesFromDOM(false);renderSourcesList();closeModal('srcModal');render();}
function addSource(title,url){sources.push({title,url});try{localStorage.setItem('doview_sources',JSON.stringify(sources));}catch(e){}}
/* in read-only mode, popup windows still open and are viewable, but text/content fields inside them must be clearly non-editable. This helper sweeps the modal's textareas, text inputs, and contenteditable regions and disables editability, so typing or deletion inside popup fields is not possible when the change would not persist anyway. Links stay clickable; the Close button and navigation affordances are unaffected. A small .ro-keep-editable escape hatch is respected so viewing/filtering inputs (e.g. the global-search input) stay live. applyReadonlyToModal is called from openModal every time a modal opens, so dynamically-inserted content (Measure detail, EQ detail, Link detail, Sources list, Clone chooser, etc.) is covered after its innerHTML is set. */
function applyReadonlyToModal(modalEl){if(!IS_READONLY||!modalEl)return;try{var tas=modalEl.querySelectorAll('textarea');for(var i=0;i=maxZ)maxZ=z+1;});el.style.zIndex=maxZ;var mb=el.querySelector('.modal-box');if(mb)mb.scrollTop=0;el.scrollTop=0;}catch(e){}applyReadonlyToModal(el);}
function closeModal(id){document.getElementById(id).classList.remove('open');}
function openInfoDev(){openModal('infoModal');}
function openPrototypeOnly(){openModal('prototypeOnlyModal');}
function openAck(){openModal('ackModal');}
function openDisclaimer(){openModal('disclaimerModal');}
/* Board info now opens in rendered view mode by default. The saved text is rendered once with clickable URLs (via buildPopupDisplayBlock); an Edit button toggles to the textarea + Save / Cancel. Empty fields show a modest empty-state line above the Edit button rather than a duplicate textarea + rendered block. In read-only copies, the Edit button is hidden via the existing body.is-readonly button[onclick=...] CSS rule and only the rendered view is visible. URLs in the rendered view remain clickable in both editable and read-only modes. */
function _renderBoardInfoView(){var _bid=document.getElementById('boardInfoDisplay');if(!_bid)return;var s=(boardInfo===null||boardInfo===undefined)?'':String(boardInfo);if(!s.trim()){_bid.innerHTML='
No board info yet.'+(IS_READONLY?'':' Click Edit to add some.')+'
';}else{_bid.innerHTML='
'+linkifyDisplay(s)+'
';}}
function openBoardInfo(){_renderBoardInfoView();var v=document.getElementById('boardInfoView');var e=document.getElementById('boardInfoEdit');if(v)v.style.display='';if(e)e.style.display='none';/* always start in view mode; Edit button (hidden in read-only via existing CSS rule on saveBoardInfo's button[onclick]; we hide the Edit button via body.is-readonly #boardInfoEditBtn) switches to edit mode. */openModal('boardInfoModal');}
function boardInfoStartEdit(){if(IS_READONLY)return;var v=document.getElementById('boardInfoView');var e=document.getElementById('boardInfoEdit');var ta=document.getElementById('boardInfoTa');if(ta)ta.value=boardInfo||'';if(v)v.style.display='none';if(e)e.style.display='';if(ta){setTimeout(function(){try{ta.focus();}catch(_){}},30);}}
function boardInfoCancelEdit(){var v=document.getElementById('boardInfoView');var e=document.getElementById('boardInfoEdit');if(v)v.style.display='';if(e)e.style.display='none';/* cancel does not save; the previous saved text is preserved. Re-render the view block from the unchanged in-memory boardInfo. */_renderBoardInfoView();}
function saveBoardInfo(){if(IS_READONLY){closeModal('boardInfoModal');return;}var ta=document.getElementById('boardInfoTa');boardInfo=ta?(ta.value||''):boardInfo;saveState();/* after Save, return to view mode rather than closing the modal so the user can immediately see their saved text rendered with clickable URLs. The modal stays open. */_renderBoardInfoView();var vDiv=document.getElementById('boardInfoView');var eDiv=document.getElementById('boardInfoEdit');if(vDiv)vDiv.style.display='';if(eDiv)eDiv.style.display='none';render();}
function _renderPageInfoView(){var _pid=document.getElementById('pageInfoDisplay');if(!_pid)return;var s=pageInfo[currentPage]||'';var spNoteObj=SP.find(function(_s){return _s.id===currentPage;});var srcNote=(spNoteObj&&spNoteObj.sourceNote)?'
'+linkifyDisplay(spNoteObj.sourceNote)+'
':'';if(!s.trim()){_pid.innerHTML=srcNote+'
No page info yet.'+(IS_READONLY?'':' Click Edit to add some.')+'
';}else{_pid.innerHTML=srcNote+'
'+linkifyDisplay(s)+'
';}}
function openPageInfo(){var pid=currentPage;var label=pid;if(pid==='overview')label='Overview';else if(pid==='final')label='Final Outcomes';else{var sp=SP.find(function(s){return s.id===pid;});if(sp)label=sp.label;}document.getElementById('pageInfoTitle').textContent='Page info: '+label;_renderPageInfoView();var v=document.getElementById('pageInfoView');var e=document.getElementById('pageInfoEdit');if(v)v.style.display='';if(e)e.style.display='none';openModal('pageInfoModal');}
function pageInfoStartEdit(){if(IS_READONLY)return;var v=document.getElementById('pageInfoView');var e=document.getElementById('pageInfoEdit');var ta=document.getElementById('pageInfoTa');if(ta)ta.value=pageInfo[currentPage]||'';if(v)v.style.display='none';if(e)e.style.display='';if(ta){setTimeout(function(){try{ta.focus();}catch(_){}},30);}}
function pageInfoCancelEdit(){var v=document.getElementById('pageInfoView');var e=document.getElementById('pageInfoEdit');if(v)v.style.display='';if(e)e.style.display='none';_renderPageInfoView();}
function savePageInfo(){if(IS_READONLY){closeModal('pageInfoModal');return;}var ta=document.getElementById('pageInfoTa');pageInfo[currentPage]=ta?(ta.value||''):pageInfo[currentPage];saveState();/* after Save, return to view mode rather than closing the modal so the user can immediately see their saved text rendered with clickable URLs. */_renderPageInfoView();var vDiv=document.getElementById('pageInfoView');var eDiv=document.getElementById('pageInfoEdit');if(vDiv)vDiv.style.display='';if(eDiv)eDiv.style.display='none';}
function renderPageInfoBar(){var bar=document.getElementById('pageInfoBar');if(!bar)return;if(presentationView){bar.style.display='none';bar.innerHTML='';return;}var pid=currentPage;var label='';if(pid==='overview')label='Overview';else if(pid==='final')label='Final Outcomes';else{var sp=SP.find(function(s){return s.id===pid;});if(sp)label=sp.label;}if(label){bar.style.display='';var isSubpage=isCurrentPageSubpage();
// far-left page-navigation history arrows (\u2190 / \u2192). These are browser-style navigation only; they do not affect page order.
var backCls=navHistory.length?'pi-move':'pi-move pi-disabled';
var fwdCls=navFuture.length?'pi-move':'pi-move pi-disabled';
var navBackBtn='\u2190';
var navFwdBtn='\u2192 ';
// page-order control relabelled 'Change page order' and moved to sit AFTER the page name and Page info, BEFORE View. Wording is direction-neutral so future vertical page-picking/reordering remains compatible.
var reorderBtn=(!IS_READONLY&&isSubpage)?' Change page order':'';
var delBtn=isSubpage?' Delete':'';var undoClass=undoStack.length?'pi-undo':'pi-undo pi-disabled';var undoBtn=' \u21A9';var viewBtn=currentPageSupportsPageView()?' Page View':'';
var stepNav=pageStepNavHTML();
var pageInfoBtn=' Page info';
/* second-line page controls are ordered as navigation, Page View, page-order controls, Page info, Delete, Undo. Back to Overview is intentionally omitted from this line; the separate lower Back to Overview button is unchanged. */
var leftHtml=navBackBtn+navFwdBtn+stepNav+viewBtn+reorderBtn+pageInfoBtn+delBtn+undoBtn;
if(isSubpage){var _hlsp=SP.find(function(s){return s.id===pid;});if(_hlsp&&_hlsp.pageType==='how'){var hlv=_hlsp.howLevel;var howPages=SP.filter(function(s){return s.pageType==='how';});var lvlText=hlv?'Level '+hlv:'No level';var canUp=hlv&&hlv>1;var canDn=hlv&&hlv'+lvlText+' ▲▼'+(hlv?'\u2715':'Set')+'';}}
// board-level Top right text rendered right-justified in the page-info bar. When non-empty, the text itself is shown (clickable in editable boards to edit, plain non-clickable in read-only mode). When empty, a modest "+ Add top right text" affordance was previously shown in editable boards only.
// when topRightText is empty, the right-hand page-info bar now shows nothing at all in both editable boards and read-only copies. The previous "+ Add top right text" affordance is removed so finished boards do not look unfinished. Top right text is edited through the direct page-info-bar text modal, not through Board info.
var rightHtml='';
if(topRightText&&topRightText.trim()){
rightHtml=''+escHtml(topRightText)+'';
}
bar.innerHTML='
'+leftHtml+'
'+rightHtml+'
';
}else{bar.style.display='none';}}
// open the Top right text editor modal. The input is pre-populated with the current value. In read-only copies the modal is opened too (so users can read the text in detail), but its Save / Clear buttons are hidden via the standard CSS rule body.is-readonly button[onclick=...] pattern; for symmetry and safety, the save/clear functions also short-circuit on IS_READONLY.
function openTopRightTextModal(){var inp=document.getElementById('topRightTextInput');if(inp)inp.value=topRightText||'';openModal('topRightTextModal');if(!IS_READONLY&&inp){setTimeout(function(){try{inp.focus();inp.select();}catch(_){}},50);}}
function saveTopRightText(){if(IS_READONLY){closeModal('topRightTextModal');return;}var inp=document.getElementById('topRightTextInput');var v=inp?(inp.value||''):'';v=v.replace(/[\r\n\t]+/g,' ').trim();takeSnapshot();topRightText=v;saveState();closeModal('topRightTextModal');render();}
function clearTopRightText(){if(IS_READONLY){closeModal('topRightTextModal');return;}takeSnapshot();topRightText='';saveState();closeModal('topRightTextModal');render();}
// open the non-editable Official DoView Badge Standards-Compliant Board Structure information popup. Modal content is static (declared in buildHTML) and contains only a Close button. (badge wording reordered; popup heading now reads "Official DoView® Badge Standards-Compliant Board Structure".)
function openOfficialBadgeModal(){openModal('officialBadgeModal');}
function buildStateText(){const total=Object.keys(B).length;const green=Object.values(B).filter(v=>v.light==='green').length;const yellow=Object.values(B).filter(v=>v.light==='yellow').length;const red=Object.values(B).filter(v=>v.light==='red').length;let txt=`DOVIEW-STATE: ${TITLE}\nSlug: ${SLUG}\nSummary: ${green}/${total} green · ${yellow} yellow · ${red} red\n\nSTRUCTURE:\n`;SP.forEach(sp=>{txt+=`\n[${sp.id}] ${sp.label} | type:${sp.pageType||'this_then'}${sp.pageType==='how'&&sp.nextHowNum?' | nextHowNum:'+sp.nextHowNum:''}${sp.pageType==='how'&&sp.howLevel?' | howLevel:'+sp.howLevel:''} | bg:${spColor(sp).bg} bdr:${spColor(sp).bdr} tab:${spColor(sp).tab}\n`;if(sp.pageType==='how'&&sp.howBoxes){sp.howBoxes.forEach(hb=>{const k=`${sp.id}-${hb.id}`;const b=B[k];if(!b)return;const pri=b.priority?` | Pri:${b.priority}`:'';const entries=b.entries.length?' | '+b.entries.map(e=>`[${e.type}] ${e.text}`).join(' | '):'';const dt=b.detailText?` | [detail] ${b.detailText}`:'';const bc=b.borderColor?` | [border] ${b.borderColor}`:'';txt+=` ${k} | ${b.light}${pri} | ${b.label}${entries}${dt}${bc}\n`;});}else{sp.cols.forEach((col,ci)=>{txt+=` Column ${ci+1}: "${col.h}"\n`;col.boxes.forEach((_,bi)=>{const k=`${sp.id}-c${ci}-b${bi}`;const b=B[k];if(!b)return;const pri=b.priority?` | Pri:${b.priority}`:'';const entries=b.entries.length?' | '+b.entries.map(e=>`[${e.type}] ${e.text}`).join(' | '):'';const dt=b.detailText?` | [detail] ${b.detailText}`:'';const bc=b.borderColor?` | [border] ${b.borderColor}`:'';txt+=` ${k} | ${b.light}${pri} | ${b.label}${entries}${dt}${bc}\n`;});});}});txt+=`\nFINAL OUTCOMES:\n`;FO_DATA.forEach((label,i)=>{const k=`final-b${i}`;const b=B[k];if(!b)return;const pri=b.priority?` | Pri:${b.priority}`:'';const entries=b.entries.length?' | '+b.entries.map(e=>`[${e.type}] ${e.text}`).join(' | '):'';const dt=b.detailText?` | [detail] ${b.detailText}`:'';const bc=b.borderColor?` | [border] ${b.borderColor}`:'';txt+=` ${k} | ${b.light}${pri} | ${label}${entries}${dt}${bc}\n`;});if(boardInfo)txt+=`\nBOARD INFO:\n${boardInfo}\n`;if(topRightText)txt+=`\nTOP RIGHT TEXT:\n${topRightText}\n`;const piKeys=Object.keys(pageInfo).filter(k=>pageInfo[k]);if(piKeys.length){txt+=`\nPAGE INFO:\n`;piKeys.forEach(pid=>{let label=pid;if(pid==='overview')label='Overview';else if(pid==='final')label='Final Outcomes';else{const sp=SP.find(s=>s.id===pid);if(sp)label=sp.label;}txt+=` [${label} (${pid})]: ${pageInfo[pid]}\n`;});}const _bstDcKeys=Object.keys(docContent).filter(k=>docContent[k]);if(_bstDcKeys.length){txt+=`\nDOCUMENTATION CONTENT:\n`;_bstDcKeys.forEach(pid=>{let label=pid;const sp=SP.find(s=>s.id===pid);if(sp)label=sp.label;txt+=` [${label} (${pid})]: ${docContent[pid]}\n`;});}if(ttLinks.length){txt+=`\nTHIS-THEN LINKS:\n`;ttLinks.forEach(l=>{const fb=B[l.from];const tb=B[l.to];if(fb&&tb)txt+=` ${l.id} | ${l.from} -> ${l.to} | ${fb.label} -> ${tb.label}${l.mainText?' | main:'+l.mainText:''}${l.notes1?' | n1:'+l.notes1:''}${l.notes2?' | n2:'+l.notes2:''}${l.notes3?' | n3:'+l.notes3:''}${l.measures&&l.measures.length?' | measures:'+l.measures.join(','):''}${l.evalQuestions&&l.evalQuestions.length?' | evalQuestions:'+l.evalQuestions.join(','):''}\n`;});txt+=` nextId: ${ttLinkNextId}\n`;}if(howLinks.length){txt+=`\nHOW LINKS:\n`;howLinks.forEach(l=>{const fb=B[l.from];const tb=B[l.to];if(fb&&tb)txt+=` ${l.id} | ${l.from} -> ${l.to} | ${fb.label} -> ${tb.label}${l.mainText?' | main:'+l.mainText:''}${l.notes1?' | n1:'+l.notes1:''}${l.notes2?' | n2:'+l.notes2:''}${l.notes3?' | n3:'+l.notes3:''}\n`;});txt+=` nextId: ${howLinkNextId}\n`;}if(measures.length){txt+=`\nMEASURES:\n`;measures.forEach(m=>{const cnt=getBoxesLinkedToMeasure(m.id).length;txt+=` ${m.id} | ${m.title} | linked:${cnt}${m.mainText?' | main:'+m.mainText:''}${m.notes1?' | n1:'+m.notes1:''}${m.notes2?' | n2:'+m.notes2:''}${m.notes3?' | n3:'+m.notes3:''}\n`;});txt+=` nextId: ${measureNextId}\n`;}if(evalQuestions.length){txt+=`\nEVALUATION QUESTIONS:\n`;evalQuestions.forEach(q=>{const cnt=getBoxesLinkedToEQ(q.id).length;txt+=` ${q.id} | ${q.questionText} | linked:${cnt}${q.mainText?' | main:'+q.mainText:''}${q.notes1?' | n1:'+q.notes1:''}${q.notes2?' | n2:'+q.notes2:''}${q.notes3?' | n3:'+q.notes3:''}\n`;});txt+=` nextId: ${eqNextId}\n`;}txt+=`\nBOX MEASURES/EQ LINKS:\n`;Object.keys(B).forEach(k=>{const b=B[k];if(b.measures&&b.measures.length)txt+=` ${k} measures: ${b.measures.join(',')}\n`;if(b.evalQuestions&&b.evalQuestions.length)txt+=` ${k} evalQuestions: ${b.evalQuestions.join(',')}\n`;});txt+=`\nVIEW SETTINGS:\n thisThen: showCounts=${viewSettings.thisThen.showCounts} showTrafficLights=${viewSettings.thisThen.showTrafficLights} showPriorities=${viewSettings.thisThen.showPriorities} showHowCounts=${viewSettings.thisThen.showHowCounts} showMeasures=${viewSettings.thisThen.showMeasures} showEvalQuestions=${viewSettings.thisThen.showEvalQuestions} showMainText=${viewSettings.thisThen.showMainText}\n how: showNumbering=${viewSettings.how.showNumbering} showTrafficLights=${viewSettings.how.showTrafficLights} showPriorities=${viewSettings.how.showPriorities} showWhyCounts=${viewSettings.how.showWhyCounts} showLateralHow=${viewSettings.how.showLateralHow!==false} showMeasures=${viewSettings.how.showMeasures} showEvalQuestions=${viewSettings.how.showEvalQuestions} showMainText=${viewSettings.how.showMainText}\n finalOutcomes: showTrafficLights=${viewSettings.finalOutcomes.showTrafficLights} showPriorities=${viewSettings.finalOutcomes.showPriorities} showMeasures=${viewSettings.finalOutcomes.showMeasures} showEvalQuestions=${viewSettings.finalOutcomes.showEvalQuestions} showMainText=${viewSettings.finalOutcomes.showMainText}\n`;txt+=`\n---\nWhen the user types 'redraw doview', rebuild this board using the uploaded doview-board-engine.js file. Reconstruct the full config from the structure above: each [pN] block is a subpage with its colors, each "Column N" is a column with heading, each indented line is a box with its ID, light state, priority, label, and entries. Pass all states via savedState in the DoView.init() config. Include howLinks and howLinkNextId in savedState if HOW LINKS section is present. Include viewSettings in savedState if VIEW SETTINGS section is present.`;return txt;}
var _buildStateTextBase=buildStateText;
buildStateText=function(){var txt=_buildStateTextBase();txt=txt.replace(/(thisThen:[^\n]*showMainText=[^\n]*)\n/,function(_,line){return line+' showFullMainText='+viewSettings.thisThen.showFullMainText+' showMainTextCodeStyle='+viewSettings.thisThen.showMainTextCodeStyle+' showLinkInfoOnHover='+viewSettings.thisThen.showLinkInfoOnHover+'\n';});txt=txt.replace(/( how:[^\n]*showMainText=[^\n]*)\n/,function(_,line){return line+' showFullMainText='+viewSettings.how.showFullMainText+' showMainTextCodeStyle='+viewSettings.how.showMainTextCodeStyle+'\n';});txt=txt.replace(/( finalOutcomes:[^\n]*showMainText=[^\n]*)\n/,function(_,line){return line+' showFullMainText='+viewSettings.finalOutcomes.showFullMainText+' showMainTextCodeStyle='+viewSettings.finalOutcomes.showMainTextCodeStyle+'\n';});function escRe(s){return String(s).replace(/[.*+?^${}()|[\]\\]/g,'\\$&');}(ttLinks||[]).forEach(function(l){var lt=linkTrafficValue(l);if(!lt)return;var re=new RegExp('(^ '+escRe(l.id)+' \\| [^\\n]*)(\\n)','m');txt=txt.replace(re,function(m,line,nl){return line.indexOf(' | light:')>=0?m:line+' | light:'+lt+nl;});});return txt;};
function saveToClaud(){if(IS_READONLY)return;const txt=buildStateText();if(typeof sendPrompt==='function'){sendPrompt(txt);const btn=document.getElementById('saveBtn2');if(btn){btn.textContent='\u2713 Sent';btn.style.background='#22c55e';btn.style.color='#fff';btn.style.borderColor='#22c55e';setTimeout(()=>{btn.textContent='Update board changes to Main AI Chat';btn.style.background='';btn.style.color='';btn.style.borderColor='';},2000);}}else{document.getElementById('updTa').value=txt;document.getElementById('cpyBtn').textContent='Select All and Copy';document.getElementById('cpyBtn').className='copy-btn';openModal('updModal');}}
function copyState(){const ta=document.getElementById('updTa');ta.select();document.execCommand('copy');const btn=document.getElementById('cpyBtn');btn.textContent='✓ Copied!';btn.className='copy-btn copied';setTimeout(()=>{btn.textContent='Select All and Copy';btn.className='copy-btn';},2000);}
function getPageObjectKeys(pid){return Object.keys(B||{}).filter(function(k){return k.indexOf(pid+'-')===0;});}
function _copySourceMeta(obj,type,label,meta){obj.sourceUid=(obj.uid||obj.sourceUid||'');obj.sourceBoardUid=meta.sourceBoardUid||'';obj.sourceBoardTitle=meta.sourceBoardTitle||'';obj.sourceObjectType=type||'';obj.sourceObjectLabelAtCopy=label||'';obj.copiedAt=meta.copiedAt||'';return obj;}
function _cloneForPageClipboard(o){return JSON.parse(JSON.stringify(o||{}));}
function _collectPageSupportingContent(boxKeys,links){var mSet={},qSet={};boxKeys.forEach(function(k){var b=B[k];(b&&b.measures||[]).forEach(function(id){mSet[id]=true;});(b&&b.evalQuestions||[]).forEach(function(id){qSet[id]=true;});});(links||[]).forEach(function(l){(l.measures||[]).forEach(function(id){mSet[id]=true;});(l.evalQuestions||[]).forEach(function(id){qSet[id]=true;});});return {measures:(measures||[]).filter(function(m){return mSet[m.id];}).map(_cloneForPageClipboard),evalQuestions:(evalQuestions||[]).filter(function(q){return qSet[q.id];}).map(_cloneForPageClipboard)};}
function buildCopiedPagePayload(){_backfillUids();ensureTagIdsOnSupportedElements();var sp=SP.find(function(s){return s.id===currentPage;});if(!sp)return null;var copiedAt=new Date().toISOString();var boxKeys=getPageObjectKeys(sp.id);var keySet={};boxKeys.forEach(function(k){keySet[k]=true;});var boxes={};boxKeys.forEach(function(k){var b=_cloneForPageClipboard(B[k]);_copySourceMeta(b,'box',b.label||'',{sourceBoardUid:boardUid||'',sourceBoardTitle:TITLE||'',copiedAt:copiedAt});boxes[k]=b;});var internalTT=[];var skipped=0;(ttLinks||[]).forEach(function(l){if(keySet[l.from]&&keySet[l.to]){var c=_cloneForPageClipboard(l);_copySourceMeta(c,'this_then_link',(B[l.from]?B[l.from].label:'')+' → '+(B[l.to]?B[l.to].label:''),{sourceBoardUid:boardUid||'',sourceBoardTitle:TITLE||'',copiedAt:copiedAt});internalTT.push(c);}else if(keySet[l.from]||keySet[l.to])skipped++;});var internalHow=[];(howLinks||[]).forEach(function(l){if(keySet[l.from]&&keySet[l.to]){var c=_cloneForPageClipboard(l);_copySourceMeta(c,'how_link',(B[l.from]?B[l.from].label:'')+' → '+(B[l.to]?B[l.to].label:''),{sourceBoardUid:boardUid||'',sourceBoardTitle:TITLE||'',copiedAt:copiedAt});internalHow.push(c);}else if(keySet[l.from]||keySet[l.to])skipped++;});var support=_collectPageSupportingContent(boxKeys,internalTT.concat(internalHow));support.measures.forEach(function(m){_copySourceMeta(m,'measure',m.title||m.id,{sourceBoardUid:boardUid||'',sourceBoardTitle:TITLE||'',copiedAt:copiedAt});});support.evalQuestions.forEach(function(q){_copySourceMeta(q,'evaluation_question',q.questionText||q.id,{sourceBoardUid:boardUid||'',sourceBoardTitle:TITLE||'',copiedAt:copiedAt});});var spCopy=_cloneForPageClipboard(sp);_copySourceMeta(spCopy,'page',sp.label||'',{sourceBoardUid:boardUid||'',sourceBoardTitle:TITLE||'',copiedAt:copiedAt});return {kind:'doview-page-clipboard',payloadVersion:'',page:spCopy,pageInfo:pageInfo[sp.id]||'',docContent:sanitizeDocHtml(docContent[sp.id]||''),boxes:boxes,ttLinks:internalTT,howLinks:internalHow,measures:support.measures,evalQuestions:support.evalQuestions,sourceBoardTitle:TITLE||'',sourceBoardUid:boardUid||'',sourceBoardVersion:'',copiedAt:copiedAt,skippedOutsideLinks:skipped};}
function copyCurrentPageForPasting(){if(IS_READONLY)return;var payload=buildCopiedPagePayload();if(!payload){showSaveToast('Only a normal page tab can be copied in this build.','#f59e0b');return;}var txt=JSON.stringify(payload);function ok(){showSaveToast('Page copied for pasting.','#22c55e');}function fail(){showSaveToast('The browser did not allow copying this page to the clipboard.','#f59e0b');}if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt).then(ok).catch(fail);}else{try{var ta=document.createElement('textarea');ta.value=txt;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta);ta.focus();ta.select();var done=document.execCommand('copy');document.body.removeChild(ta);done?ok():fail();}catch(e){fail();}}}
function _validateCopiedPagePayload(p){return !!(p&&p.kind==='doview-page-clipboard'&&p.page&&p.boxes);}
let pendingPastePagePayload=null;
async function pasteCopiedPage(){if(IS_READONLY)return;var txt='';try{if(!navigator.clipboard||!navigator.clipboard.readText){showSaveToast('The browser did not allow reading the clipboard.','#f59e0b');return;}txt=await navigator.clipboard.readText();}catch(e){showSaveToast('The browser did not allow reading the clipboard.','#f59e0b');return;}var payload=null;try{payload=JSON.parse(txt);}catch(e){}if(!_validateCopiedPagePayload(payload)){showSaveToast('No copied DoView page was found on the clipboard.','#f59e0b');return;}pendingPastePagePayload=payload;var boxCount=Object.keys(payload.boxes||{}).length;var linkCount=(payload.ttLinks||[]).length+(payload.howLinks||[]).length;var mCount=(payload.measures||[]).length;var qCount=(payload.evalQuestions||[]).length;var counts='
Will add copied page with links and what is associated with boxes but not links to what is outside the copied page.
Tag pasted boxes and internal This–Then links
Optionally apply a tag to boxes and internal This–Then links pasted with this page.
'+buildPasteTagControl();var el=document.getElementById('pastePagePreview');if(el)el.innerHTML=html;openModal('pastePageModal');}
function buildPasteTagControl(){var opts='';(tags||[]).forEach(function(t){opts+='';});return '
Tags:NoneManage tags
';}
function refreshPasteTagPicker(){var sel=document.getElementById('pasteTagSelect');if(!sel)return;var cur=sel.value;var chosen=(document.getElementById('pasteTagPicker')||{}).value||'';var opts='';(tags||[]).forEach(function(t){if(t.id!==chosen||t.id===cur)opts+='';});sel.innerHTML=opts;if(cur)sel.value=cur;updatePasteTagUI();}
function updatePasteTagUI(){var hid=document.getElementById('pasteTagPicker');var chip=document.getElementById('pasteSelectedTagChip');if(!hid||!chip)return;var t=getTag(hid.value);if(t){var sl=normalizeTagShortLabel(t.shortLabel||'');chip.innerHTML=''+escHtml(sl)+' ✕';}else{chip.innerHTML='None';}}
function selectPasteTagFromPicker(){if(IS_READONLY)return;var sel=document.getElementById('pasteTagSelect');var hid=document.getElementById('pasteTagPicker');if(!sel||!hid||!sel.value)return;hid.value=sel.value;sel.value='';refreshPasteTagPicker();updatePasteTagUI();}
function removePasteTagSelection(){if(IS_READONLY)return;var hid=document.getElementById('pasteTagPicker');if(hid)hid.value='';refreshPasteTagPicker();updatePasteTagUI();}
function createPasteTagAndSelect(){openTagsManager();}
function _newPastedPageId(){return nextUnusedPageId();}
function _uniquePastedPageTitle(base){base=String(base||'Pasted page').trim()||'Pasted page';var root=base.replace(/\s*\(copy(?: \d+)?\)$/i,'');var name=root+' (copy)';var n=2;var exists=function(x){return SP.some(function(s){return String(s.label||'')===x;});};while(exists(name)){name=root+' (copy '+(n++)+')';}return name;}
function _remapTextKey(k,oldPid,newPid){if(!k)return k;return String(k).replace(oldPid+'-',newPid+'-');}
function _setSourceForPastedObj(obj,type,label,payload){var oldUid=obj.uid||obj.sourceUid||'';obj.uid=_genUid(type==='page'?'page':type==='column'?'col':type.indexOf('link')>=0?'link':type==='measure'?'measure':type==='evaluation_question'?'eq':'box');obj.sourceUid=oldUid;obj.sourceBoardUid=payload.sourceBoardUid||'';obj.sourceBoardTitle=payload.sourceBoardTitle||'';obj.sourceObjectType=type||'';obj.sourceObjectLabelAtCopy=label||'';obj.copiedAt=payload.copiedAt||new Date().toISOString();return obj;}
function _mapIds(list,map){return (Array.isArray(list)?list:[]).map(function(id){return map[id];}).filter(Boolean);}
function doPasteCopiedPage(){if(IS_READONLY||!pendingPastePagePayload)return;var payload=pendingPastePagePayload;if(!_validateCopiedPagePayload(payload)){closeModal('pastePageModal');showSaveToast('No copied DoView page was found on the clipboard.','#f59e0b');return;}var pastedLevel=payload.page&&payload.page.pageType==='how'?normalizedVerticalHowLevel(payload.page.howLevel):null;if(pastedLevel!==null&&verticalHowPageAtLevel(pastedLevel)){alert('This copied How Page is Level '+pastedLevel+', but that numbered How Page level already exists. There is only one vertical How Page hierarchy. Paste or create lateral/cross-link How Pages with No level.');return;}takeSnapshot();var oldPid=payload.page.id;var newPid=_newPastedPageId();var tagId='';var sel=document.getElementById('pasteTagPicker');if(sel&&sel.value)tagId=sel.value;var measureMap={},eqMap={};(payload.measures||[]).forEach(function(m){var nm=_cloneForPageClipboard(m);var oldId=nm.id;nm.id='M'+String(measureNextId).padStart(3,'0');nm.displayId=displayMeasureId(nm.id);measureNextId++;_setSourceForPastedObj(nm,'measure',nm.title||oldId,payload);if(!Array.isArray(nm.tagIds))nm.tagIds=[];measureMap[oldId]=nm.id;measures.push(nm);});(payload.evalQuestions||[]).forEach(function(q){var nq=_cloneForPageClipboard(q);var oldId=nq.id;nq.id='EQ'+String(eqNextId).padStart(3,'0');nq.displayId=displayEQId(nq.id);eqNextId++;_setSourceForPastedObj(nq,'evaluation_question',nq.questionText||oldId,payload);if(!Array.isArray(nq.tagIds))nq.tagIds=[];eqMap[oldId]=nq.id;evalQuestions.push(nq);});var sp=_cloneForPageClipboard(payload.page);sp.id=newPid;var defaultPageName=_uniquePastedPageTitle(sp.label||'Pasted page');var nameEl=document.getElementById('pastePageName');var chosenPageName=nameEl?String(nameEl.value||'').trim():'';sp.label=chosenPageName||defaultPageName;_setSourceForPastedObj(sp,'page',sp.label,payload);if(Array.isArray(sp.cols)){sp.cols.forEach(function(c){_setSourceForPastedObj(c,'column',c.h||'',payload);});}if(Array.isArray(sp.howBoxes)){sp.howBoxes.forEach(function(hb){_setSourceForPastedObj(hb,'box',hb.label||'',payload);});}SP.push(sp);var keyMap={};Object.keys(payload.boxes||{}).forEach(function(oldKey){var newKey=_remapTextKey(oldKey,oldPid,newPid);keyMap[oldKey]=newKey;var b=_cloneForPageClipboard(payload.boxes[oldKey]);_setSourceForPastedObj(b,'box',b.label||'',payload);if(!Array.isArray(b.entries))b.entries=[];if(!Array.isArray(b.tagIds))b.tagIds=[];b.measures=_mapIds(b.measures,measureMap);b.evalQuestions=_mapIds(b.evalQuestions,eqMap);if(tagId&&!b.tagIds.includes(tagId))b.tagIds.push(tagId);B[newKey]=b;});(payload.ttLinks||[]).forEach(function(l){if(!keyMap[l.from]||!keyMap[l.to])return;var nl=_cloneForPageClipboard(l);nl.id='ttl_'+ttLinkNextId++;nl.from=keyMap[l.from];nl.to=keyMap[l.to];_setSourceForPastedObj(nl,'this_then_link',(B[nl.from]?B[nl.from].label:'')+' → '+(B[nl.to]?B[nl.to].label:''),payload);nl.measures=_mapIds(nl.measures,measureMap);nl.evalQuestions=_mapIds(nl.evalQuestions,eqMap);if(!Array.isArray(nl.tagIds))nl.tagIds=[];if(tagId&&!nl.tagIds.includes(tagId))nl.tagIds.push(tagId);ttLinks.push(nl);});(payload.howLinks||[]).forEach(function(l){if(!keyMap[l.from]||!keyMap[l.to])return;var nl=_cloneForPageClipboard(l);nl.id='hwl_'+howLinkNextId++;nl.from=keyMap[l.from];nl.to=keyMap[l.to];_setSourceForPastedObj(nl,'how_link',(B[nl.from]?B[nl.from].label:'')+' → '+(B[nl.to]?B[nl.to].label:''),payload);nl.measures=_mapIds(nl.measures,measureMap);nl.evalQuestions=_mapIds(nl.evalQuestions,eqMap);if(!Array.isArray(nl.tagIds))nl.tagIds=[];howLinks.push(nl);});pageInfo[newPid]=payload.pageInfo||'';docContent[newPid]=sanitizeDocHtml(payload.docContent||'');sp.sourceNote='Originally pasted from: '+(payload.sourceBoardTitle||'another DoView Board')+'\nThis page may have been amended since it was pasted.';buildDepsMap();cascade();saveState();closeModal('pastePageModal');pendingPastePagePayload=null;navTo(newPid);showSaveToast('Copied page pasted.','#22c55e');}
function copyBoardHTML(){/* backfill uids before serializing. */_backfillUids();normalizePrioritiesInMemory();normalizeTrafficLightsInMemory();normalizeFinalOutcomesInMemory();sanitizeCustomColorsInMemory();const cleanB={};Object.entries(B).forEach(([k,v])=>{cleanB[k]={label:v.label,light:v.light,entries:[...(Array.isArray(v.entries)?v.entries:[])],priority:serializedPriority(v.priority),hasSubpage:v.hasSubpage,detailText:v.detailText||'',borderColor:v.borderColor||'',boxColor:v.boxColor||'',measures:[...(v.measures||[])],evalQuestions:[...(v.evalQuestions||[])],tagIds:[...(v.tagIds||[])],jumpToPage:v.jumpToPage||'',/* persist box uid + optional provenance fields in Copy HTML Board. */uid:v.uid||'',sourceUid:v.sourceUid||'',sourceBoardUid:v.sourceBoardUid||'',sourceBoardTitle:v.sourceBoardTitle||'',sourceObjectType:v.sourceObjectType||'',sourceObjectLabelAtCopy:v.sourceObjectLabelAtCopy||'',copiedAt:v.copiedAt||''};});const fullConfig={format:DOVIEW_JSON_FORMAT,schemaVersion:DOVIEW_SCHEMA_VERSION,engineVersion:DOVIEW_ENGINE_VERSION,title:TITLE,slug:SLUG,subpages:JSON.parse(JSON.stringify(SP)),finalOutcomes:[...FO_DATA],sources:sources.length?JSON.parse(JSON.stringify(sources)):[],builderValidation:builderValidationForExport(),savedState:{B:cleanB,SP:JSON.parse(JSON.stringify(SP)),FO:[...FO_DATA],boardInfo:boardInfo,pageInfo:JSON.parse(JSON.stringify(pageInfo)),docContent:sanitizedDocContentState(),createdDate:createdDate,boardInstanceId:boardInstanceId,/* preserve boardUid in Copy HTML Board because a copied HTML is a saved version of the same board. */boardUid:boardUid||'',ttLinks:JSON.parse(JSON.stringify(ttLinks)),ttLinkNextId:ttLinkNextId,howLinks:JSON.parse(JSON.stringify(howLinks)),howLinkNextId:howLinkNextId,measures:JSON.parse(JSON.stringify(measures)),measureNextId:measureNextId,evalQuestions:JSON.parse(JSON.stringify(evalQuestions)),eqNextId:eqNextId,tags:JSON.parse(JSON.stringify(tags||[])),viewSettings:JSON.parse(JSON.stringify(viewSettings)),topRightText:topRightText||'',presentationView:!!presentationView,/* persist sourcesInitialized in copy/exported HTML so the default seed is not re-applied to a board the user has already removed it from. */sourcesInitialized:!!sourcesInitialized},aiEndpoint:aiSettings.endpoint||'',aiModel:aiSettings.model||''};const configJson=escapeScriptJson(JSON.stringify(fullConfig));let engineCode=_ENGINE_SRC;engineCode=engineCode.replace(/let EMBEDDED_STATE[\s\S]*?let IS_READONLY[\s\S]*?;\nconst _ENGINE_SRC/,`let EMBEDDED_STATE = ${configJson};\nlet IS_READONLY = false;\nconst _ENGINE_SRC`);const fullHtml='\n\n\n\n\n\n'+standaloneBoardTitleHtml()+'<\/title>\n