/* Chladni Plant Builder - Webflow asset build. Generated by apps/plant-builder/scripts/build-webflow.mjs from dist/plant-builder.css and dist/plant-builder.js. Do not edit this file; edit the sources and rebuild. The app code is carried as an escaped string (no HTML-looking bytes) so the Webflow uploader accepts it; the loader below executes it in global scope. */ (function(){ if (window.__chladniPlantBuilderLoaded) return; window.__chladniPlantBuilderLoaded = true; var s = document.createElement("script"); s.text = "(function(){\n if (document.getElementById(\"chladni-plant-builder-css\")) return;\n var s = document.createElement(\"style\");\n s.id = \"chladni-plant-builder-css\";\n s.appendChild(document.createTextNode(\"\\n #chladni-plant-builder{ --ink:#0E0E0E; --paper:#FFFFFF; --panel:#F4F4F4; --muted:#8A8A8A; --hairline:#E2E2E2; }\\n #chladni-plant-builder, #chladni-plant-builder *{ margin:0; padding:0; box-sizing:border-box; }\\n @media (prefers-reduced-motion: reduce) { #chladni-plant-builder, #chladni-plant-builder *{ transition:none !important; } }\\n #chladni-plant-builder{ background:var(--paper); color:var(--ink); font-family:Arial,Helvetica,sans-serif; font-size:15px; line-height:1.5; }\\n\\n #chladni-plant-builder .topbar{ background:var(--ink); color:#fff; display:flex; align-items:center; justify-content:space-between; gap:6px 16px; flex-wrap:wrap; padding:14px 20px; }\\n #chladni-plant-builder .topbar b{ font-size:14px; letter-spacing:1px; }\\n #chladni-plant-builder .topbar span{ color:#cfcfcf; font-size:12px; }\\n /* v14: mobile responsiveness pass - below ~480px the title and the plant-name link no longer\\n fit side by side without wrapping mid-word into an overlapping mess (client-reported bug).\\n Stacking them into their own full-width rows, left-aligned, is simpler and more reliable\\n than trying to shrink text to fit one line on every device width. */\\n @media (max-width:480px){\\n #chladni-plant-builder .topbar{ flex-direction:column; align-items:flex-start; padding:12px 16px; }\\n }\\n\\n\\n #chladni-plant-builder{ overflow-x: clip; max-width: 100%; }\\n #chladni-plant-builder .appgrid{ display:grid; grid-template-columns:340px 1fr; min-height:calc(100vh - 46px); }\\n @media (max-width:880px){ #chladni-plant-builder .appgrid{ grid-template-columns:1fr; } }\\n\\n /* ---- left: asset hierarchy tree ---- */\\n #chladni-plant-builder .tree{ background:var(--panel); border-right:1px solid var(--hairline); padding:18px 16px; overflow-y:auto; }\\n #chladni-plant-builder .tree h2{ font-size:11px; letter-spacing:2.5px; text-transform:uppercase; font-weight:700; color:var(--muted); margin-bottom:14px; }\\n #chladni-plant-builder .treeEmpty{ font-size:13px; color:var(--muted); padding:18px 4px; line-height:1.6; }\\n #chladni-plant-builder .assetNode{ background:#fff; border:1.5px solid var(--hairline); padding:12px 13px; margin-bottom:10px; cursor:pointer; }\\n #chladni-plant-builder .assetNode:hover, #chladni-plant-builder .assetNode.sel{ border-color:var(--ink); }\\n #chladni-plant-builder .assetNode .an-top{ display:flex; justify-content:space-between; align-items:flex-start; gap:8px; }\\n #chladni-plant-builder .assetNode .an-name{ font-weight:900; font-size:14px; line-height:1.25; }\\n #chladni-plant-builder .assetNode .an-type{ font-size:11px; color:var(--muted); margin-top:2px; }\\n #chladni-plant-builder .assetNode .an-badges{ margin-top:8px; display:flex; gap:5px; flex-wrap:wrap; }\\n #chladni-plant-builder .badge{ font-size:9px; letter-spacing:1px; font-weight:700; padding:2.5px 6px; text-transform:uppercase; background:var(--panel); color:#555; }\\n #chladni-plant-builder .badge.crit-very{ background:var(--ink); color:#fff; }\\n #chladni-plant-builder .badge.crit-high{ background:#3a3a3a; color:#fff; }\\n #chladni-plant-builder .badge.legacy{ background:#fff; border:1px solid var(--ink); }\\n #chladni-plant-builder .badge.incomplete{ background:#fff; border:1px solid #b02020; color:#b02020; }\\n #chladni-plant-builder .an-row{ display:flex; align-items:center; justify-content:space-between; margin-top:10px; padding-top:9px; border-top:1px solid var(--hairline); }\\n #chladni-plant-builder .stepper{ display:flex; align-items:center; gap:6px; }\\n #chladni-plant-builder .stepper button{ width:22px; height:22px; border:1px solid #cfcfcf; background:#fff; font-weight:900; cursor:pointer; line-height:1; font-family:Arial; }\\n #chladni-plant-builder .stepper button:hover{ border-color:var(--ink); }\\n #chladni-plant-builder .stepper .sn{ font-weight:700; font-size:13px; min-width:16px; text-align:center; }\\n #chladni-plant-builder .an-actions{ display:flex; gap:10px; }\\n #chladni-plant-builder .an-actions button{ background:none; border:none; font-size:11px; font-weight:700; letter-spacing:0.5px; color:var(--muted); cursor:pointer; text-decoration:underline; font-family:Arial; }\\n #chladni-plant-builder .an-actions button:hover{ color:var(--ink); }\\n #chladni-plant-builder .an-price{ font-size:12px; color:#555; margin-top:6px; }\\n #chladni-plant-builder .addAssetBtn{ width:100%; margin-top:6px; background:#fff; border:2px dashed #bdbdbd; padding:14px; font-family:Arial; font-size:13px; font-weight:700; letter-spacing:0.5px; cursor:pointer; }\\n #chladni-plant-builder .addAssetBtn:hover{ border-color:var(--ink); }\\n #chladni-plant-builder .treeTotals{ margin-top:16px; padding-top:14px; border-top:1.5px solid var(--ink); }\\n #chladni-plant-builder .treeTotals .tt-row{ display:flex; justify-content:space-between; font-size:13px; margin-bottom:6px; }\\n #chladni-plant-builder .treeTotals .tt-price{ font-size:20px; font-weight:900; letter-spacing:-0.5px; }\\n\\n /* ---- right: centered working card ---- */\\n #chladni-plant-builder .cardOuter{ display:flex; justify-content:center; padding:26px 28px 60px; }\\n #chladni-plant-builder .card{ width:100%; max-width:720px; text-align:left; }\\n #chladni-plant-builder .crumb{ font-size:11px; letter-spacing:2px; text-transform:uppercase; color:var(--muted); font-weight:700; margin-bottom:6px; text-align:left; }\\n #chladni-plant-builder .cardTitle{ font-size:24px; font-weight:900; letter-spacing:-0.6px; margin-bottom:18px; text-align:left; }\\n\\n #chladni-plant-builder .search input{ width:100%; font-family:Arial; font-size:15px; padding:12px 14px; border:2px solid var(--ink); }\\n #chladni-plant-builder .search input:focus{ outline:none; }\\n #chladni-plant-builder .cat{ margin-top:20px; }\\n #chladni-plant-builder .cat h3{ font-size:11px; letter-spacing:2.5px; text-transform:uppercase; font-weight:700; color:var(--muted); margin-bottom:9px; text-align:left; }\\n #chladni-plant-builder .mgrid{ display:grid; grid-template-columns:repeat(2,1fr); gap:8px; }\\n @media (min-width:560px){ #chladni-plant-builder .mgrid{ grid-template-columns:repeat(3,1fr); } }\\n #chladni-plant-builder .mcardbtn{ font-family:Arial; font-size:13px; font-weight:700; color:var(--ink); background:#fff; border:1.5px solid #cfcfcf; padding:12px 10px; cursor:pointer; text-align:left; line-height:1.3; }\\n #chladni-plant-builder .mcardbtn:hover{ border-color:var(--ink); }\\n #chladni-plant-builder .mcardbtn.on{ border-color:var(--ink); background:var(--ink); color:#fff; }\\n\\n #chladni-plant-builder .fld{ margin-bottom:18px; text-align:left; }\\n #chladni-plant-builder .fldLabelRow{ display:flex; align-items:center; gap:6px; margin-bottom:7px; }\\n #chladni-plant-builder .fldLabelRow label{ font-size:11px; letter-spacing:1.5px; text-transform:uppercase; font-weight:700; color:var(--muted); }\\n #chladni-plant-builder .fldReq{ color:#b02020; }\\n #chladni-plant-builder .infoIcon{ width:16px; height:16px; border-radius:50%; border:1.3px solid var(--muted); color:var(--muted); font-size:10.5px; font-weight:900; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; background:#fff; font-family:Arial; line-height:1; flex:none; }\\n #chladni-plant-builder .infoIcon:hover, #chladni-plant-builder .infoIcon:focus-visible{ border-color:var(--ink); color:var(--ink); outline:none; }\\n #chladni-plant-builder .fld input[type=text], #chladni-plant-builder .fld input[type=number], #chladni-plant-builder .fld input[type=email], #chladni-plant-builder .fld select{ width:100%; font-family:Arial; font-size:15px; padding:11px 12px; border:1px solid #cfcfcf; background:#fff; text-align:left; }\\n #chladni-plant-builder .fld input.errField{ border-color:#b02020; }\\n #chladni-plant-builder .fld input:focus, #chladni-plant-builder .fld select:focus{ outline:none; border-color:var(--ink); }\\n #chladni-plant-builder .fldErr{ font-size:12px; color:#b02020; font-weight:700; margin-top:6px; }\\n #chladni-plant-builder .tip{ position:fixed; max-width:min(280px, calc(100vw - 20px)); background:var(--ink); color:#fff; font-size:12.5px; line-height:1.5; padding:10px 28px 10px 12px; z-index:50; box-shadow:0 6px 18px rgba(0,0,0,.25); display:none; text-align:left; }\\n #chladni-plant-builder .tipClose{ position:absolute; top:4px; right:6px; width:22px; height:22px; border:none; background:none; color:#fff; font-size:16px; line-height:1; cursor:pointer; font-family:Arial; }\\n #chladni-plant-builder .tip.on{ display:block; }\\n\\n #chladni-plant-builder .opts{ display:grid; grid-template-columns:repeat(2,1fr); gap:8px; }\\n #chladni-plant-builder .opts.row4{ grid-template-columns:repeat(4,1fr); }\\n #chladni-plant-builder .opts.row2{ grid-template-columns:repeat(2,1fr); }\\n @media (min-width:520px){ #chladni-plant-builder .opts.wide{ grid-template-columns:repeat(3,1fr); } }\\n @media (max-width:640px){ #chladni-plant-builder .opts.row4{ grid-template-columns:repeat(2,1fr); } }\\n #chladni-plant-builder .opt{ font-family:Arial; background:#fff; border:1.5px solid #cfcfcf; padding:11px 9px; cursor:pointer; text-align:center; font-size:12.5px; font-weight:700; }\\n #chladni-plant-builder .opt:hover{ border-color:var(--ink); }\\n #chladni-plant-builder .opt.on{ border-color:var(--ink); background:var(--ink); color:#fff; }\\n #chladni-plant-builder .opt .od{ display:block; font-size:10px; font-weight:400; opacity:0.75; margin-top:3px; }\\n\\n #chladni-plant-builder .toggleRow{ display:flex; align-items:center; gap:10px; cursor:pointer; user-select:none; margin:22px 0 16px; padding:12px 14px; background:var(--panel); }\\n #chladni-plant-builder .toggleRow .tSwitch{ width:36px; height:20px; border-radius:10px; background:#cfcfcf; position:relative; flex:none; }\\n #chladni-plant-builder .toggleRow .tSwitch::after{ content:\\\"\\\"; width:16px; height:16px; border-radius:50%; background:#fff; position:absolute; top:2px; left:2px; transition:left .12s; }\\n #chladni-plant-builder .toggleRow.on .tSwitch{ background:var(--ink); }\\n #chladni-plant-builder .toggleRow.on .tSwitch::after{ left:18px; }\\n #chladni-plant-builder .toggleRow span.tLabel{ font-size:13.5px; font-weight:700; }\\n #chladni-plant-builder .advBlock{ display:none; border-left:2px solid var(--hairline); padding-left:16px; margin-bottom:18px; }\\n #chladni-plant-builder .advBlock.on{ display:block; }\\n\\n #chladni-plant-builder .driverCard{ border:1.5px solid var(--hairline); padding:14px 16px; margin-bottom:12px; }\\n /* v11: the driven-unit \\\"More about this \u003ctype\u003e\\\" card renders as a sibling of .tabPanel, not\\n nested inside it - this margin is what actually creates the visible gap that reads as a\\n separate, detached section rather than a box drawn inside another box. */\\n #chladni-plant-builder .detachedCard{ margin-top:16px; margin-bottom:0; }\\n #chladni-plant-builder .driverCard .dcHead{ display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }\\n #chladni-plant-builder .driverCard .dcHead b{ font-size:13px; }\\n #chladni-plant-builder .driverCard .dcRemove{ background:none; border:none; color:var(--muted); font-size:11px; font-weight:700; text-decoration:underline; cursor:pointer; font-family:Arial; }\\n #chladni-plant-builder .addDriverBtn, #chladni-plant-builder .addInterBtn{ background:#fff; border:1.5px dashed #bdbdbd; padding:10px 14px; font-family:Arial; font-size:12.5px; font-weight:700; cursor:pointer; margin-bottom:16px; }\\n #chladni-plant-builder .addDriverBtn:hover, #chladni-plant-builder .addInterBtn:hover{ border-color:var(--ink); }\\n\\n #chladni-plant-builder .btn{ display:inline-block; background:var(--ink); color:#fff; border:2px solid var(--ink); font-family:Arial; font-size:13px; font-weight:700; letter-spacing:1.5px; text-transform:uppercase; padding:13px 26px; cursor:pointer; }\\n #chladni-plant-builder .btn:hover{ background:#fff; color:var(--ink); }\\n #chladni-plant-builder .btn.ghost{ background:#fff; color:var(--ink); }\\n #chladni-plant-builder .btn.ghost:hover{ background:var(--ink); color:#fff; }\\n #chladni-plant-builder .btn:disabled{ opacity:0.35; cursor:not-allowed; }\\n #chladni-plant-builder .btn:disabled:hover{ background:var(--ink); color:#fff; }\\n #chladni-plant-builder .btnrow{ display:flex; gap:10px; flex-wrap:wrap; margin-top:22px; justify-content:space-between; }\\n\\n #chladni-plant-builder .sectionHead{ font-size:11px; letter-spacing:2.5px; text-transform:uppercase; font-weight:700; color:var(--muted); margin:24px 0 12px; padding-top:18px; border-top:1px solid var(--hairline); text-align:left; }\\n #chladni-plant-builder .sectionHead:first-child{ border-top:none; padding-top:0; margin-top:0; }\\n\\n #chladni-plant-builder .quoteTable{ width:100%; border-collapse:collapse; margin-top:10px; }\\n #chladni-plant-builder .quoteTable th, #chladni-plant-builder .quoteTable td{ text-align:left; padding:9px 10px; border-bottom:1px solid var(--hairline); font-size:13px; }\\n #chladni-plant-builder .quoteTable th{ font-size:10.5px; letter-spacing:1.5px; text-transform:uppercase; color:var(--muted); font-weight:700; }\\n #chladni-plant-builder .quoteTable td.num{ text-align:right; font-variant-numeric:tabular-nums; }\\n #chladni-plant-builder .reviewBlockHead td{ background:var(--panel); font-size:10.5px; letter-spacing:1.5px; text-transform:uppercase; color:var(--muted); font-weight:700; padding:7px 10px; }\\n #chladni-plant-builder .puckOverrideInp{ width:56px; font-family:Arial; font-size:13px; text-align:right; padding:5px 6px; border:1px solid #cfcfcf; }\\n #chladni-plant-builder .puckOverrideInp:focus{ outline:none; border-color:var(--ink); }\\n #chladni-plant-builder .quoteBig{ font-size:30px; font-weight:900; letter-spacing:-1px; margin-top:14px; text-align:left; }\\n #chladni-plant-builder .hint{ font-size:12.5px; color:var(--muted); margin-top:6px; text-align:left; }\\n #chladni-plant-builder .scopeNote{ border:1.5px solid #cfcfcf; background:var(--panel); padding:12px 13px; font-size:12.5px; line-height:1.5; color:var(--ink); margin:10px 0 16px; text-align:left; }\\n #chladni-plant-builder .scopeNote b{ font-weight:700; }\\n #chladni-plant-builder .checkline{ display:flex; align-items:center; gap:9px; border:1.5px solid #cfcfcf; padding:12px 13px; cursor:pointer; font-size:13px; font-weight:700; background:#fff; margin-bottom:16px; }\\n #chladni-plant-builder .checkline input{ width:16px; height:16px; }\\n #chladni-plant-builder .checkline.on{ border-color:var(--ink); }\\n\\n /* ---- tabbed drivetrain workflow ---- */\\n #chladni-plant-builder .tabbar{ display:flex; gap:0; margin:8px 0 0; border-bottom:2px solid var(--ink); }\\n #chladni-plant-builder .tabbtn{ flex:1; background:var(--panel); border:none; border-bottom:3px solid transparent; font-family:Arial; font-size:11.5px; font-weight:700; letter-spacing:0.5px; text-transform:uppercase; padding:12px 6px; cursor:pointer; color:var(--muted); text-align:center; }\\n #chladni-plant-builder .tabbtn .tn{ display:block; font-size:9px; letter-spacing:1px; opacity:0.7; margin-bottom:3px; }\\n #chladni-plant-builder .tabbtn.active{ background:#fff; color:var(--ink); border-bottom-color:var(--ink); }\\n #chladni-plant-builder .tabbtn.done:not(.active){ color:var(--ink); }\\n #chladni-plant-builder .tabbtn.done .tn::after{ content:\\\" \\\\2713\\\"; }\\n #chladni-plant-builder .tabbtn:disabled{ cursor:not-allowed; opacity:0.4; }\\n /* v14: 5 equal-width tabs plus their block-number sub-label get tight on a phone - shrink text\\n and padding further rather than letting any tab's label collide with its neighbor. */\\n @media (max-width:480px){\\n #chladni-plant-builder .tabbtn{ font-size:9.5px; padding:9px 3px; }\\n #chladni-plant-builder .tabbtn .tn{ font-size:7.5px; }\\n }\\n #chladni-plant-builder .tabPanel{ border:1.5px solid var(--hairline); border-top:none; padding:22px 20px; margin-bottom:0; }\\n @media (max-width:480px){ #chladni-plant-builder .tabPanel{ padding:16px 14px; } }\\n #chladni-plant-builder .lockNote{ font-size:12.5px; color:var(--muted); background:var(--panel); padding:12px 14px; margin:18px 0; text-align:left; }\\n\\n /* ---- plant hierarchy grouping ---- */\\n #chladni-plant-builder .zoneHead{ display:flex; align-items:center; justify-content:space-between; font-size:11px; letter-spacing:1.5px; text-transform:uppercase; font-weight:700; color:var(--ink); margin:16px 0 8px; padding-top:12px; border-top:1px solid var(--hairline); cursor:pointer; user-select:none; }\\n #chladni-plant-builder .zoneHead:first-child{ border-top:none; padding-top:0; margin-top:0; }\\n #chladni-plant-builder .zoneHead:hover{ color:#555; }\\n #chladni-plant-builder .zoneCaret{ font-size:10px; }\\n #chladni-plant-builder .zoneGroup{ padding-left:8px; border-left:1.5px solid var(--hairline); margin-left:2px; }\\n #chladni-plant-builder .treePlantInfo{ margin-bottom:16px; padding-bottom:14px; border-bottom:1.5px solid var(--ink); }\\n #chladni-plant-builder .treePlantInfo .tp-company{ font-weight:900; font-size:15px; line-height:1.3; }\\n #chladni-plant-builder .treePlantInfo .tp-plant{ font-size:12.5px; color:#555; margin-top:2px; }\\n #chladni-plant-builder .treePlantInfo .tp-meta{ font-size:10.5px; color:var(--muted); margin-top:5px; text-transform:uppercase; letter-spacing:0.5px; }\\n #chladni-plant-builder .topbarLink{ color:#fff; text-decoration:underline; cursor:pointer; font-size:12px; background:none; border:none; font-family:Arial; }\\n #chladni-plant-builder .topbarLink:hover{ color:#cfcfcf; }\\n\\n #chladni-plant-builder .bigInput{ font-size:17px !important; padding:15px 14px !important; }\\n\\n /* ---- v10: lead-gated pricing + quote export + shareable link ---- */\\n #chladni-plant-builder .lockedPrice{ font-size:13px; color:var(--muted); font-weight:700; }\\n #chladni-plant-builder .lockLink{ display:block; font-size:11.5px; text-decoration:underline; color:var(--ink); background:none; border:none; cursor:pointer; font-family:Arial; margin-top:4px; padding:0; text-align:left; }\\n #chladni-plant-builder .quoteActions{ display:flex; flex-direction:column; gap:8px; margin-top:14px; }\\n #chladni-plant-builder .quoteActions .btn{ text-align:center; font-size:11.5px; padding:11px 10px; }\\n #chladni-plant-builder .resumeBanner{ background:var(--ink); color:#fff; padding:12px 16px; display:flex; align-items:center; justify-content:space-between; gap:8px 14px; flex-wrap:wrap; font-size:13px; }\\n #chladni-plant-builder .resumeBanner button{ font-family:Arial; font-size:11.5px; font-weight:700; letter-spacing:0.5px; text-transform:uppercase; padding:8px 14px; cursor:pointer; border:1.5px solid #fff; background:none; color:#fff; }\\n #chladni-plant-builder .resumeBanner button:hover{ background:#fff; color:var(--ink); }\\n\\n #chladni-plant-builder .quoteOverlay{ display:none; position:fixed; inset:0; background:rgba(14,14,14,0.6); z-index:200; overflow-y:auto; padding:40px 16px; }\\n #chladni-plant-builder .quoteOverlay.on{ display:block; }\\n #chladni-plant-builder .quoteSheet{ max-width:720px; margin:0 auto; background:#fff; padding:40px 44px; }\\n #chladni-plant-builder .quoteSheet .qTop{ display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:22px; border-bottom:2px solid var(--ink); padding-bottom:16px; }\\n #chladni-plant-builder .quoteSheet .qBrand{ font-size:22px; font-weight:900; letter-spacing:-0.5px; }\\n #chladni-plant-builder .quoteSheet .qMeta{ font-size:12px; color:var(--muted); margin-top:3px; text-align:right; }\\n #chladni-plant-builder .quoteSheet .qPrep{ font-size:13px; line-height:1.7; margin-bottom:18px; }\\n /* v14: the quote table has 6 columns (Asset/Type/Criticality/Qty/Sensors/Monthly) - on a phone\\n width that's more columns than can fit without either scrolling or crushing text unreadably.\\n Wrapping it in a scrollable strip (rather than shrinking font/padding until it's unreadable)\\n keeps every column legible; min-width on the table is what makes the wrapper actually scroll\\n instead of squeezing the table down to the wrapper's width. */\\n #chladni-plant-builder .quoteTableWrap{ overflow-x:auto; -webkit-overflow-scrolling:touch; margin:14px 0; }\\n #chladni-plant-builder .quoteSheet table{ width:100%; min-width:480px; border-collapse:collapse; margin:0; }\\n #chladni-plant-builder .quoteSheet th, #chladni-plant-builder .quoteSheet td{ text-align:left; padding:8px 6px; border-bottom:1px solid var(--hairline); font-size:12.5px; white-space:nowrap; }\\n #chladni-plant-builder .quoteSheet th{ font-size:10px; letter-spacing:1px; text-transform:uppercase; color:var(--muted); }\\n #chladni-plant-builder .quoteSheet td.num, #chladni-plant-builder .quoteSheet th.num{ text-align:right; font-variant-numeric:tabular-nums; }\\n #chladni-plant-builder .quoteActionsRow{ display:flex; gap:10px; margin-top:24px; justify-content:flex-end; flex-wrap:wrap; }\\n @media (max-width:600px){\\n #chladni-plant-builder .quoteOverlay{ padding:24px 8px; }\\n #chladni-plant-builder .quoteSheet{ padding:24px 18px; }\\n #chladni-plant-builder .quoteSheet .qTop{ flex-direction:column; gap:10px; }\\n #chladni-plant-builder .quoteSheet .qMeta{ text-align:left; }\\n #chladni-plant-builder .quoteActionsRow{ justify-content:stretch; }\\n #chladni-plant-builder .quoteActionsRow .btn{ flex:1; text-align:center; }\\n }\\n\\n @media print {\\n #chladni-plant-builder, #chladni-plant-builder *{ visibility:hidden; }\\n #chladni-plant-builder .quoteSheet, #chladni-plant-builder .quoteSheet *{ visibility:visible; }\\n #chladni-plant-builder .quoteOverlay{ position:absolute; inset:auto; left:0; top:0; background:#fff; padding:0; }\\n #chladni-plant-builder .quoteSheet{ box-shadow:none; max-width:100%; }\\n #chladni-plant-builder .quoteActionsRow{ display:none; }\\n }\\n\"));\n document.head.appendChild(s);\n})();\n/* ================= PLANT INFO (restored from v1) ================= */\nvar INDUSTRIES = [\"Food & Beverage\",\"Building Materials\",\"Plastics & Rubber\",\"Paper & Pulp\",\"Chemicals\",\"Pharmaceuticals\",\"Automotive\",\"Metals / Steel / Aluminum\",\"Mining & Aggregates\",\"General Manufacturing\",\"Packaging\",\"Textile\",\"Wood Products\",\"Water / Wastewater\",\"Oil & Gas\",\"Data Centers\",\"Warehousing / Distribution\",\"Other\"];\nvar plant = { company:\"\", name:\"\", industry:INDUSTRIES[0], country:\"United States\", state:\"\", contactName:\"\", contactEmail:\"\", _err:\"\" };\nvar appStage = \"plant\"; /* 'plant' | 'app' */\n\n/* ================= CONFIG ================= */\nvar CONFIG = {\n RATE: 79, BLOCK_SIZE: 4, EXTRA_PAIR_MONTHLY: 20, MCA_MONTHLY: 79,\n GATEWAY_CAPACITY: 40,\n BUNDLE_DISCOUNT: 25\n};\n/* v13: EDGE_STREAM_PER_DRIVER and the Legacy Rack Panel question it priced are retired - see\n diagnostic addendum below. This tool now scopes out equipment already on an existing\n monitoring system (Bently Nevada or similar) up front, on the Plant Info screen, instead of\n asking per-asset and switching to an Edge-Streaming price. */\n/* v13: smallMotorKw/smallMotorPucks (the old \"4 kW or less on a Direct coupling = 1 puck\"\n exemption) is retired - see motorSensorCount() below, which replaces it with an ISO\n 20816-3 / NEMA MG-1 horsepower-and-speed tier lookup per client request. pucksPerLocation\n remains the flat default for every non-motor bearing location in the tool. */\nvar SENSOR_RULES = { pucksPerLocation:2 };\n\n/* ================= v12-web: ANALYTICS =================\n Pushes named events onto window.dataLayer; Google Tag Manager on the hosting page reads them\n and fans out to GA4 / Meta. Tag and pixel IDs live in GTM, never in this file. No PII in params. */\nfunction track(name, params){\n try {\n window.dataLayer = window.dataLayer || [];\n var payload = { event: name };\n if (params){ for (var k in params){ if (params.hasOwnProperty(k)) payload[k] = params[k]; } }\n window.dataLayer.push(payload);\n } catch(e){}\n}\nvar plantContinueTracked = false;\n\n/* ================= CATALOG =================\n v4: Pump / Fan-Blower / Compressor sub-types (Centrifugal vs Reciprocating vs Screw, etc.)\n moved out of this picker and into Block 5 (Driven Unit), per the client - the picker now\n only needs the broad family, and Block 5 asks the engineering-specific follow-up questions\n contextually (category, bearing arrangement, cylinder/stage count).\n v7: restored three whole categories - Plastics, HVAC / Utilities, and Paper / Web Processing - \n that were present in the original tool (Chladni_Plant_Builder_39.html) but dropped when the\n relational rewrite started in v2 and never carried forward through v3-v6. These items don't get\n their own contextual Block 5 sub-questions the way Pump/Fan/Compressor do - they fall through to\n the same generic driven-component treatment Grinder, Shredder, and Vibrating Screen already get\n - but they're selectable again, matching the original tool's full equipment coverage.\n v9: Pump, Fan / Blower, and Compressor each had their own single-item category, and there was no\n entry anywhere for a gearbox as a standalone driven unit (a gearbox could already appear as an\n *intermediate stage* between driver and driven unit - that's unrelated and unchanged). Per client\n request, those three moved into one consolidated \"General\" category alongside Gearbox / Speed\n Reducer and two more common catch-all driven types (Generator, Flywheel). Nothing downstream keys\n off the category label (cat:) - only off the item name (pkg.machineType) - so this is a picker\n reorganization only; Pump/Fan-Blower/Compressor keep every bit of their existing Block 5\n sub-question logic and pricing untouched. */\nvar CATALOG = [\n { cat:\"General\", items:[\"Pump\",\"Fan / Blower\",\"Compressor\",\"Gearbox / Speed Reducer\",\"Generator\",\"Flywheel\"] },\n { cat:\"Heavy Equipment\", items:[\"Crusher\",\"Mill\",\"Grinder\",\"Shredder\",\"Vibrating Screen\"] },\n { cat:\"Conveying\", items:[\"Belt Conveyor\",\"Screw Conveyor\",\"Roller Conveyor\",\"Chain Conveyor\",\"Bucket Elevator\",\"Feeder\"] },\n { cat:\"Processing\", items:[\"Mixer\",\"Agitator\",\"Blender\",\"Homogenizer\",\"Centrifuge\",\"Separator\"] },\n { cat:\"Plastics\", items:[\"Extruder\",\"Injection Molding Machine\",\"Blow Molding Machine\",\"Pelletizer\",\"Granulator\"] },\n { cat:\"HVAC / Utilities\", items:[\"Air Handling Unit (AHU)\",\"Supply Fan\",\"Exhaust Fan\",\"Cooling Tower Fan\",\"HVAC Pump\",\"Chiller\",\"Refrigeration Compressor\",\"Other HVAC Equipment\"] },\n { cat:\"Paper / Web Processing\", items:[\"Paper Machine\",\"Corrugator\",\"Winder / Rewinder\",\"Calender\",\"Coater / Laminator\",\"Printing Line\",\"Other Paper / Web Equipment\"] },\n { cat:\"Other\", items:[\"Custom Machine\",\"Production Equipment\",\"Other Rotating Equipment\"] }\n];\n\n/* Coupling Type - gearbox removed per spec (gearbox now lives in the Intermediate block).\n \"Direct\" is the shared-shaft equivalent of v2's \"close-coupled\". */\nvar COUPLING_TYPES = [\n [\"belt\",\"Belt\"], [\"chain\",\"Chain\"], [\"direct\",\"Direct\"], [\"flexible\",\"Flexible\"],\n [\"fluidmag\",\"Fluid / Magnetic\"], [\"gear\",\"Gear\"], [\"integral\",\"Integral\"],\n [\"webdrive\",\"Sheet / Web Drive\"], [\"spline\",\"Spline\"], [\"unknown\",\"Unknown\"]\n];\nvar ORIENTATIONS = [\n [\"inline\",\"In-line\"], [\"angular\",\"Angular\"], [\"parallel\",\"Parallel\"], [\"perpendicular\",\"Perpendicular\"], [\"unknown\",\"Unknown\"]\n];\n\n/* ================= RELATIONAL SCHEMA (Level 6 / Level 7) ================= */\nvar driverSeq = 1, pkgSeq = 1, interSeq = 1;\n\nfunction initDriverFields(d){\n /* v13: Turbine and Engine driver types are retired - motors are the only driver this tool\n covers for now. See diagnostic addendum below. */\n d.control_type = d.control_type || \"\"; d.insulation_class = d.insulation_class || \"\";\n d.voltage_level = d.voltage_level || \"\"; d.mcc_panel_access = d.mcc_panel_access || \"\";\n}\nfunction newDriver(kind){\n /* v4: hp_tier/amperage_tier/speed_tier bucket options are gone - the client wants a plain\n number box for these instead of a tier picker (see diagnostic #13), so these are now free\n text fields holding whatever number the user types, never gating anything. */\n var d = { id:\"drv\"+(driverSeq++), driver_type: kind || \"motor\",\n horsepower:\"\", amperage:\"\", line_freq:\"\", speed_rpm:\"\" };\n initDriverFields(d);\n return d;\n}\nfunction newIntermediate(){\n /* v4: a stage is now ONE element type (gearbox / jackshaft / other), never both at once - \n see diagnostic #14. shaftCount only applies when type === \"gearbox\". */\n return { id:\"int\"+(interSeq++), type:\"\", shaftCount:\"\" };\n}\nfunction syncInterCouplings(pkg){\n /* Keeps pkg.interCouplings sized to exactly (intermediates.length - 1): one coupling\n question per junction between consecutive intermediate stages. Trims from the end on\n removal and appends blank entries on growth - a stage removed from the middle shifts\n which physical junction a later coupling answer was describing, which is an accepted\n simplification for this tool's scope (documented in diagnostic #14) rather than trying\n to track junction identity through arbitrary mid-array edits. */\n var needed = Math.max(0, pkg.intermediates.length - 1);\n while (pkg.interCouplings.length \u003c needed) pkg.interCouplings.push({ type:\"\", orientation:\"\" });\n while (pkg.interCouplings.length \u003e needed) pkg.interCouplings.pop();\n}\nfunction newPackage(){\n /* v5: machineType/machineCat are no longer chosen up front - the equipment-type picker moved\n into Block 5 (Driven Unit), see diagnostic #18. A brand-new draft starts with no type at all\n and picks one up only once the user reaches that block. */\n return {\n id:\"pkg\"+(pkgSeq++),\n name:\"\", zone:\"\", machineType:\"\", machineCat:\"\",\n criticality:\"medium\", instanceCount:1,\n bundleDiscountEligible:false,\n drivers:[ newDriver(\"motor\") ],\n coupling_nodes:[ { type:\"\", orientation:\"\" }, { type:\"\", orientation:\"\" } ],\n intermediateAnswer:\"\", /* '' | 'yes' | 'no' */\n intermediates:[],\n interCouplings:[], /* one {type,orientation} per junction between consecutive intermediate stages */\n driven_unit:{ category:\"\", cylinders:\"\", stages:\"\", bearingArrangement:\"\" },\n puckOverrides:{}, /* componentKey -\u003e manually-set puck count, overriding the calculated default */\n advancedOpen:false,\n activeTab:0,\n maxUnlocked:0\n };\n}\n\nvar assets = [];\nvar draft = null;\nvar editingId = null;\n\n/* ================= COMPONENT / CALCULATION ENGINE ================= */\n/* v13: motor sensor allocation, replacing the old flat \"kW \u2264 4 on Direct coupling = 1 puck\"\n exemption per client request, grounded in ISO 20816-3 machine-size tiers and NEMA MG-1\n frame/speed physics:\n - Any motor over 400 HP (ISO 20816-3 \"Large\") always gets 2 sensors - casing/frame size\n alone rules out single-point coverage.\n - Any motor below 1000 RPM (\"Low speed\", NEMA MG-1 high-pole-count frames) always gets 2\n sensors, regardless of horsepower - a low-speed frame's DE-to-NDE bearing span exceeds\n the ISO 13373-1 / 20816-3 \u00a75 30-inch single-point isolation limit even on a small motor.\n - Small motors (\u226420 HP) at standard or high speed get 1 sensor - compact single-housing\n frame, ISO 20816-3 Group tier baseline.\n - Medium motors (20-400 HP) at high speed (\u003e2000 RPM, compact 2-pole frame) get 1 sensor\n only up to 100 HP; above that, 2.\n - Every other combination (medium at standard speed, medium at high speed above 100 HP)\n gets 2 sensors - ISO 20816-3 dual-housing mandate.\n Verified against the client's three edge cases: 15 HP @ 900 RPM -\u003e 2; 75 HP @ 3600 RPM -\u003e 1;\n any motor \u003e 400 HP -\u003e 2, unconditionally. */\nfunction motorSensorCount(hp, rpm){\n if (hp \u003e 400) return 2;\n if (rpm \u003c 1000) return 2;\n if (hp \u003c= 20) return 1;\n if (rpm \u003e 2000 && hp \u003c= 100) return 1;\n return 2;\n}\nfunction pucksForDriver(driver, pkg){\n if (driver.driver_type === \"motor\"){\n var hp = parseFloat(driver.horsepower), rpm = parseFloat(driver.speed_rpm);\n if (!isNaN(hp) && hp \u003e 0 && !isNaN(rpm) && rpm \u003e 0){\n return motorSensorCount(hp, rpm);\n }\n }\n return SENSOR_RULES.pucksPerLocation;\n}\nfunction intermediateComponents(pkg){\n /* v4: a stage is a single element type now (gearbox XOR jackshaft XOR other - diagnostic #14),\n never both, so this is a plain switch instead of two independent yes/no checks. Gearbox\n puck count now scales with the shaft count the client asked to capture (diagnostic #14),\n defaulting to 2 shafts (input + output) if left blank rather than gating the workflow on it. */\n var out = [];\n pkg.intermediates.forEach(function(inter, i){\n var tag = pkg.intermediates.length\u003e1 ? \" #\"+(i+1) : \"\";\n if (inter.type === \"gearbox\"){\n var shafts = parseInt(inter.shaftCount, 10);\n if (!shafts || shafts \u003c 1) shafts = 2;\n out.push({ label:\"Gearbox / speed reducer\"+tag+\" (\"+shafts+\" shaft\"+(shafts===1?\"\":\"s\")+\")\", cls:\"A\",\n total: shafts * SENSOR_RULES.pucksPerLocation, reason:\"critical drivetrain\" });\n } else if (inter.type === \"jackshaft\"){\n out.push({ label:\"Jackshaft / countershaft bearing pedestals\"+tag, cls:\"B\", total:SENSOR_RULES.pucksPerLocation,\n reason:\"intermediate reduction stage - independent bearing fault frequencies\" });\n } else if (inter.type === \"other\"){\n out.push({ label:\"Other intermediate element\"+tag, cls:\"B\", total:SENSOR_RULES.pucksPerLocation,\n reason:\"intermediate stage present, type not otherwise listed\" });\n }\n });\n return out;\n}\nvar RULES = {\n \"Reciprocating Compressor\": function(pkg){\n var cyl = parseInt(pkg.driven_unit.cylinders, 10); if (!cyl || cyl \u003c 1) cyl = 1;\n return [\n { label:\"Crankshaft / main bearing housings\", cls:\"A\", total:SENSOR_RULES.pucksPerLocation, reason:\"main rotating element\" },\n { label:\"Cylinder & valve assembly\", cls:\"A\", total: cyl * SENSOR_RULES.pucksPerLocation, reason:\"valve failure is the leading cause of downtime\" },\n { label:\"Crosshead\", cls:\"B\", total:SENSOR_RULES.pucksPerLocation, reason:\"converts rotary to linear motion\" },\n { label:\"Packing / rod seal\", cls:\"B\", total: cyl, reason:\"gas leakage / seal wear, one per cylinder\" }\n ];\n },\n \"Centrifugal Compressor\": function(pkg){\n var stagesQty = pkg.driven_unit.stages === \"multi\" ? 2 : 1;\n return [\n { label:\"Main casing shaft bearings (radial)\", cls:\"A\", total: stagesQty * SENSOR_RULES.pucksPerLocation, reason:\"main rotor support\" },\n { label:\"Thrust bearing\", cls:\"A\", total:SENSOR_RULES.pucksPerLocation, reason:\"absorbs axial thrust - distinct failure point\" },\n { label:\"Shaft seal system\", cls:\"B\", total:SENSOR_RULES.pucksPerLocation, reason:\"dry gas / oil seal condition\" }\n ];\n },\n \"Rotary Screw Compressor\": function(pkg){\n return [ { label:\"Screw rotor bearing housings\", cls:\"A\", total:SENSOR_RULES.pucksPerLocation, reason:\"main rotating element\" } ];\n },\n /* v4 additions: Pump and Fan/Blower sub-categories, chosen in Block 5 instead of the initial\n picker (diagnostic #15). Reciprocating pump mirrors the reciprocating-compressor logic\n (valve/plunger wear dominates); Centrifugal pump and Fan/Blower scale with API 610-style\n bearing arrangement (Overhung = one bearing frame, Between Bearings = two, one each side\n of the impeller/wheel); Rotary and Screw pumps get a single main-rotor bearing point,\n matching how the tool already treats Rotary Screw Compressor. */\n \"Reciprocating Pump\": function(pkg){\n var cyl = parseInt(pkg.driven_unit.cylinders, 10); if (!cyl || cyl \u003c 1) cyl = 1;\n return [\n { label:\"Cylinder & valve/plunger assembly\", cls:\"A\", total: cyl * SENSOR_RULES.pucksPerLocation, reason:\"valve/plunger wear is the leading reciprocating-pump failure mode\" },\n { label:\"Packing / plunger seal\", cls:\"B\", total: cyl, reason:\"leakage / seal wear, one per plunger\" }\n ];\n },\n \"Centrifugal Pump\": function(pkg){\n var qty = pkg.driven_unit.bearingArrangement === \"between\" ? 2 : 1;\n return [ { label:\"Pump bearing housings (\"+(qty===2?\"Between Bearings\":\"Overhung\")+\")\", cls:\"B\",\n total: qty * SENSOR_RULES.pucksPerLocation, reason:\"driven machine bearings\" } ];\n },\n \"Rotary Pump\": function(pkg){\n return [ { label:\"Rotor bearing housings\", cls:\"B\", total:SENSOR_RULES.pucksPerLocation, reason:\"main rotating element\" } ];\n },\n \"Screw Pump\": function(pkg){\n return [ { label:\"Screw rotor bearing housings\", cls:\"B\", total:SENSOR_RULES.pucksPerLocation, reason:\"main rotating element\" } ];\n },\n \"Fan / Blower\": function(pkg){\n var qty = pkg.driven_unit.bearingArrangement === \"between\" ? 2 : 1;\n return [ { label:\"Fan/blower shaft bearings (\"+(qty===2?\"Between Bearings\":\"Overhung\")+\")\", cls:\"B\",\n total: qty * SENSOR_RULES.pucksPerLocation, reason:\"driven machine bearings\" } ];\n },\n \"Crusher\": function(pkg){\n return [ { label:\"Main rotor / shaft bearing housings\", cls:\"A\", total:SENSOR_RULES.pucksPerLocation, reason:\"main rotating element\" } ];\n },\n \"Mill\": function(pkg){\n return [ { label:\"Main shaft / trunnion bearings\", cls:\"A\", total: 2*SENSOR_RULES.pucksPerLocation, reason:\"both ends of the main rotating element\" } ];\n },\n /* v11 addition: Gearbox / Speed Reducer as a Block 5 driven unit was falling through to the\n generic defaultDrivenComponents() single bearing point, with no shaft-count question - the\n same gap the intermediate-stage gearbox already solved (diagnostic #14). Reuses that exact\n pattern: ask for shaft count, default to 2 (input + output) if left blank. */\n \"Gearbox / Speed Reducer\": function(pkg){\n var shafts = parseInt(pkg.driven_unit.shaftCount, 10);\n if (!shafts || shafts \u003c 1) shafts = 2;\n return [ { label:\"Gearbox / speed reducer shaft bearings (\"+shafts+\" shaft\"+(shafts===1?\"\":\"s\")+\")\", cls:\"A\",\n total: shafts * SENSOR_RULES.pucksPerLocation, reason:\"critical drivetrain\" } ];\n }\n};\n/* v4: Pump and Compressor no longer key RULES directly off pkg.machineType (that's just \"Pump\"\n or \"Compressor\" now - the specific engineering category is a Block 5 answer). Everything else\n (Fan/Blower, Crusher, Mill, ...) still keys off machineType exactly as before. */\nfunction drivenRuleKey(pkg){\n if (pkg.machineType === \"Compressor\"){\n return { centrifugal:\"Centrifugal Compressor\", reciprocating:\"Reciprocating Compressor\", screw:\"Rotary Screw Compressor\" }[pkg.driven_unit.category] || null;\n }\n if (pkg.machineType === \"Pump\"){\n return { centrifugal:\"Centrifugal Pump\", reciprocating:\"Reciprocating Pump\", rotary:\"Rotary Pump\", screw:\"Screw Pump\" }[pkg.driven_unit.category] || null;\n }\n return pkg.machineType;\n}\nfunction defaultDrivenComponents(pkg){\n if (pkg.coupling_nodes[0].type === \"direct\") return [];\n var lbl = \"Driven machine\";\n if (/Fan/.test(pkg.machineType)) lbl = \"Fan shaft\";\n else if (/Pump/.test(pkg.machineType)) lbl = \"Pump shaft\";\n else if (/Compressor/.test(pkg.machineType)) lbl = \"Compressor rotor\";\n return [ { label:lbl+\" bearings\", cls:\"B\", total:SENSOR_RULES.pucksPerLocation, reason:\"driven machine bearings\" } ];\n}\nfunction calcComponents(pkg){\n var comps = [];\n pkg.drivers.forEach(function(d, i){\n comps.push({ label:(d.driver_type.charAt(0).toUpperCase()+d.driver_type.slice(1)) + (pkg.drivers.length\u003e1 ? \" #\"+(i+1) : \"\"),\n cls:\"A\", total: pucksForDriver(d, pkg), reason:\"primary drive\", block:\"driver\", key:\"driver-\"+i });\n });\n var interComps = intermediateComponents(pkg);\n interComps.forEach(function(c, idx){ c.block = \"inter\"; c.key = \"inter-\"+idx; });\n comps = comps.concat(interComps);\n var rule = RULES[drivenRuleKey(pkg)];\n var drivenComps = rule ? rule(pkg) : defaultDrivenComponents(pkg);\n drivenComps.forEach(function(c, idx){ c.block = \"driven\"; c.key = \"driven-\"+idx; });\n comps = comps.concat(drivenComps);\n return comps;\n}\n/* v4: every component's puck count can be manually overridden in the Review screen (diagnostic\n #16) - \"maybe I like to put 4 sensors on my gearbox, or only 2 would be enough\". The override\n lives on the package (keyed by the component's stable key), never mutates the calculated\n default, and is what every downstream total (vib count, price, tree preview) actually reads. */\nfunction effectivePucks(pkg, c){\n return (pkg.puckOverrides && pkg.puckOverrides[c.key] != null) ? pkg.puckOverrides[c.key] : c.total;\n}\nfunction calcVib(pkg){ return calcComponents(pkg).reduce(function(s,c){ return s + effectivePucks(pkg, c); }, 0); }\nfunction driverMcaEligible(d, pkg){\n if (d.driver_type !== \"motor\") return false;\n if (d.mcc_panel_access !== \"yes\") return false;\n return pkg.criticality === \"very\" || pkg.criticality === \"high\";\n}\nfunction mcaCharged(pkg){\n return pkg.drivers.some(function(d){ return driverMcaEligible(d, pkg); });\n}\nfunction priceFor(pkg){\n var v = calcVib(pkg);\n var vibP = v === 0 ? 0 : CONFIG.RATE + Math.ceil(Math.max(0, v - CONFIG.BLOCK_SIZE) / 2) * CONFIG.EXTRA_PAIR_MONTHLY;\n var mcaP = mcaCharged(pkg) ? CONFIG.MCA_MONTHLY : 0;\n var discount = (pkg.bundleDiscountEligible && v \u003e 0 && mcaP \u003e 0) ? CONFIG.BUNDLE_DISCOUNT : 0;\n return { vib:vibP, mca:mcaP, edge:0, discount:discount, total: Math.max(0, vibP + mcaP - discount) };\n}\nfunction totals(){\n var t = { assets:0, pucks:0, monthly:0, gateways:0 };\n assets.forEach(function(pkg){\n var n = Math.max(1, pkg.instanceCount||1);\n var p = priceFor(pkg), v = calcVib(pkg);\n t.assets += n; t.pucks += v*n; t.monthly += p.total*n;\n });\n t.gateways = t.assets === 0 ? 0 : Math.max(1, Math.ceil(t.pucks / CONFIG.GATEWAY_CAPACITY));\n return t;\n}\nfunction money(n){ return \"$\" + Math.round(n).toLocaleString(\"en-US\"); }\n\n/* ================= v10: LEAD CAPTURE, AUTOSAVE, SHAREABLE LINK, QUOTE EXPORT =================\n Tier 1 sales-readiness additions, added together since all three lean on the same underlying\n piece: a full, round-trippable snapshot of {plant, assets}. No backend exists for this tool, so\n \"save and resume\" and \"shareable link\" are the same mechanism aimed at two different places to\n read from - localStorage for the browser coming back on its own, the URL hash for handing the\n exact configuration to someone else (or opening it on another device). Both are additive: they\n never change what calcComponents/priceFor/totals compute, only when the tool reads/writes state\n around them, so none of the existing pricing or drivetrain-gating logic needed touching. */\nfunction isValidEmail(s){ return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test((s||\"\").trim()); }\nfunction leadCaptured(){ return !!(plant.contactName && plant.contactName.trim()) && isValidEmail(plant.contactEmail); }\n\nvar AUTOSAVE_KEY = \"chladni_plant_builder_autosave_v14\";\nfunction snapshotState(){ return { plant: plant, assets: assets, pkgSeq: pkgSeq, driverSeq: driverSeq, interSeq: interSeq, draft: draft, editingId: editingId }; }\nfunction encodeState(){\n try { return btoa(unescape(encodeURIComponent(JSON.stringify(snapshotState())))); }\n catch(e){ return null; }\n}\nfunction decodeState(s){\n try { return JSON.parse(decodeURIComponent(escape(atob(s)))); }\n catch(e){ return null; }\n}\nvar lastSavedAt = null;\nfunction persistLocal(){\n try {\n var enc = encodeState();\n if (enc){ localStorage.setItem(AUTOSAVE_KEY, enc); lastSavedAt = new Date(); }\n }\n catch(e){ /* private browsing / storage disabled - autosave just silently no-ops */ }\n}\nfunction savedStatusText(){\n if (!lastSavedAt) return \"Not saved yet\";\n return \"Saved \" + lastSavedAt.toLocaleTimeString(\"en-US\", { hour:\"numeric\", minute:\"2-digit\" });\n}\n/* v11: persistLocal() already ran silently on every render (renderTree(), plant-field edits,\n beforeunload) since v10 - but \"silently\" turned out to be the problem the client actually hit:\n there was no visible confirmation anywhere that anything had been saved at all, so from the\n outside it looked like the feature didn't exist. This is an explicit, visible \"Save progress\"\n action wired to the exact same persistLocal()/AUTOSAVE_KEY mechanism, not a second save path - \n clicking it just makes the existing save happen on demand and shows that it happened. */\nfunction saveProgressNow(btn){\n persistLocal();\n track(\"plant_builder_progress_saved\");\n if (!btn) return;\n var old = btn.textContent;\n btn.textContent = \"Saved \u2713\";\n setTimeout(function(){ btn.textContent = old; }, 1600);\n var status = document.getElementById(btn.getAttribute(\"data-status-target\"));\n if (status) status.textContent = savedStatusText();\n}\n/* Restoring a saved snapshot brings back plain data only (see newPackage()/newDriver() - no\n functions are ever stored on a package), so nothing here needs to touch the drivetrain-gating\n or pricing functions. The one thing that does need care is the id sequence counters: a restored\n asset already carries ids like \"pkg3\"/\"drv2\", and if driverSeq/pkgSeq/interSeq reset to 1 on\n reload, the next NEW driver or asset created after a restore could mint an id that collides\n with a restored one. Fixed by taking whichever is larger: the saved counter, or one past the\n highest numeric suffix actually found in the restored data. */\nfunction reseedCounters(state){\n var maxPkg = 0, maxDrv = 0, maxInt = 0;\n (state.assets||[]).forEach(function(pkg){\n var m = /^pkg(\\d+)$/.exec(pkg.id||\"\"); if (m) maxPkg = Math.max(maxPkg, parseInt(m[1],10));\n (pkg.drivers||[]).forEach(function(d){ var md = /^drv(\\d+)$/.exec(d.id||\"\"); if (md) maxDrv = Math.max(maxDrv, parseInt(md[1],10)); });\n (pkg.intermediates||[]).forEach(function(it){ var mi = /^int(\\d+)$/.exec(it.id||\"\"); if (mi) maxInt = Math.max(maxInt, parseInt(mi[1],10)); });\n });\n pkgSeq = Math.max(typeof state.pkgSeq === \"number\" ? state.pkgSeq : 1, maxPkg + 1);\n driverSeq = Math.max(typeof state.driverSeq === \"number\" ? state.driverSeq : 1, maxDrv + 1);\n interSeq = Math.max(typeof state.interSeq === \"number\" ? state.interSeq : 1, maxInt + 1);\n}\nfunction applyRestoredState(state){\n var basePlant = { company:\"\", name:\"\", industry:INDUSTRIES[0], country:\"United States\", state:\"\", contactName:\"\", contactEmail:\"\", _err:\"\" };\n var incoming = state.plant || {};\n for (var k in incoming){ if (incoming.hasOwnProperty(k)) basePlant[k] = incoming[k]; }\n plant = basePlant;\n assets = state.assets || [];\n reseedCounters(state);\n draft = state.draft || null;\n editingId = state.editingId || null;\n if (editingId){\n var stillThere = assets.some(function(pkg){ return pkg.id === editingId; });\n if (!stillThere){ draft = null; editingId = null; }\n }\n}\nfunction shareLinkUrl(){\n var enc = encodeState();\n if (!enc) return null;\n return location.origin + location.pathname + location.search + \"#s=\" + enc;\n}\nfunction copyShareLink(){\n var url = shareLinkUrl();\n if (!url) return;\n var btn = document.getElementById(\"shareLinkBtn\");\n track(\"plant_builder_share_link_copied\");\n function flash(label){ if (!btn) return; var old = btn.textContent; btn.textContent = label; setTimeout(function(){ btn.textContent = old; }, 1800); }\n if (navigator.clipboard && navigator.clipboard.writeText){\n navigator.clipboard.writeText(url).then(function(){ flash(\"Link copied\"); }, function(){ window.prompt(\"Copy this link:\", url); });\n } else {\n window.prompt(\"Copy this link:\", url);\n }\n}\nvar quoteNumber = null;\nfunction ensureQuoteNumber(){\n if (!quoteNumber) quoteNumber = \"CHL-\" + Date.now().toString(36).toUpperCase();\n return quoteNumber;\n}\n\n/* ================= v12-web: LEAD DELIVERY VIA HIDDEN WEBFLOW FORM =================\n No backend of our own: the Webflow page hosting this bundle carries a hidden native form named\n \"Plant Builder Lead\" (see webflow/lead-form.md). submitLead() fills its fields from the current\n state and submits it programmatically; Webflow's own form handler intercepts the submit event\n and posts it, so the lead lands in Webflow form submissions / notification email with zero\n server code. Two stages:\n \"lead\" - once, the first time contact name + valid email are both present\n \"quote\" - every time the quote sheet is opened with a monthly total we have not sent yet\n Dedupe markers live on `plant` (leadSentAt, quoteSentTotal) so they ride along in the autosave\n snapshot and survive a reload. If the form is not on the page (local shell without the stub,\n or a future page without the form) this is a silent no-op. Never touches pricing. */\nfunction setField(form, name, value){\n var f = form.querySelector('[name=\"' + name + '\"]');\n if (!f) return;\n f.value = String(value == null ? \"\" : value);\n}\nfunction submitLead(stage){\n var form = document.querySelector('form[data-name=\"Plant Builder Lead\"]');\n if (!form){\n if (window.console && console.debug) console.debug(\"[plant-builder] lead form not found; skipping\", stage);\n return;\n }\n var t = totals();\n if (stage === \"lead\" && plant.leadSentAt) return;\n if (stage === \"quote\" && t.monthly === plant.quoteSentTotal) return;\n\n var quoteId = \"\";\n if (stage === \"quote\") quoteId = ensureQuoteNumber();\n var pageUrl = location.href.split(\"#\")[0];\n\n setField(form, \"stage\", stage);\n setField(form, \"contact_name\", plant.contactName);\n setField(form, \"contact_email\", plant.contactEmail);\n setField(form, \"company\", plant.company);\n setField(form, \"plant_name\", plant.name);\n setField(form, \"industry\", plant.industry);\n setField(form, \"country\", plant.country);\n setField(form, \"state\", plant.state);\n setField(form, \"asset_count\", t.assets);\n setField(form, \"puck_count\", t.pucks);\n setField(form, \"monthly_total\", Math.round(t.monthly));\n setField(form, \"quote_id\", quoteId);\n setField(form, \"config_link\", shareLinkUrl() || \"\");\n setField(form, \"page_url\", pageUrl);\n\n try {\n if (typeof form.requestSubmit === \"function\"){ form.requestSubmit(); }\n else { var b = form.querySelector('[type=\"submit\"]'); if (b) b.click(); }\n } catch(e){\n if (window.console && console.debug) console.debug(\"[plant-builder] lead submit failed\", e);\n return;\n }\n\n if (stage === \"lead\"){\n plant.leadSentAt = new Date().toISOString();\n track(\"plant_builder_lead_captured\", { has_email: true });\n }\n if (stage === \"quote\") plant.quoteSentTotal = t.monthly;\n persistLocal();\n}\nfunction fmtDate(d){ return d.toLocaleDateString(\"en-US\", { year:\"numeric\", month:\"long\", day:\"numeric\" }); }\nfunction renderQuoteSheet(){\n var host = document.getElementById(\"quoteSheet\");\n host.innerHTML = \"\";\n var today = new Date();\n var validUntil = new Date(today.getTime() + 30*24*60*60*1000);\n\n var top = el(\"div\",\"qTop\");\n var left = el(\"div\");\n left.appendChild(el(\"div\",\"qBrand\",\"CHLADNI\"));\n left.appendChild(el(\"div\",\"qMeta\",\"Predictive maintenance monitoring quotation\"));\n var right = el(\"div\");\n right.appendChild(el(\"div\",\"qMeta\",\"Quote \" + ensureQuoteNumber()));\n right.appendChild(el(\"div\",\"qMeta\",\"Date: \" + fmtDate(today)));\n right.appendChild(el(\"div\",\"qMeta\",\"Valid until: \" + fmtDate(validUntil)));\n top.appendChild(left); top.appendChild(right);\n host.appendChild(top);\n\n var prepLines = [(plant.company||\" - \") + (plant.name ? \" - \" + plant.name : \"\")];\n var contactLine = [plant.contactName||\"\"].concat(plant.contactEmail ? [plant.contactEmail] : []).filter(Boolean).join(\" \u00b7 \");\n if (contactLine) prepLines.push(contactLine);\n host.appendChild(el(\"div\",\"qPrep\",\"\u003cb\u003ePrepared for\u003c/b\u003e\u003cbr\u003e\" + prepLines.join(\"\u003cbr\u003e\")));\n\n var table = document.createElement(\"table\");\n var thead = \"\u003cthead\u003e\u003ctr\u003e\u003cth\u003eAsset\u003c/th\u003e\u003cth\u003eType\u003c/th\u003e\u003cth\u003eCriticality\u003c/th\u003e\u003cth class='num'\u003eQty\u003c/th\u003e\u003cth class='num'\u003eSensors\u003c/th\u003e\u003cth class='num'\u003eMonthly\u003c/th\u003e\u003c/tr\u003e\u003c/thead\u003e\";\n var rows = \"\";\n assets.forEach(function(pkg){\n var n = Math.max(1, pkg.instanceCount||1);\n var pr = priceFor(pkg);\n rows += \"\u003ctr\u003e\u003ctd\u003e\"+pkg.name+\"\u003c/td\u003e\u003ctd\u003e\"+(pkg.machineType||\" - \")+\"\u003c/td\u003e\u003ctd\u003e\"+critLabel(pkg.criticality)+\"\u003c/td\u003e\"+\n \"\u003ctd class='num'\u003e\"+n+\"\u003c/td\u003e\u003ctd class='num'\u003e\"+(calcVib(pkg)*n)+\"\u003c/td\u003e\u003ctd class='num'\u003e\"+money(pr.total*n)+\"\u003c/td\u003e\u003c/tr\u003e\";\n });\n table.innerHTML = thead + \"\u003ctbody\u003e\" + rows + \"\u003c/tbody\u003e\";\n var tableWrap = el(\"div\",\"quoteTableWrap\");\n tableWrap.appendChild(table);\n host.appendChild(tableWrap);\n\n var t = totals();\n host.appendChild(el(\"div\",\"hint\", t.assets + \" asset\"+(t.assets===1?\"\":\"s\")+\" \u00b7 \" + t.pucks + \" sensor\"+(t.pucks===1?\"\":\"s\")+\" \u00b7 \" + t.gateways + \" gateway\"+(t.gateways===1?\"\":\"s\")));\n host.appendChild(el(\"div\",\"quoteBig\", money(t.monthly) + \"/mo\"));\n host.appendChild(el(\"div\",\"hint\",\"Configuration-based estimate. Final pricing is confirmed after a site survey. Valid for 30 days from the date above.\"));\n\n var actions = el(\"div\",\"quoteActionsRow\");\n var closeBtn = el(\"button\",\"btn ghost\",\"Close\"); closeBtn.type=\"button\"; closeBtn.addEventListener(\"click\", closeQuote);\n var printBtn = el(\"button\",\"btn\",\"Print / save as PDF\"); printBtn.type=\"button\"; printBtn.addEventListener(\"click\", function(){ window.print(); });\n actions.appendChild(closeBtn); actions.appendChild(printBtn);\n host.appendChild(actions);\n}\nfunction openQuote(){\n if (!leadCaptured() || !assets.length) return;\n renderQuoteSheet();\n var overlay = document.getElementById(\"quoteOverlay\");\n overlay.classList.add(\"on\");\n overlay.onclick = function(e){ if (e.target === overlay) closeQuote(); };\n submitLead(\"quote\");\n var qt = totals();\n track(\"plant_builder_quote_opened\", { quote_id: ensureQuoteNumber(), monthly_total: qt.monthly, asset_count: qt.assets, puck_count: qt.pucks });\n}\nfunction closeQuote(){\n document.getElementById(\"quoteOverlay\").classList.remove(\"on\");\n}\n\n/* ================= TAB DEFINITIONS & GATING =================\n Execution rule: all 5 blocks must be traversed and valid before the\n asset can be finalized. Each validator explicitly handles empty arrays. */\nvar TABS = [\n { key:\"driver\", label:\"Driver\", n:\"Block 1\" },\n { key:\"coupling1\",label:\"Coupling 1\", n:\"Block 2\" },\n { key:\"inter\", label:\"Intermediate\",n:\"Block 3\" },\n { key:\"coupling2\",label:\"Coupling 2\", n:\"Block 4\" },\n { key:\"driven\", label:\"Driven Unit\", n:\"Block 5\" }\n];\nfunction tabValid(pkg, key){\n if (key === \"driver\"){\n if (!pkg.drivers || pkg.drivers.length === 0) return false;\n return pkg.drivers.every(function(d){\n if (!d.driver_type) return false;\n /* v13: a motor's vital size question is Horsepower + Operating speed (RPM), not kW - see\n motorSensorCount()'s comment. Motors are the only driver type this tool covers. */\n var hp = parseFloat(d.horsepower), rpm = parseFloat(d.speed_rpm);\n if (isNaN(hp) || hp \u003c= 0) return false;\n if (isNaN(rpm) || rpm \u003c= 0) return false;\n /* VFD? and MCC/panel access are both vital for a motor now, not optional (diagnostic #13) - \n MCC access determines whether electrical monitoring can actually be delivered at all. */\n if (!d.control_type || !d.mcc_panel_access) return false;\n return true;\n });\n }\n if (key === \"coupling1\"){\n return !!pkg.coupling_nodes[0].type && !!pkg.coupling_nodes[0].orientation;\n }\n if (key === \"inter\"){\n if (pkg.intermediateAnswer === \"no\") return true;\n if (pkg.intermediateAnswer !== \"yes\") return false;\n if (!pkg.intermediates.length) return false;\n /* v4: each stage is a single element type (gearbox needs a shaft count too), and once\n there is more than one stage, the coupling between every consecutive pair must also be\n described - diagnostic #14. */\n var stagesOk = pkg.intermediates.every(function(inter){\n if (!inter.type) return false;\n if (inter.type === \"gearbox\" && !inter.shaftCount) return false;\n return true;\n });\n if (!stagesOk) return false;\n if (pkg.intermediates.length \u003e 1){\n for (var k=0; k\u003cpkg.intermediates.length-1; k++){\n var jc = pkg.interCouplings[k];\n if (!jc || !jc.type || !jc.orientation) return false;\n }\n }\n return true;\n }\n if (key === \"coupling2\"){\n if (pkg.intermediateAnswer !== \"yes\" || pkg.intermediates.length === 0) return true; /* auto-complete: nothing to bound */\n return !!pkg.coupling_nodes[1].type && !!pkg.coupling_nodes[1].orientation;\n }\n if (key === \"driven\"){\n /* v5: the equipment type itself is now chosen inside this block (diagnostic #18) - nothing\n downstream (including this validator) can assume a type has been picked yet.\n v11: per client request, picking an equipment type is no longer mandatory to complete this\n block - a prospect who isn't sure, or whose machine isn't in the catalog, can leave it blank\n and still finish the asset. Skipping it just means calcComponents() falls through to\n defaultDrivenComponents() (already null-safe on an empty machineType - see its own comment)\n instead of a type-specific rule set. The instant a type IS picked, though, that type's own\n follow-up questions (category, bearing arrangement, cylinder count, ...) are still required\n exactly as before - optional is \"skip the picker entirely,\" not \"half-answer it.\" */\n if (!pkg.machineType) return true;\n if (pkg.machineType === \"Compressor\"){\n if (!pkg.driven_unit.category) return false;\n if (pkg.driven_unit.category === \"reciprocating\" && !pkg.driven_unit.cylinders) return false;\n if (pkg.driven_unit.category === \"centrifugal\" && !pkg.driven_unit.stages) return false;\n return true;\n }\n if (pkg.machineType === \"Pump\"){\n if (!pkg.driven_unit.category) return false;\n if (!pkg.driven_unit.bearingArrangement) return false;\n if (pkg.driven_unit.category === \"reciprocating\" && !pkg.driven_unit.cylinders) return false;\n return true;\n }\n if (pkg.machineType === \"Fan / Blower\"){\n return !!pkg.driven_unit.bearingArrangement;\n }\n if (pkg.machineType === \"Gearbox / Speed Reducer\"){\n return !!pkg.driven_unit.shaftCount;\n }\n return true;\n }\n return false;\n}\nfunction baselineValid(pkg){ return !!(pkg.name && pkg.name.trim()); }\nfunction recomputeUnlock(pkg){\n var maxU = 0;\n for (var i=0;i\u003cTABS.length;i++){\n if (tabValid(pkg, TABS[i].key)) maxU = i+1; else break;\n }\n pkg.maxUnlocked = Math.max(pkg.maxUnlocked, maxU);\n /* re-lock if a later tab was previously trusted but is no longer valid (diagnostic #6) */\n for (var j=0;j\u003cTABS.length;j++){\n if (!tabValid(pkg, TABS[j].key)){ pkg.maxUnlocked = Math.min(pkg.maxUnlocked, j); break; }\n }\n}\nfunction allTabsComplete(pkg){ return TABS.every(function(t){ return tabValid(pkg, t.key); }); }\n\n/* ================= small DOM helpers ================= */\nfunction el(t,c,h){ var e=document.createElement(t); if(c) e.className=c; if(h!==undefined) e.innerHTML=h; return e; }\nfunction clamp(n,lo,hi){ return Math.max(lo, Math.min(hi, n)); }\n\n/* Fix (v3.1): renderCard() tears down and rebuilds the entire #card subtree on every\n change, which is fine for buttons but silently breaks free-text/number inputs - the\n node holding focus gets destroyed and recreated, so a real user typing character by\n character loses focus after the first keystroke (this is what made Asset Name and\n Power Rating look \"stuck\": the model updates internally, but a rebuilt input can no\n longer be typed into without re-clicking it every time, and if a field never re-renders\n on 'input' at all - as Asset Name previously didn't - the rest of the UI never learns\n the value changed until an unrelated click happens to force a render). Any input that\n needs to trigger a live re-render tags itself with data-refocus=\"\u003ckey\u003e\" and calls this\n instead of renderCard() directly; it restores focus, cursor position and the card's\n scroll offset after the rebuild. */\nfunction reRenderPreserveFocus(){\n var active = document.activeElement;\n var key = (active && active.getAttribute) ? active.getAttribute(\"data-refocus\") : null;\n var selStart = null, selEnd = null;\n if (key && typeof active.selectionStart === \"number\"){ selStart = active.selectionStart; selEnd = active.selectionEnd; }\n var cardEl = document.getElementById(\"card\");\n var scrollTop = cardEl ? cardEl.scrollTop : 0;\n var outerScroll = window.scrollY;\n renderCard();\n if (key){\n var el2 = document.querySelector('[data-refocus=\"'+key+'\"]');\n if (el2){\n el2.focus();\n if (selStart != null && el2.setSelectionRange){ try { el2.setSelectionRange(selStart, selEnd); } catch(e){} }\n }\n }\n if (cardEl) cardEl.scrollTop = scrollTop;\n window.scrollTo(window.scrollX, outerScroll);\n}\n\nvar activeTip = null;\nfunction hideTip(){\n var tip = document.getElementById(\"tip\");\n if (tip) tip.classList.remove(\"on\");\n activeTip = null;\n}\nfunction showTip(icon){\n var tip = document.getElementById(\"tip\");\n var text = icon.getAttribute(\"data-tip\") || \"\";\n var r = icon.getBoundingClientRect();\n tip.innerHTML = \"\";\n tip.appendChild(document.createTextNode(text));\n var close = document.createElement(\"button\");\n close.type = \"button\";\n close.className = \"tipClose\";\n close.setAttribute(\"aria-label\", \"Close tip\");\n close.textContent = \"x\";\n close.addEventListener(\"click\", function(ev){ ev.stopPropagation(); hideTip(); });\n tip.appendChild(close);\n tip.style.position = \"fixed\";\n var maxLeft = Math.max(10, window.innerWidth - Math.min(280, window.innerWidth - 20) - 10);\n tip.style.left = clamp(r.left, 10, maxLeft) + \"px\";\n tip.style.top = (r.bottom + 8) + \"px\";\n tip.classList.add(\"on\");\n activeTip = icon;\n}\ndocument.addEventListener(\"click\", function(e){\n var icon = e.target.closest ? e.target.closest(\".infoIcon\") : null;\n if (icon){\n if (activeTip === icon){ hideTip(); e.stopPropagation(); return; }\n showTip(icon);\n e.stopPropagation();\n return;\n }\n if (e.target && e.target.closest && e.target.closest(\".tip\")) return;\n if (activeTip) hideTip();\n});\ndocument.addEventListener(\"keydown\", function(e){\n if (e.key === \"Escape\"){\n hideTip();\n if (document.getElementById(\"quoteOverlay\") && document.getElementById(\"quoteOverlay\").classList.contains(\"on\")) closeQuote();\n }\n});\n\nfunction fieldWrap(labelText, tipText, inner, required){\n var w = el(\"div\",\"fld\");\n var row = el(\"div\",\"fldLabelRow\");\n row.appendChild(el(\"label\",null,labelText + (required ? ' \u003cspan class=\"fldReq\"\u003e*\u003c/span\u003e' : \"\")));\n if (tipText){\n var ic = el(\"button\",\"infoIcon\",\"i\"); ic.type=\"button\"; ic.setAttribute(\"data-tip\", tipText); ic.setAttribute(\"aria-label\", \"About \" + labelText);\n row.appendChild(ic);\n }\n w.appendChild(row);\n w.appendChild(inner);\n return w;\n}\nfunction optGroup(options, current, gridClass, onPick){\n var wrap = el(\"div\",\"opts\" + (gridClass ? \" \"+gridClass : \"\"));\n options.forEach(function(o){\n var b = el(\"button\",\"opt\" + (current===o[0] ? \" on\" : \"\"), '\u003cspan\u003e'+o[1]+'\u003c/span\u003e' + (o[2] ? '\u003cspan class=\"od\"\u003e'+o[2]+'\u003c/span\u003e' : \"\"));\n b.type = \"button\";\n b.addEventListener(\"click\", function(){ onPick(o[0]); });\n wrap.appendChild(b);\n });\n return wrap;\n}\n/* v4: plain \"empty box the client adds the number to\" pattern for optional numeric detail\n (Horsepower, Amperage, Frequency, Speed) - replacing the earlier tier-bucket button groups\n per direct client feedback. These fields never gate the workflow and nothing else on screen\n depends on them live, so the input just updates the model on every keystroke without ever\n re-rendering - no raw-string/reRenderPreserveFocus machinery needed, unlike Power Rating. */\nfunction numBox(obj, field, placeholder){\n var inp = document.createElement(\"input\"); inp.type=\"text\"; inp.inputMode=\"decimal\";\n inp.value = obj[field] || \"\";\n if (placeholder) inp.placeholder = placeholder;\n inp.addEventListener(\"input\", function(){ obj[field] = inp.value; });\n return inp;\n}\n/* v4 bug found while testing: gearbox shaft count uses the same \"empty box\" pattern, but unlike\n HP/Amperage/Frequency/Speed it DOES gate tabValid() - so with plain numBox() the Next button's\n disabled attribute went stale the instant shaft count was typed (it only reflects tabValid()\n as of the last render, and numBox never triggers one). A field that gates anything must\n re-render after every keystroke; this variant does that with the same focus-preserving\n approach already used for Power Rating and the Review screen's puck overrides, keyed by\n data-refocus so typing doesn't lose its place. */\nfunction numBoxLive(obj, field, placeholder, refocusKey){\n var inp = document.createElement(\"input\"); inp.type=\"text\"; inp.inputMode=\"decimal\";\n inp.value = obj[field] || \"\";\n if (placeholder) inp.placeholder = placeholder;\n if (refocusKey) inp.setAttribute(\"data-refocus\", refocusKey);\n inp.addEventListener(\"input\", function(){ obj[field] = inp.value; reRenderPreserveFocus(); });\n return inp;\n}\n\n/* ================= LEFT PANE: asset hierarchy tree ================= */\nfunction stepInstance(pkg, delta){\n pkg.instanceCount = clamp((pkg.instanceCount||1) + delta, 1, 999);\n renderTree(); renderCard();\n}\nfunction removeAsset(id){\n assets = assets.filter(function(p){ return p.id !== id; });\n if (editingId === id){ draft = null; editingId = null; }\n renderTree(); renderCard();\n}\nfunction duplicateAsset(pkg){\n var copy = JSON.parse(JSON.stringify(pkg));\n copy.id = \"pkg\"+(pkgSeq++);\n copy.name = pkg.name + \" (copy)\";\n assets.push(copy);\n renderTree();\n}\nfunction editAsset(pkg){\n draft = JSON.parse(JSON.stringify(pkg));\n editingId = pkg.id;\n recomputeUnlock(draft);\n draft.maxUnlocked = TABS.length; /* diagnostic #6: a previously completed asset opens fully unlocked */\n draft.activeTab = 0;\n renderTree(); renderCard();\n}\nfunction critLabel(c){ return {very:\"Very critical\", high:\"High\", medium:\"Medium\", low:\"Low\"}[c] || \"Medium\"; }\nfunction renderAssetNode(pkg){\n var n = el(\"div\",\"assetNode\" + (editingId===pkg.id ? \" sel\" : \"\"));\n var top = el(\"div\",\"an-top\");\n top.appendChild(el(\"div\",null,'\u003cdiv class=\"an-name\"\u003e'+pkg.name+'\u003c/div\u003e\u003cdiv class=\"an-type\"\u003e'+(pkg.machineType||\"Equipment\")+'\u003c/div\u003e'));\n n.appendChild(top);\n var badges = el(\"div\",\"an-badges\");\n badges.appendChild(el(\"span\",\"badge crit-\"+pkg.criticality, critLabel(pkg.criticality)));\n if (pkg.drivers.length\u003e1) badges.appendChild(el(\"span\",\"badge\", pkg.drivers.length+\" drivers\"));\n if (!allTabsComplete(pkg)) badges.appendChild(el(\"span\",\"badge incomplete\",\"Incomplete\"));\n n.appendChild(badges);\n var p = priceFor(pkg);\n var priceLabel = leadCaptured() ? (money(p.total) + \"/mo per unit\") : \"Pricing locked\";\n n.appendChild(el(\"div\",\"an-price\", priceLabel + \" \u00b7 \" + calcVib(pkg) + \" pucks\"));\n var row = el(\"div\",\"an-row\");\n var st = el(\"div\",\"stepper\");\n var minus = el(\"button\",null,\"\u2212\"); minus.type=\"button\"; minus.addEventListener(\"click\", function(ev){ ev.stopPropagation(); stepInstance(pkg,-1); });\n var plus = el(\"button\",null,\"+\"); plus.type=\"button\"; plus.addEventListener(\"click\", function(ev){ ev.stopPropagation(); stepInstance(pkg,1); });\n st.appendChild(minus); st.appendChild(el(\"span\",\"sn\", String(pkg.instanceCount||1))); st.appendChild(plus);\n row.appendChild(st);\n var act = el(\"div\",\"an-actions\");\n var eb = el(\"button\",null,\"edit\"); eb.type=\"button\"; eb.addEventListener(\"click\", function(ev){ ev.stopPropagation(); editAsset(pkg); });\n var db = el(\"button\",null,\"duplicate\"); db.type=\"button\"; db.title=\"Create a copy of this asset with the same configuration\"; db.addEventListener(\"click\", function(ev){ ev.stopPropagation(); duplicateAsset(pkg); });\n var rb = el(\"button\",null,\"remove\"); rb.type=\"button\"; rb.addEventListener(\"click\", function(ev){ ev.stopPropagation(); removeAsset(pkg.id); });\n act.appendChild(eb); act.appendChild(db); act.appendChild(rb);\n row.appendChild(act);\n n.appendChild(row);\n n.addEventListener(\"click\", function(){ editAsset(pkg); });\n return n;\n}\nvar collapsedZones = {};\nfunction renderTree(){\n var host = document.getElementById(\"tree\");\n host.innerHTML = \"\";\n /* v4: the plant info from the intake screen now sits at the top of the hierarchy itself, not\n just in the topbar link (diagnostic #17) - \"put that information on the asset hierarchy...\n and keep updating it with new assets\". Since this reads `plant` fresh on every renderTree()\n call, and renderTree() already runs after every save/edit/remove, it stays current with no\n extra wiring. */\n var plantBox = el(\"div\",\"treePlantInfo\");\n plantBox.appendChild(el(\"div\",\"tp-company\", plant.company || \" - \"));\n if (plant.name) plantBox.appendChild(el(\"div\",\"tp-plant\", plant.name));\n var metaParts = [];\n if (plant.industry) metaParts.push(plant.industry);\n var loc = [plant.state, plant.country].filter(function(x){ return x && x.trim(); }).join(\", \");\n if (loc) metaParts.push(loc);\n if (metaParts.length) plantBox.appendChild(el(\"div\",\"tp-meta\", metaParts.join(\" \u00b7 \")));\n plantBox.appendChild(el(\"div\",\"tp-edit\",\"Edit plant details\"));\n plantBox.style.cursor = \"pointer\";\n plantBox.title = \"Edit plant details\";\n plantBox.addEventListener(\"click\", function(){ showPlantStage(); });\n host.appendChild(plantBox);\n if (leadCaptured() && !plant.leadSentAt) submitLead(\"lead\");\n\n host.appendChild(el(\"h2\",null,\"Asset Hierarchy\"));\n if (!assets.length){\n host.appendChild(el(\"div\",\"treeEmpty\",\"No equipment packages yet. Add your first asset in the form to start building the plant hierarchy.\"));\n }\n /* Group by plant area/zone when more than one zone bucket is actually in use (diagnostic #9)\n - an unzoned fleet renders exactly as a flat list. Each zone group is independently\n collapsible/expandable (diagnostic #17: \"I want that tree being able to expand it or\n minimize each area\") - collapsedZones is a plain in-memory map, not written to any asset,\n so it never affects saved data or pricing. */\n var zoneOrder = [], zoneMap = {};\n assets.forEach(function(pkg){\n var z = (pkg.zone||\"\").trim() || \"Unassigned\";\n if (!zoneMap[z]){ zoneMap[z] = []; zoneOrder.push(z); }\n zoneMap[z].push(pkg);\n });\n var showZoneHeads = zoneOrder.length \u003e 1 || (zoneOrder.length===1 && zoneOrder[0] !== \"Unassigned\");\n zoneOrder.forEach(function(z){\n if (showZoneHeads){\n var collapsed = !!collapsedZones[z];\n var zh = el(\"div\",\"zoneHead\" + (collapsed ? \" collapsed\" : \"\"));\n zh.appendChild(el(\"span\",null, z + \" (\" + zoneMap[z].length + \")\"));\n zh.appendChild(el(\"span\",\"zoneCaret\", collapsed ? \"\u25b8\" : \"\u25be\"));\n zh.addEventListener(\"click\", function(){ collapsedZones[z] = !collapsedZones[z]; renderTree(); });\n host.appendChild(zh);\n if (collapsed) return;\n }\n var group = showZoneHeads ? el(\"div\",\"zoneGroup\") : host;\n zoneMap[z].forEach(function(pkg){ group.appendChild(renderAssetNode(pkg)); });\n if (showZoneHeads) host.appendChild(group);\n });\n var addBtn = el(\"button\",\"addAssetBtn\",\"+ Add asset\");\n addBtn.type = \"button\";\n addBtn.addEventListener(\"click\", function(){ draft = null; editingId = null; renderCard(); });\n host.appendChild(addBtn);\n\n /* v11: client flagged that there was nowhere in the tool visibly showing that progress gets\n saved - persistLocal() has run silently on every render since v10, but silent is exactly the\n problem: there was no on-screen confirmation, so from the outside it read as a feature that\n didn't exist. This row is always shown, even with zero assets (unlike the quote/share actions\n below, which need at least one asset to mean anything), and it is the SAME save mechanism - \n clicking it doesn't add a second save path, it just makes the existing one visible and gives\n it an explicit \"Saved \u2713\" confirmation plus a persistent last-saved timestamp. */\n var saveRow = el(\"div\",\"quoteActions\");\n var saveBtn = el(\"button\",\"btn ghost\",\"Save progress\"); saveBtn.type=\"button\";\n saveBtn.title = \"Saves everything on this plant to this browser, so it's still here if you close the tab and come back.\";\n saveBtn.setAttribute(\"data-status-target\",\"saveStatusTree\");\n saveBtn.addEventListener(\"click\", function(){ saveProgressNow(saveBtn); });\n saveRow.appendChild(saveBtn);\n var saveStatus = el(\"div\",\"hint\", savedStatusText()); saveStatus.id = \"saveStatusTree\";\n saveRow.appendChild(saveStatus);\n host.appendChild(saveRow);\n\n if (assets.length){\n var t = totals();\n var tt = el(\"div\",\"treeTotals\");\n tt.appendChild(el(\"div\",\"tt-row\",'\u003cspan\u003eAssets\u003c/span\u003e\u003cb\u003e'+t.assets+'\u003c/b\u003e'));\n tt.appendChild(el(\"div\",\"tt-row\",'\u003cspan\u003ePucks\u003c/span\u003e\u003cb\u003e'+t.pucks+'\u003c/b\u003e'));\n tt.appendChild(el(\"div\",\"tt-row\",'\u003cspan\u003eGateways\u003c/span\u003e\u003cb\u003e'+t.gateways+'\u003c/b\u003e'));\n if (leadCaptured()){\n tt.appendChild(el(\"div\",\"tt-price\", money(t.monthly)+\"/mo\"));\n } else {\n tt.appendChild(el(\"div\",\"tt-price lockedPrice\",\"Pricing locked\"));\n var unlockBtn = el(\"button\",\"lockLink\",\"Add your name and email to view pricing\");\n unlockBtn.type = \"button\";\n unlockBtn.addEventListener(\"click\", showPlantStage);\n tt.appendChild(unlockBtn);\n }\n var qa = el(\"div\",\"quoteActions\");\n var shareBtn = el(\"button\",\"btn ghost\",\"Copy shareable link\"); shareBtn.type=\"button\"; shareBtn.id=\"shareLinkBtn\";\n shareBtn.title = \"Encodes this exact configuration into a link - opening it restores everything, on any device.\";\n shareBtn.addEventListener(\"click\", copyShareLink);\n var quoteBtn = el(\"button\",\"btn\",\"View / export quote\"); quoteBtn.type=\"button\";\n quoteBtn.disabled = !leadCaptured();\n quoteBtn.title = leadCaptured() ? \"Open a printable quote for this plant.\" : \"Add your name and email above to unlock the quote.\";\n quoteBtn.addEventListener(\"click\", openQuote);\n qa.appendChild(shareBtn); qa.appendChild(quoteBtn);\n tt.appendChild(qa);\n host.appendChild(tt);\n }\n persistLocal();\n var liveStatus = document.getElementById(\"saveStatusTree\");\n if (liveStatus) liveStatus.textContent = savedStatusText();\n}\n\n/* ================= RIGHT PANE: baseline + tabbed drivetrain wizard ================= */\n/* v5: the equipment-type picker (Pumps / Fans / Compressors / Heavy Equipment / ...) used to be\n a standalone screen shown before Baseline - that's what the client called \"the second page.\"\n Per direct feedback, it now lives inside Block 5 (Driven Unit) instead, so the very next thing\n after Plant Info is Baseline followed immediately by the 5-block question sequence. Selecting a\n type here resets driven_unit's contextual answers, since switching from e.g. Pump to Crusher\n would otherwise leave stale category/bearing-arrangement data behind. */\nfunction renderDrivenPicker(pkg, host){\n var wrap = el(\"div\");\n var searchWrap = el(\"div\",\"search\");\n var inp = document.createElement(\"input\"); inp.type=\"text\"; inp.placeholder=\"Search equipment type\u2026\";\n searchWrap.appendChild(inp);\n wrap.appendChild(searchWrap);\n var grid = el(\"div\");\n wrap.appendChild(grid);\n function paint(filter){\n grid.innerHTML = \"\";\n var f = (filter||\"\").toLowerCase();\n CATALOG.forEach(function(cat){\n var items = cat.items.filter(function(it){ return !f || it.toLowerCase().indexOf(f)\u003e=0; });\n if (!items.length) return;\n var sec = el(\"div\",\"cat\");\n sec.appendChild(el(\"h3\",null,cat.cat));\n var mg = el(\"div\",\"mgrid\");\n items.forEach(function(label){\n var b = el(\"button\",\"mcardbtn\" + (pkg.machineType===label ? \" on\" : \"\"), label); b.type=\"button\";\n b.addEventListener(\"click\", function(){\n if (pkg.machineType !== label){\n pkg.machineType = label; pkg.machineCat = cat.cat;\n pkg.driven_unit = { category:\"\", cylinders:\"\", stages:\"\", bearingArrangement:\"\", shaftCount:\"\" };\n }\n renderCard();\n });\n mg.appendChild(b);\n });\n sec.appendChild(mg);\n grid.appendChild(sec);\n });\n }\n paint(\"\");\n inp.addEventListener(\"input\", function(){ paint(inp.value); });\n host.appendChild(wrap);\n}\n\nfunction driverTypeFields(d){\n /* v13: Turbine and Engine driver types are retired (this tool covers motors only for now - see\n diagnostic addendum below), so this is unconditionally the motor's optional-detail fields.\n Control type (VFD?) and MCC/Panel Access both moved out of here and into renderDriverCard's\n always-visible vital fields - see diagnostic #13. Everything left in this function is\n optional supplementary detail, shown only inside the collapsible box below. */\n var wrap = document.createDocumentFragment();\n wrap.appendChild(fieldWrap(\"Insulation Class (IC)\",\"The motor's insulation temperature rating (on the nameplate) - Class F and H are the most common industrial ratings.\",\n optGroup([[\"b\",\"Class B\"],[\"f\",\"Class F\"],[\"h\",\"Class H\"]], d.insulation_class, null, function(v){ d.insulation_class=v; renderCard(); })));\n wrap.appendChild(fieldWrap(\"Voltage Level (V)\",\"The motor's supply voltage - affects which current-analysis hardware fits the panel.\",\n optGroup([[\"lv\",\"Low voltage (\u003c1kV)\"],[\"mv\",\"Medium voltage (1-15kV)\"]], d.voltage_level, null, function(v){ d.voltage_level=v; renderCard(); })));\n return wrap;\n}\n\nfunction renderDriverCard(pkg, d, i, host){\n var dc = el(\"div\",\"driverCard\");\n var head = el(\"div\",\"dcHead\");\n head.appendChild(el(\"b\",null, (d.driver_type.charAt(0).toUpperCase()+d.driver_type.slice(1)) + \" #\" + (i+1)));\n if (pkg.drivers.length \u003e 1){\n var rm = el(\"button\",\"dcRemove\",\"remove\"); rm.type=\"button\";\n rm.addEventListener(\"click\", function(){ pkg.drivers.splice(i,1); renderCard(); });\n head.appendChild(rm);\n }\n dc.appendChild(head);\n /* v13: Turbine and Engine driver types are retired - this tool covers motors only for now (see\n diagnostic addendum below), so the \"Driver type\" picker is gone; every driver is a motor. */\n /* v13: a motor's vital size question is Horsepower + Operating speed (RPM), not kW - per client\n request, sensor count for a motor is looked up from an ISO 20816-3 / NEMA MG-1 HP-and-speed\n tier table (see motorSensorCount()), which needs both numbers, not a single kW figure. Both\n fields gate tabValid() so they re-render on every keystroke, the same live-gating pattern\n used for gearbox shaft count. */\n dc.appendChild(fieldWrap(\"Horsepower (HP)\",\"The motor's nameplate horsepower. Combined with operating speed below, this determines the sensor count per ISO 20816-3 / NEMA MG-1 machine-size tiers.\",\n numBoxLive(d, \"horsepower\", \"e.g. 75\", \"hp-\"+d.id), true));\n dc.appendChild(fieldWrap(\"Operating speed (RPM)\",\"The motor's nameplate rotational speed. A low-speed motor (below 1,000 RPM) needs two-point bearing coverage regardless of horsepower.\",\n numBoxLive(d, \"speed_rpm\", \"e.g. 1780\", \"rpm-\"+d.id), true));\n /* Vital questions for a motor, per the client: VFD-or-not and MCC/panel access are the other\n two things that matter up front (diagnostic #13) - MCC access determines whether\n electrical monitoring can be delivered on this driver at all, which is as consequential as\n the size question above. Everything else about the motor is optional supplementary detail\n and lives in the collapsible box below, never gating the workflow. */\n dc.appendChild(fieldWrap(\"Variable Frequency Drive (VFD)?\",\"Whether this motor runs at fixed speed across-the-line, or has a Variable Frequency Drive controlling its speed.\",\n optGroup([[\"vfd\",\"Yes\"],[\"fixed\",\"No\"]], d.control_type, \"row2\", function(v){ d.control_type=v; renderCard(); }), true));\n dc.appendChild(fieldWrap(\"MCC / Panel Access\",\"Whether a technician can safely open the motor control panel to clamp on current-analysis hardware. This does not affect vibration sensor count, but it determines whether electrical monitoring can be offered on this driver.\",\n optGroup([[\"yes\",\"Yes\"],[\"no\",\"No\"],[\"notsure\",\"Not sure\"]], d.mcc_panel_access, \"wide\", function(v){ d.mcc_panel_access=v; renderCard(); }), true));\n /* Toggle lives right above the box it opens (one shared pkg.advancedOpen flag covers every\n driver card, so they all expand/collapse together, but each card gets its own visible\n control right next to its own optional fields rather than one control stranded below\n every card's content). */\n var advToggle = el(\"div\",\"toggleRow\" + (pkg.advancedOpen ? \" on\" : \"\"));\n advToggle.appendChild(el(\"div\",\"tSwitch\"));\n advToggle.appendChild(el(\"span\",\"tLabel\",\"Additional optional information\"));\n advToggle.addEventListener(\"click\", function(){ pkg.advancedOpen = !pkg.advancedOpen; renderCard(); });\n dc.appendChild(advToggle);\n if (pkg.advancedOpen){\n /* v4: plain number boxes instead of tier-bucket buttons, per direct client feedback - \n diagnostic #13. Nothing downstream reads these, so no render is needed on input (numBox\n just updates the model directly); see numBox's own comment for why that also sidesteps\n the earlier focus-loss/infinite-loop bug class rather than needing the same workaround. */\n var adv = el(\"div\",\"advBlock on\");\n /* v13: HP and RPM are asked as vitals above (they feed the sensor-count lookup directly), so\n they're left out here to avoid asking twice. */\n adv.appendChild(fieldWrap(\"A\",\"The driver's full-load amperage - helps size current-analysis hardware.\", numBox(d,\"amperage\",\"e.g. 62\")));\n adv.appendChild(fieldWrap(\"Hz\",\"The electrical supply frequency this driver runs on.\", numBox(d,\"line_freq\",\"e.g. 60\")));\n adv.appendChild(driverTypeFields(d));\n dc.appendChild(adv);\n }\n host.appendChild(dc);\n}\n\nfunction renderTabPanel(pkg, host){\n var key = TABS[pkg.activeTab].key;\n var panel = el(\"div\",\"tabPanel\");\n var drivenDetachedEl = null; /* v11: \"More about this \u003ctype\u003e\" card for the driven block, kept\n out of panel so it renders as its own detached section below it */\n if (key === \"driver\"){\n pkg.drivers.forEach(function(d,i){ renderDriverCard(pkg, d, i, panel); });\n var addDrv = el(\"button\",\"addDriverBtn\",\"+ Add another driver (multi-drive asset)\");\n addDrv.type=\"button\";\n addDrv.addEventListener(\"click\", function(){ pkg.drivers.push(newDriver(\"motor\")); renderCard(); });\n panel.appendChild(addDrv);\n }\n else if (key === \"coupling1\"){\n panel.appendChild(fieldWrap(\"Coupling type\",\"How the driver connects to the next stage down the drivetrain.\",\n optGroup(COUPLING_TYPES, pkg.coupling_nodes[0].type, \"wide\", function(v){ pkg.coupling_nodes[0].type=v; renderCard(); }), true));\n panel.appendChild(fieldWrap(\"Arrangement orientation\",\"The physical orientation of this coupling relative to the shafts it joins.\",\n optGroup(ORIENTATIONS, pkg.coupling_nodes[0].orientation, \"wide\", function(v){ pkg.coupling_nodes[0].orientation=v; renderCard(); }), true));\n }\n else if (key === \"inter\"){\n panel.appendChild(fieldWrap(\"Is there an intermediate stage between the driver and the driven unit?\",\"A jackshaft, countershaft, or gearbox stage sitting between the driver and the final driven machine - its own bearings, running at its own speed. Most direct-coupled or belt-driven assets have none - pick No to skip straight to Coupling 2.\",\n optGroup([[\"yes\",\"Yes\"],[\"no\",\"No, skip this block\"]], pkg.intermediateAnswer, \"row2\", function(v){\n pkg.intermediateAnswer = v;\n if (v===\"yes\" && !pkg.intermediates.length) pkg.intermediates.push(newIntermediate());\n if (v===\"no\") pkg.intermediates = [];\n syncInterCouplings(pkg);\n renderCard();\n }), true));\n if (pkg.intermediateAnswer === \"yes\"){\n /* v4: a stage is now a single element type - Gearbox / Jackshaft / Other, never more than\n one at once (diagnostic #14: \"I prefer to select only one at a time\"). Two elements on\n one shaft train become two separate stages, and once there is more than one stage, the\n coupling between each consecutive pair is asked right after the second of that pair. */\n pkg.intermediates.forEach(function(inter, i){\n var ic = el(\"div\",\"driverCard\");\n var ih = el(\"div\",\"dcHead\"); ih.appendChild(el(\"b\",null,\"Intermediate stage #\"+(i+1)));\n if (pkg.intermediates.length\u003e1){\n var rm = el(\"button\",\"dcRemove\",\"remove\"); rm.type=\"button\";\n rm.addEventListener(\"click\", function(){ pkg.intermediates.splice(i,1); syncInterCouplings(pkg); renderCard(); });\n ih.appendChild(rm);\n }\n ic.appendChild(ih);\n ic.appendChild(fieldWrap(\"Element type\",\"Pick the one that applies to this stage. If this shaft train actually has both a gearbox and a jackshaft, add a second intermediate stage below for the other one, then describe how the two connect.\",\n optGroup([[\"gearbox\",\"Gearbox\"],[\"jackshaft\",\"Jackshaft\"],[\"other\",\"Other\"]], inter.type, \"wide\", function(v){ inter.type=v; renderCard(); }), true));\n if (inter.type === \"gearbox\"){\n ic.appendChild(fieldWrap(\"Shafts\",\"How many shafts this gearbox has (input, output, and any intermediate shafts) - sets how many bearing locations get monitored on it.\", numBoxLive(inter,\"shaftCount\",\"e.g. 2\",\"shaftCount-\"+inter.id), true));\n }\n panel.appendChild(ic);\n if (i \u003c pkg.intermediates.length - 1){\n if (!pkg.interCouplings[i]) pkg.interCouplings[i] = { type:\"\", orientation:\"\" };\n var jc = pkg.interCouplings[i];\n var cpl = el(\"div\",\"driverCard\");\n cpl.appendChild(el(\"div\",\"dcHead\",'\u003cb\u003eCoupling - Intermediate #'+(i+1)+' to #'+(i+2)+'\u003c/b\u003e'));\n cpl.appendChild(fieldWrap(\"Coupling type\",\"How intermediate stage #\"+(i+1)+\" connects to stage #\"+(i+2)+\".\",\n optGroup(COUPLING_TYPES, jc.type, \"wide\", function(v){ jc.type=v; renderCard(); }), true));\n cpl.appendChild(fieldWrap(\"Arrangement orientation\",\"The physical orientation of this coupling relative to the shafts it joins.\",\n optGroup(ORIENTATIONS, jc.orientation, \"wide\", function(v){ jc.orientation=v; renderCard(); }), true));\n panel.appendChild(cpl);\n }\n });\n var addInt = el(\"button\",\"addInterBtn\",\"+ Add another intermediate stage\");\n addInt.type=\"button\";\n addInt.addEventListener(\"click\", function(){ pkg.intermediates.push(newIntermediate()); syncInterCouplings(pkg); renderCard(); });\n panel.appendChild(addInt);\n }\n }\n else if (key === \"coupling2\"){\n if (pkg.intermediateAnswer !== \"yes\" || pkg.intermediates.length === 0){\n panel.appendChild(el(\"div\",\"lockNote\",\"No intermediate stage was configured in Block 3, so there is no second boundary to describe. This block is automatically complete - add an intermediate stage in Block 3 first if one exists.\"));\n } else {\n panel.appendChild(fieldWrap(\"Coupling type\",\"How the intermediate stage connects onward to the driven machine.\",\n optGroup(COUPLING_TYPES, pkg.coupling_nodes[1].type, \"wide\", function(v){ pkg.coupling_nodes[1].type=v; renderCard(); }), true));\n panel.appendChild(fieldWrap(\"Arrangement orientation\",\"The physical orientation of this second coupling.\",\n optGroup(ORIENTATIONS, pkg.coupling_nodes[1].orientation, \"wide\", function(v){ pkg.coupling_nodes[1].orientation=v; renderCard(); }), true));\n }\n }\n else if (key === \"driven\"){\n /* v5: the equipment-type picker itself now lives here - see renderDrivenPicker's comment - \n followed by the same contextual follow-ups as before: bearing arrangement for Fan/Blower\n and Pump (API 610-style Overhung vs Between Bearings), and a process category for\n Pump/Compressor (Centrifugal / Reciprocating / Rotary / Screw), with a cylinder count for\n Reciprocating and a casing question for Centrifugal. Any element with more than one of the\n same wearing part - a gearbox's shafts, a reciprocating machine's cylinders - asks for the\n exact count as a number box rather than a capped bucket, per direct client feedback\n (\"make sure at any stage, if I selected an element which could have more than one shaft,\n ask for the number\"). */\n var pickHead = el(\"div\",\"fld\");\n var pickRow = el(\"div\",\"fldLabelRow\");\n pickRow.appendChild(el(\"label\",null,'Equipment type'));\n var pickTip = el(\"button\",\"infoIcon\",\"i\"); pickTip.type=\"button\";\n pickTip.setAttribute(\"data-tip\",\"Optional. What the driver is actually turning. Pick the specific machine so Chladni can ask the right follow-up questions and calculate the correct monitoring points - leave it blank if you're not sure, and this asset still gets a generic driven-component estimate.\");\n pickRow.appendChild(pickTip);\n pickHead.appendChild(pickRow);\n panel.appendChild(pickHead);\n renderDrivenPicker(pkg, panel);\n\n /* v6: per direct client feedback, the follow-up questions (category, bearing arrangement,\n cylinder count, ...) were sitting directly under the equipment-type grid with nothing to\n set them apart, which read as one long confusing list. They now sit in their own bordered\n section with its own heading, appearing only once an equipment type that actually has\n follow-ups has been picked - so the section itself doubles as a visual \"this is now asking\n about your specific machine\" cue rather than blending into the type picker above it.\n v11: that bordered section was still nested INSIDE the equipment-type picker's own bordered\n box, reading as a box-within-a-box rather than a clearly separate section (direct client\n feedback). It's now appended as its own standalone card straight to the block, below and\n fully detached from the picker's box, with visible space between the two. */\n if (pkg.machineType === \"Compressor\" || pkg.machineType === \"Pump\" || pkg.machineType === \"Fan / Blower\" || pkg.machineType === \"Gearbox / Speed Reducer\"){\n var detailBox = el(\"div\",\"driverCard detachedCard\");\n detailBox.appendChild(el(\"div\",\"dcHead\",'\u003cb\u003eMore about this ' + pkg.machineType + '\u003c/b\u003e'));\n if (pkg.machineType === \"Compressor\"){\n detailBox.appendChild(fieldWrap(\"Compressor category\",\"The compressor's operating principle - determines which failure modes and monitoring points apply.\",\n optGroup([[\"centrifugal\",\"Centrifugal\"],[\"reciprocating\",\"Reciprocating\"],[\"screw\",\"Rotary Screw\"],[\"rotaryother\",\"Rotary (Other)\"]], pkg.driven_unit.category, \"wide\", function(v){ pkg.driven_unit.category=v; renderCard(); }), true));\n if (pkg.driven_unit.category === \"reciprocating\"){\n detailBox.appendChild(fieldWrap(\"Cylinders / stages\",\"How many cylinders on the compressor frame - each one gets its own cylinder & valve monitoring point.\",\n numBoxLive(pkg.driven_unit,\"cylinders\",\"e.g. 2\",\"comp-cyl-\"+pkg.id), true));\n } else if (pkg.driven_unit.category === \"centrifugal\"){\n detailBox.appendChild(fieldWrap(\"Casing configuration\",\"Whether the compressor is a single casing or multiple casings / multistage.\",\n optGroup([[\"single\",\"Single casing\"],[\"multi\",\"Multiple casings / multistage\"]], pkg.driven_unit.stages, \"row2\", function(v){ pkg.driven_unit.stages=v; renderCard(); }), true));\n }\n } else if (pkg.machineType === \"Pump\"){\n detailBox.appendChild(fieldWrap(\"Pump category\",\"The pump's operating principle - determines which failure modes and monitoring points apply.\",\n optGroup([[\"centrifugal\",\"Centrifugal\"],[\"reciprocating\",\"Reciprocating\"],[\"rotary\",\"Rotary\"],[\"screw\",\"Screw\"]], pkg.driven_unit.category, \"wide\", function(v){ pkg.driven_unit.category=v; renderCard(); }), true));\n if (pkg.driven_unit.category === \"reciprocating\"){\n detailBox.appendChild(fieldWrap(\"Cylinders / plungers\",\"How many cylinders/plungers on the pump - each one gets its own valve/plunger monitoring point.\",\n numBoxLive(pkg.driven_unit,\"cylinders\",\"e.g. 2\",\"pump-cyl-\"+pkg.id), true));\n }\n detailBox.appendChild(fieldWrap(\"Bearing arrangement\",\"Overhung (API 610 OH-type): the impeller sits outside the bearing span, on one side. Between Bearings (API 610 BB-type): the impeller sits between two bearings - needs monitoring at both ends.\",\n optGroup([[\"overhung\",\"Overhung\"],[\"between\",\"Between Bearings\"]], pkg.driven_unit.bearingArrangement, \"row2\", function(v){ pkg.driven_unit.bearingArrangement=v; renderCard(); }), true));\n } else if (pkg.machineType === \"Fan / Blower\"){\n detailBox.appendChild(fieldWrap(\"Bearing arrangement\",\"Overhung: the fan/blower wheel sits outside the bearing span, on one side. Between Bearings: the wheel sits between two bearings - needs monitoring at both ends.\",\n optGroup([[\"overhung\",\"Overhung\"],[\"between\",\"Between Bearings\"]], pkg.driven_unit.bearingArrangement, \"row2\", function(v){ pkg.driven_unit.bearingArrangement=v; renderCard(); }), true));\n } else if (pkg.machineType === \"Gearbox / Speed Reducer\"){\n detailBox.appendChild(fieldWrap(\"Shafts\",\"How many shafts this gearbox has (input, output, and any intermediate shafts) - sets how many bearing locations get monitored on it.\",\n numBoxLive(pkg.driven_unit,\"shaftCount\",\"e.g. 2\",\"driven-gb-shaft-\"+pkg.id), true));\n }\n drivenDetachedEl = detailBox;\n } else if (pkg.machineType){\n drivenDetachedEl = el(\"div\",\"lockNote\",\"No additional driven-unit questions apply to \"+pkg.machineType+\". This block is complete - the driven-side component (if any) is calculated automatically.\");\n }\n }\n host.appendChild(panel);\n if (drivenDetachedEl) host.appendChild(drivenDetachedEl);\n}\n\nfunction renderTabbedWizard(pkg, host){\n var tabbar = el(\"div\",\"tabbar\");\n TABS.forEach(function(t, i){\n var b = el(\"button\",\"tabbtn\" + (i===pkg.activeTab ? \" active\" : \"\") + (i \u003c pkg.activeTab && tabValid(pkg,t.key) ? \" done\" : \"\"),\n '\u003cspan class=\"tn\"\u003e'+t.n+'\u003c/span\u003e'+t.label);\n b.type = \"button\";\n b.disabled = i \u003e pkg.maxUnlocked;\n b.addEventListener(\"click\", function(){ if (i \u003c= pkg.maxUnlocked){ pkg.activeTab = i; renderCard(); } });\n tabbar.appendChild(b);\n });\n host.appendChild(tabbar);\n renderTabPanel(pkg, host);\n\n var row = el(\"div\",\"btnrow\");\n var backBtn = el(\"button\",\"btn ghost\",\"Back\");\n backBtn.type=\"button\"; backBtn.disabled = pkg.activeTab === 0;\n backBtn.addEventListener(\"click\", function(){ if (pkg.activeTab\u003e0){ pkg.activeTab--; renderCard(); } });\n row.appendChild(backBtn);\n\n var curValid = tabValid(pkg, TABS[pkg.activeTab].key);\n if (pkg.activeTab \u003c TABS.length - 1){\n var nextBtn = el(\"button\",\"btn\", \"Next -\u003e\");\n nextBtn.type=\"button\"; nextBtn.disabled = !curValid;\n nextBtn.addEventListener(\"click\", function(){\n recomputeUnlock(pkg);\n pkg.activeTab = Math.min(TABS.length-1, pkg.activeTab+1);\n renderCard();\n });\n row.appendChild(nextBtn);\n } else {\n var finishBtn = el(\"button\",\"btn\", \"Review & Finish -\u003e\");\n finishBtn.type=\"button\"; finishBtn.disabled = !curValid;\n finishBtn.addEventListener(\"click\", function(){ recomputeUnlock(pkg); pkg.activeTab = TABS.length; renderCard(); });\n row.appendChild(finishBtn);\n }\n host.appendChild(row);\n\n if (!allTabsComplete(pkg)){\n host.appendChild(el(\"div\",\"lockNote\",\"Pricing and the final component list stay locked until all 5 drivetrain blocks above are complete. \" + TABS.filter(function(t){return !tabValid(pkg,t.key);}).length + \" block(s) remaining.\"));\n }\n}\n\nfunction couplingDesc(node){\n if (!node || !node.type) return \"not configured\";\n var t = COUPLING_TYPES.filter(function(x){ return x[0]===node.type; })[0];\n var o = ORIENTATIONS.filter(function(x){ return x[0]===node.orientation; })[0];\n return (t ? t[1] : node.type) + (o ? \", \" + o[1] : \"\");\n}\nfunction blockHeadRow(label){\n var tr = el(\"tr\",\"reviewBlockHead\");\n var td = el(\"td\",null,label); td.colSpan = 2;\n tr.appendChild(td);\n return tr;\n}\nfunction couplingInfoRow(label){\n var tr = el(\"tr\");\n tr.appendChild(el(\"td\",null,label));\n tr.appendChild(el(\"td\",\"num\",\" - \"));\n return tr;\n}\nfunction puckRow(pkg, c){\n var tr = el(\"tr\");\n tr.appendChild(el(\"td\",null,c.label));\n var td = el(\"td\",\"num\");\n var inp = document.createElement(\"input\");\n inp.type = \"text\"; inp.inputMode = \"numeric\"; inp.className = \"puckOverrideInp\";\n inp.value = String(effectivePucks(pkg, c));\n inp.setAttribute(\"data-refocus\", \"puck-\"+c.key);\n inp.addEventListener(\"input\", function(){\n var raw = inp.value.trim();\n if (raw === \"\"){ delete pkg.puckOverrides[c.key]; }\n else {\n var n = parseInt(raw, 10);\n if (!isNaN(n) && n \u003e= 0) pkg.puckOverrides[c.key] = n;\n }\n reRenderPreserveFocus();\n });\n td.appendChild(inp);\n tr.appendChild(td);\n return tr;\n}\n/* v5: \"at the end you should be able to investigate if the path from driver to driven has been\n successfully implemented\" - this walks the same chain the 5 blocks built, in order, and either\n confirms every link is there or lists exactly what's missing. allTabsComplete() already gates\n reaching this screen, so in practice this should always come back verified; it's shown anyway\n as an explicit confirmation rather than a silent assumption. */\nfunction drivetrainChainSummary(pkg){\n var parts = [];\n parts.push(pkg.drivers.map(function(d,i){\n return (d.driver_type.charAt(0).toUpperCase()+d.driver_type.slice(1)) + (pkg.drivers.length\u003e1 ? \" #\"+(i+1) : \"\");\n }).join(\" + \"));\n parts.push(\"Coupling 1 (\" + couplingDesc(pkg.coupling_nodes[0]) + \")\");\n if (pkg.intermediateAnswer === \"yes\" && pkg.intermediates.length){\n pkg.intermediates.forEach(function(inter, i){\n var lbl = inter.type === \"gearbox\" ? \"Gearbox\" : inter.type === \"jackshaft\" ? \"Jackshaft\" : \"Other element\";\n parts.push(\"Intermediate #\"+(i+1)+\" (\" + lbl + \")\");\n if (i \u003c pkg.intermediates.length-1) parts.push(\"Coupling (\" + couplingDesc(pkg.interCouplings[i]) + \")\");\n });\n parts.push(\"Coupling 2 (\" + couplingDesc(pkg.coupling_nodes[1]) + \")\");\n }\n parts.push(\"Driven Unit (\" + (pkg.machineType || \" - \") + \")\");\n return parts;\n}\nfunction verifyDrivetrainPath(pkg){\n var issues = [];\n if (!pkg.drivers.length) issues.push(\"No driver is defined.\");\n pkg.drivers.forEach(function(d, i){\n if (!d.driver_type){\n issues.push(\"Driver #\"+(i+1)+\" has no driver type selected.\");\n } else {\n var hp = parseFloat(d.horsepower), rpm = parseFloat(d.speed_rpm);\n if (isNaN(hp) || hp \u003c= 0 || isNaN(rpm) || rpm \u003c= 0) issues.push(\"Driver #\"+(i+1)+\" is missing a valid horsepower or operating speed.\");\n }\n if (!d.control_type || !d.mcc_panel_access) issues.push(\"Driver #\"+(i+1)+\" is missing VFD or MCC access.\");\n });\n if (!pkg.coupling_nodes[0].type || !pkg.coupling_nodes[0].orientation) issues.push(\"Coupling 1 is not fully defined.\");\n if (pkg.intermediateAnswer === \"yes\"){\n if (!pkg.intermediates.length) issues.push(\"Intermediate stage was flagged Yes but none is defined.\");\n pkg.intermediates.forEach(function(inter, i){\n if (!inter.type) issues.push(\"Intermediate stage #\"+(i+1)+\" has no element type.\");\n if (inter.type === \"gearbox\" && !inter.shaftCount) issues.push(\"Gearbox stage #\"+(i+1)+\" is missing a shaft count.\");\n });\n for (var k=0; k\u003cpkg.intermediates.length-1; k++){\n var jc = pkg.interCouplings[k];\n if (!jc || !jc.type || !jc.orientation) issues.push(\"The coupling between intermediate stages #\"+(k+1)+\" and #\"+(k+2)+\" is not defined.\");\n }\n if (!pkg.coupling_nodes[1].type || !pkg.coupling_nodes[1].orientation) issues.push(\"Coupling 2 is not fully defined.\");\n } else if (pkg.intermediateAnswer !== \"no\"){\n issues.push(\"Whether an intermediate stage exists was never answered.\");\n }\n /* v11: equipment type is optional (see tabValid's \"driven\" case) - an unpicked type is no\n longer an issue here either. The follow-up-questions check still applies whenever a type\n that HAS follow-ups was picked but left half-answered. */\n if (pkg.machineType && !tabValid(pkg, \"driven\")){\n issues.push(\"Driven unit follow-up questions for \" + pkg.machineType + \" are incomplete.\");\n }\n return { ok: issues.length===0, issues: issues, chain: drivetrainChainSummary(pkg) };\n}\nfunction renderReview(pkg, host){\n var verify = verifyDrivetrainPath(pkg);\n if (verify.ok){\n host.appendChild(el(\"div\",\"sectionHead\",\"Drivetrain path - verified\"));\n host.appendChild(el(\"div\",\"hint\", \"Driver -\u003e \" + verify.chain.slice(1).join(\" -\u003e \")));\n } else {\n host.appendChild(el(\"div\",\"sectionHead\",\"Drivetrain path - incomplete\"));\n var ul = document.createElement(\"ul\");\n ul.style.paddingLeft = \"18px\";\n verify.issues.forEach(function(msg){ var li=document.createElement(\"li\"); li.className=\"hint\"; li.textContent=msg; ul.appendChild(li); });\n host.appendChild(ul);\n }\n host.appendChild(el(\"div\",\"sectionHead\",\"Final configuration - \" + pkg.name));\n var comps = calcComponents(pkg);\n var p = priceFor(pkg);\n var tbl = el(\"table\",\"quoteTable\");\n var thead = el(\"tr\"); [\"Component\",\"Pucks\"].forEach(function(h){ thead.appendChild(el(\"th\",null,h)); });\n tbl.appendChild(thead);\n /* v4: rows are grouped under a header per drivetrain block, and each puck count is an\n editable box - \"maybe I like to put 4 sensors on my gearbox, or only 2 would be enough\"\n (diagnostic #16). Coupling blocks are shown for transparency but never take an editable\n box: they contribute no pucks by design, and that's stated outright rather than just\n being absent from the table. */\n var driverComps = comps.filter(function(c){ return c.block===\"driver\"; });\n var interComps = comps.filter(function(c){ return c.block===\"inter\"; });\n var drivenComps = comps.filter(function(c){ return c.block===\"driven\"; });\n\n if (driverComps.length){\n tbl.appendChild(blockHeadRow(\"Driver\"));\n driverComps.forEach(function(c){ tbl.appendChild(puckRow(pkg, c)); });\n }\n tbl.appendChild(blockHeadRow(\"Coupling 1\"));\n tbl.appendChild(couplingInfoRow(\"Coupling 1 (\" + couplingDesc(pkg.coupling_nodes[0]) + \")\"));\n\n if (pkg.intermediateAnswer === \"yes\" && pkg.intermediates.length){\n tbl.appendChild(blockHeadRow(\"Intermediate\"));\n interComps.forEach(function(c){ tbl.appendChild(puckRow(pkg, c)); });\n pkg.interCouplings.forEach(function(jc, i){\n tbl.appendChild(couplingInfoRow(\"Coupling - Intermediate #\"+(i+1)+\" to #\"+(i+2)+\" (\" + couplingDesc(jc) + \")\"));\n });\n tbl.appendChild(blockHeadRow(\"Coupling 2\"));\n tbl.appendChild(couplingInfoRow(\"Coupling 2 (\" + couplingDesc(pkg.coupling_nodes[1]) + \")\"));\n }\n\n if (drivenComps.length){\n tbl.appendChild(blockHeadRow(\"Driven Unit\"));\n drivenComps.forEach(function(c){ tbl.appendChild(puckRow(pkg, c)); });\n }\n host.appendChild(tbl);\n host.appendChild(el(\"div\",\"hint\",\"Pucks shown are the calculated defaults - edit any box to override that component's count. Coupling blocks never take a sensor, by design.\"));\n var priceLine = money(p.total) + \"/mo\" + (p.discount ? \" (after \" + money(p.discount) + \" bundling discount)\" : \"\");\n host.appendChild(el(\"div\",\"quoteBig\", priceLine + \" per unit\"));\n if (mcaCharged(pkg)) host.appendChild(el(\"div\",\"hint\",\"Electrical (motor current) monitoring recommended - +\" + money(CONFIG.MCA_MONTHLY) + \"/mo, priced independently of the pucks above.\"));\n\n var bundleLine = el(\"label\",\"checkline\" + (pkg.bundleDiscountEligible ? \" on\" : \"\"));\n var bundleCb = document.createElement(\"input\"); bundleCb.type=\"checkbox\"; bundleCb.checked = pkg.bundleDiscountEligible;\n bundleCb.addEventListener(\"change\", function(){ pkg.bundleDiscountEligible = bundleCb.checked; renderCard(); });\n bundleLine.appendChild(bundleCb);\n bundleLine.appendChild(document.createTextNode(\"Apply the cross-domain bundling discount when both vibration and electrical monitoring are active\"));\n host.appendChild(bundleLine);\n host.appendChild(el(\"div\",\"hint\",\"Pricing-layer discount only, applied on top of the full totals above - never a reduction in sensor density. Vibration and electrical monitoring share no variable (calcVib / mcaCharged).\"));\n\n var row = el(\"div\",\"btnrow\");\n var backBtn = el(\"button\",\"btn ghost\",\"\u2190 Back to blocks\");\n backBtn.type=\"button\";\n backBtn.addEventListener(\"click\", function(){ pkg.activeTab = TABS.length-1; renderCard(); });\n /* v8 fix: the tab bar lets a user jump straight back to any unlocked tab (i \u003c= maxUnlocked),\n edit it, then jump straight to the last tab and hit Finish without ever passing through a\n Next button - the only place recomputeUnlock() ran mid-edit. Finish itself calls\n recomputeUnlock() but unconditionally opens Review regardless of the result, so Review could\n render (correctly) showing \"path incomplete,\" while this Save button stayed enabled and\n would still add the asset to the plant with a since-invalidated block. Gating it on\n verify.ok closes that path: Review can still be *viewed* in an incomplete state (useful for\n seeing exactly what's wrong), but nothing incomplete can be saved into the hierarchy. */\n var saveBtn = el(\"button\",\"btn\", editingId ? \"Save changes\" : \"Add to plant\");\n saveBtn.type=\"button\";\n saveBtn.disabled = !verify.ok;\n saveBtn.addEventListener(\"click\", function(){\n if (!verify.ok) return;\n if (editingId){\n var idx = assets.findIndex(function(a){ return a.id===editingId; });\n if (idx\u003e=0) assets[idx] = pkg;\n } else {\n assets.push(pkg);\n track(\"plant_builder_asset_completed\", { machine_type: pkg.machineType || \"unspecified\" });\n }\n draft = null; editingId = null;\n renderTree(); renderCard();\n });\n row.appendChild(backBtn); row.appendChild(saveBtn);\n host.appendChild(row);\n if (!verify.ok){\n host.appendChild(el(\"div\",\"lockNote\",\"This configuration can't be saved yet - go back to the block(s) listed above (\\\"Drivetrain path - incomplete\\\") and fix them first.\"));\n }\n}\n\nfunction renderBaseline(pkg, host){\n host.appendChild(el(\"div\",\"sectionHead\",\"Baseline\"));\n var nameInp = document.createElement(\"input\"); nameInp.type=\"text\"; nameInp.value=pkg.name;\n nameInp.className = (pkg.name.trim()===\"\" && pkg._nameTouched) ? \"errField\" : \"\";\n nameInp.placeholder = pkg.machineType ? ('e.g. \"' + pkg.machineType.split(\" \")[0] + ' P-101\"') : 'e.g. \"Asset name / tag number\"';\n nameInp.setAttribute(\"data-refocus\", \"assetName\");\n nameInp.addEventListener(\"input\", function(){ pkg.name = nameInp.value; reRenderPreserveFocus(); });\n /* Bug fix (v3.2): this blur handler used to call renderCard() (and, briefly while fixing the\n zone field below, a deferred reRenderPreserveFocus()) - both turned out to cause a genuine\n infinite render loop. Removing the currently-focused input from the DOM (which every render\n does, since #card is fully torn down and rebuilt) fires a *synchronous* blur on it as a side\n effect of the removal itself, not just on a real user-driven blur. So any rerender ever\n triggered from this handler re-fires this same handler again on the very next render's\n teardown, forever, with no user interaction required. The flag is still set immediately;\n showing the red inline error just waits for whatever render happens next for an unrelated\n reason (there is almost always one within a keystroke or two), which is a fine trade for not\n re-rendering on every removal-induced blur. */\n nameInp.addEventListener(\"blur\", function(){ pkg._nameTouched = true; });\n var nameField = fieldWrap(\"Asset name\", null, nameInp, true);\n if (pkg.name.trim()===\"\" && pkg._nameTouched) nameField.appendChild(el(\"div\",\"fldErr\",\"Asset name is required.\"));\n host.appendChild(nameField);\n\n var existingZones = [];\n assets.forEach(function(a){ var z=(a.zone||\"\").trim(); if (z && existingZones.indexOf(z)===-1) existingZones.push(z); });\n var zoneInp = document.createElement(\"input\"); zoneInp.type=\"text\"; zoneInp.value = pkg.zone || \"\";\n zoneInp.setAttribute(\"list\",\"zoneList\");\n zoneInp.setAttribute(\"data-refocus\", \"assetZone\");\n zoneInp.placeholder = \"e.g. Zone 1, Building A, North Wing\";\n zoneInp.addEventListener(\"input\", function(){ pkg.zone = zoneInp.value; });\n host.appendChild(fieldWrap(\"Plant area / zone\", \"Optional. Groups this asset under a physical section of the plant - like Zone 1, Building A, or North Wing - so the hierarchy on the left reflects how the plant is actually laid out, not just a flat equipment list.\", zoneInp));\n var zoneList = document.createElement(\"datalist\"); zoneList.id = \"zoneList\";\n existingZones.forEach(function(z){ var o=document.createElement(\"option\"); o.value=z; zoneList.appendChild(o); });\n host.appendChild(zoneList);\n\n host.appendChild(fieldWrap(\"Criticality\",\"What happens if this asset unexpectedly stops. Drives whether electrical (motor current) monitoring gets recommended - never changes the vibration sensor count.\",\n optGroup([[\"very\",\"Production stops immediately\"],[\"high\",\"Significant reduction\"],[\"medium\",\"Some disruption\"],[\"low\",\"Little impact\"]], pkg.criticality, \"row4\", function(v){ pkg.criticality=v; renderCard(); }), true));\n}\n\n/* ================= PLANT INFO SCREEN (gate ahead of the tree/card workspace) ================= */\nfunction renderPlantScreen(){\n var host = document.getElementById(\"plantCard\");\n host.innerHTML = \"\";\n host.appendChild(el(\"div\",\"crumb\",\"About the plant\"));\n host.appendChild(el(\"div\",\"cardTitle\",\"Tell us what machines you have.\u003cbr\u003eWe do the engineering.\"));\n host.appendChild(el(\"div\",\"hint\",\"Answer what you can see - the machine, how it's driven, how many motors. Chladni works out the pucks, the gateways, and the price, instantly.\"));\n host.appendChild(el(\"div\",\"scopeNote\",\"\u003cb\u003eScope note:\u003c/b\u003e this quotation tool applies to physically accessible rotating equipment - assets a technician can reach directly on the plant floor. Submerged or otherwise inaccessible equipment (e.g. submersible pumps), and assets already integrated into an existing continuous monitoring system (e.g. Bently Nevada), fall outside its scope. Guarding or enclosures do not affect eligibility, provided the asset remains reachable for sensor installation.\"));\n\n var companyInp = document.createElement(\"input\"); companyInp.type=\"text\"; companyInp.value=plant.company; companyInp.autocomplete=\"organization\";\n companyInp.addEventListener(\"input\", function(){ plant.company = companyInp.value; persistLocal(); });\n host.appendChild(fieldWrap(\"Company name\", null, companyInp, true));\n\n /* v11: client flagged the email box specifically as too small to comfortably read/type an\n address into - bumped both contact fields (kept the pair visually matched) to a dedicated\n larger class rather than resizing every text input in the tool, since the fix was scoped to\n this one complaint, not a general redesign. */\n var contactNameInp = document.createElement(\"input\"); contactNameInp.type=\"text\"; contactNameInp.className=\"bigInput\"; contactNameInp.value=plant.contactName; contactNameInp.autocomplete=\"name\";\n contactNameInp.addEventListener(\"input\", function(){ plant.contactName = contactNameInp.value; persistLocal(); });\n host.appendChild(fieldWrap(\"Your name\", \"Who Chladni should follow up with about this quote.\", contactNameInp, true));\n\n var contactEmailInp = document.createElement(\"input\"); contactEmailInp.type=\"email\"; contactEmailInp.className=\"bigInput\"; contactEmailInp.value=plant.contactEmail; contactEmailInp.autocomplete=\"email\";\n contactEmailInp.addEventListener(\"input\", function(){ plant.contactEmail = contactEmailInp.value; persistLocal(); });\n host.appendChild(fieldWrap(\"Your email\", \"Pricing on the tool stays locked until this is filled in with a valid address. A copy of the quote goes here.\", contactEmailInp, true));\n\n var nameInp = document.createElement(\"input\"); nameInp.type=\"text\"; nameInp.value=plant.name; nameInp.placeholder='e.g. \"Riverside Plant\"';\n nameInp.addEventListener(\"input\", function(){ plant.name = nameInp.value; });\n host.appendChild(fieldWrap(\"Plant name\", null, nameInp));\n\n var indSel = document.createElement(\"select\");\n INDUSTRIES.forEach(function(opt){\n var o = document.createElement(\"option\"); o.value=opt; o.textContent=opt;\n if (opt === plant.industry) o.selected = true;\n indSel.appendChild(o);\n });\n indSel.addEventListener(\"change\", function(){ plant.industry = indSel.value; });\n host.appendChild(fieldWrap(\"Industry\", null, indSel));\n\n var countryInp = document.createElement(\"input\"); countryInp.type=\"text\"; countryInp.value=plant.country;\n countryInp.addEventListener(\"input\", function(){ plant.country = countryInp.value; });\n host.appendChild(fieldWrap(\"Country\", null, countryInp));\n\n var stateInp = document.createElement(\"input\"); stateInp.type=\"text\"; stateInp.value=plant.state;\n stateInp.addEventListener(\"input\", function(){ plant.state = stateInp.value; });\n host.appendChild(fieldWrap(\"State / Province\", null, stateInp));\n\n if (plant._err) host.appendChild(el(\"div\",\"fldErr\", plant._err));\n\n var row = el(\"div\",\"btnrow\");\n var startBtn = el(\"button\",\"btn\", assets.length ? \"Continue\" : \"+ Add asset\");\n startBtn.type=\"button\";\n startBtn.addEventListener(\"click\", function(){\n /* v10: name/email are marked required (fieldWrap's asterisk) since pricing stays locked\n without them, but Continue itself is not blocked on them - a prospect can still explore\n the equipment configuration before deciding to hand over contact info. Company name is\n kept as the one hard gate here, unchanged from prior versions. */\n if (!plant.company.trim()){\n plant._err = \"Company name is needed so the quote has a name on it.\";\n renderPlantScreen();\n return;\n }\n plant._err = \"\";\n persistLocal();\n if (!plantContinueTracked){\n plantContinueTracked = true;\n track(\"plant_builder_plant_info_continue\", { industry: plant.industry });\n }\n enterAppStage();\n });\n row.appendChild(startBtn);\n host.appendChild(row);\n\n /* v11: a \"Save progress\" action visible on this very first screen too, not only once at least\n one asset exists - filling in company/contact info alone was already being autosaved, but\n with nothing on screen confirming it (see the matching addition in renderTree()). */\n var saveRow = el(\"div\",\"quoteActions\");\n var saveBtn = el(\"button\",\"btn ghost\",\"Save progress\"); saveBtn.type=\"button\";\n saveBtn.title = \"Saves what's filled in so far to this browser.\";\n saveBtn.setAttribute(\"data-status-target\",\"saveStatusPlant\");\n saveBtn.addEventListener(\"click\", function(){ saveProgressNow(saveBtn); });\n saveRow.appendChild(saveBtn);\n var saveStatus = el(\"div\",\"hint\", savedStatusText()); saveStatus.id = \"saveStatusPlant\";\n saveRow.appendChild(saveStatus);\n host.appendChild(saveRow);\n}\nfunction updateTopbarPlantLink(){\n /* v12-web: the topbar is gone in the Webflow-hosted build (the page has its own chrome); the\n plant box at the top of the tree is the \"edit plant details\" entry point now. Kept as a\n no-op so any shell that still renders #topbarRight keeps working. */\n var span = document.getElementById(\"topbarRight\");\n if (!span) return;\n span.innerHTML = \"\";\n var link = document.createElement(\"button\");\n link.type = \"button\"; link.className = \"topbarLink\";\n link.textContent = plant.company + (plant.name ? \" \u00b7 \" + plant.name : \"\");\n link.addEventListener(\"click\", function(){ showPlantStage(); });\n span.appendChild(link);\n}\nfunction enterAppStage(){\n appStage = \"app\";\n hideResumeBanner();\n document.getElementById(\"plantScreenOuter\").style.display = \"none\";\n document.getElementById(\"appgrid\").style.display = \"\";\n updateTopbarPlantLink();\n renderTree();\n renderCard();\n}\nfunction showPlantStage(){\n appStage = \"plant\";\n document.getElementById(\"appgrid\").style.display = \"none\";\n document.getElementById(\"plantScreenOuter\").style.display = \"\";\n renderPlantScreen();\n}\n\nfunction renderCard(){\n var host = document.getElementById(\"card\");\n host.innerHTML = \"\";\n /* v5: there is no longer a standalone \"what equipment are you adding?\" picker screen before\n Baseline (diagnostic #18) - as soon as there's no draft in progress, a blank one is created\n and the user goes straight to Baseline, then the 5-block sequence. The equipment type itself\n is now a Block 5 (Driven Unit) question. */\n if (!draft){ draft = newPackage(); editingId = null; }\n var pkg = draft;\n host.appendChild(el(\"div\",\"crumb\", pkg.machineCat || \"Add asset\"));\n host.appendChild(el(\"div\",\"cardTitle\", editingId ? \"Edit asset\" : (pkg.machineType ? \"New asset: \" + pkg.machineType : \"New asset\")));\n renderBaseline(pkg, host);\n\n if (!baselineValid(pkg)){\n host.appendChild(el(\"div\",\"lockNote\",\"Complete the baseline fields above (asset name, criticality) to unlock the 5-block drivetrain workflow.\"));\n return;\n }\n host.appendChild(el(\"div\",\"sectionHead\",\"Drivetrain - 5-Block Sequential Configuration\"));\n if (pkg.activeTab \u003e= TABS.length){\n renderReview(pkg, host);\n } else {\n renderTabbedWizard(pkg, host);\n }\n}\n\n/* ================= v10: BOOT - shareable-link restore, then offer to resume an autosave ================= */\nfunction hideResumeBanner(){\n document.getElementById(\"resumeBanner\").style.display = \"none\";\n}\nfunction showResumeBanner(state){\n var host = document.getElementById(\"resumeBanner\");\n host.innerHTML = \"\";\n var label = (state.plant && state.plant.company) ? state.plant.company : \"a previous session\";\n var bar = el(\"div\",\"resumeBanner\");\n bar.appendChild(el(\"span\",null,\"Pick up where you left off with \" + label + \"?\"));\n var btns = el(\"div\");\n var resumeBtn = document.createElement(\"button\"); resumeBtn.type=\"button\"; resumeBtn.textContent=\"Resume\";\n resumeBtn.addEventListener(\"click\", function(){\n applyRestoredState(state);\n hideResumeBanner();\n if (assets.length){ enterAppStage(); } else { renderPlantScreen(); }\n });\n var freshBtn = document.createElement(\"button\"); freshBtn.type=\"button\"; freshBtn.textContent=\"Start fresh\";\n freshBtn.style.marginLeft = \"8px\";\n freshBtn.addEventListener(\"click\", function(){\n try { localStorage.removeItem(AUTOSAVE_KEY); } catch(e){}\n hideResumeBanner();\n });\n btns.appendChild(resumeBtn); btns.appendChild(freshBtn);\n bar.appendChild(btns);\n host.appendChild(bar);\n host.style.display = \"\";\n}\nfunction boot(){\n var restored = bootRestore();\n track(\"plant_builder_page_view\", { restored: restored });\n}\nfunction bootRestore(){\n var hash = location.hash || \"\";\n if (hash.indexOf(\"#s=\") === 0){\n var linked = decodeState(hash.slice(3));\n if (linked && linked.assets){\n applyRestoredState(linked);\n try { history.replaceState(null, \"\", location.pathname + location.search); } catch(e){}\n if (assets.length){ enterAppStage(); return \"share_link\"; }\n }\n }\n try {\n var raw = localStorage.getItem(AUTOSAVE_KEY);\n if (raw){\n var saved = decodeState(raw);\n if (saved && ((saved.assets && saved.assets.length) || (saved.plant && saved.plant.company))){\n applyRestoredState(saved);\n if (assets.length || draft){ enterAppStage(); } else { renderPlantScreen(); }\n return \"autosave\";\n }\n }\n } catch(e){}\n renderPlantScreen();\n return \"none\";\n}\nwindow.addEventListener(\"beforeunload\", persistLocal);\nboot();\n"; document.head.appendChild(s); })();