← Files LegalQuants LitigationARCHIVED FILE
skills/legaldesign/assets/diligence-report.html
485 KB · Oct 2, 2026 · 00:33 UTC
<!doctype html>
<html lang="en" data-theme="light" data-template-id="legaldesign-report-template" data-document-title-placeholder="[Matter] · LegalDesign template">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Foxhill Energy Analytics · diligence report</title>
<script id="legaldesign-state" type="application/json">{"schema":"legaldesign.state.v1","artifactId":"foxhill-diligence-report","savedAt":"2026-09-02T00:00:00.000Z","brief":{"reader":"Harbor Lane Ventures' deal partner and its counsel","action":"Set the closing conditions and the representations for the Series A purchase agreement","purpose":"status","message":"The data room supports the investment with three exceptions: the CEO's founder stock has no 83(b) proof, the Delaware good standing certificate is two years old, and the founders' raises have no board approval on file. Each becomes a closing deliverable or a representation.","spine":"part-of-whole","situation":"laptop","form":"report","sources":[{"id":"dataroom","label":"Foxhill Energy Analytics, Inc. data room, as provided August 21, 2026","status":"supplied"}],"assumptions":["The reader knows the deal terms: $12 million Series A Preferred at a $48 million pre-money valuation."],"gaps":["Four of the six review areas (equity plan, securities, people, tax and filings) are listed with their counts and are not included in this sample."]},"units":{"u-dash-deal":{"kind":"text","role":"matter","selected":"a","variants":{"a":{"axis":"single","why":"","html":"$12 million Series A Preferred at a $48 million pre-money valuation · Harbor Lane Ventures"}},"edits":{"a":null},"placeholder":"[Amount, security, valuation · investor.]"},"u-dash-title":{"kind":"text","role":"title","selected":"a","claim":"Three findings in the data room decide the Series A closing conditions.","relationship":"part-of-whole","variants":{"a":{"axis":"framing","why":"The company name as the title reads as a report cover, which is how the partner files it.","html":"Foxhill Energy Analytics, Inc."},"b":{"axis":"framing","why":"The count as the title tells the partner in two seconds how much of the room needs attention.","html":"Three findings decide the Series A terms."}},"edits":{"a":null,"b":null},"placeholder":"[Company name, or the number of findings that matter.]"},"u-dash-facts":{"kind":"table","selected":"a","variants":{"a":{"axis":"single","why":"","html":"[{\"label\":\"INCORPORATED\",\"value\":\"Delaware, August 20, 2024\"},{\"label\":\"HEADQUARTERS\",\"value\":\"San Mateo, California\"},{\"label\":\"BUSINESS\",\"value\":\"Energy-analytics software\"},{\"label\":\"TEAM\",\"value\":\"19 people: 14 employees and 5 contractors\"},{\"label\":\"PRIOR ROUND\",\"value\":\"Series Seed, April 2025\"},{\"label\":\"DATA ROOM\",\"value\":\"As provided August 21, 2026\"}]"}},"edits":{"a":null},"placeholder":"[Reusable report content.]"},"u-dash-issues":{"kind":"card","selected":"a","claim":"The three findings that need attention before closing are the missing 83(b) proof, the stale good standing certificate, and the unapproved founder raises.","relationship":"priority","variants":{"a":{"axis":"granularity","why":"Three cards give each finding its own line and its own door to the finding page.","html":"[{\"tag\":\"FOUNDER STOCK\",\"title\":\"No proof of the CEO's 83(b) election.\",\"line\":\"Only an unsigned form is in the data room. The 30-day window on Sarah Okafor's 3,900,000 founder shares closed September 25, 2024.\",\"action\":\"Go to the finding\",\"page\":\"p6\"},{\"tag\":\"FORMATION\",\"title\":\"The good standing certificate is 24 months old.\",\"line\":\"The only certificate is dated August 23, 2024. Two Delaware annual reports have come due since, with no filing evidence on file.\",\"action\":\"Go to the finding\",\"page\":\"p3\"},{\"tag\":\"FORMATION\",\"title\":\"The founders' raises have no board approval on file.\",\"line\":\"$185,000 each from May 1, 2025, approved by stockholders in a consent tied to a different subject.\",\"action\":\"Go to the finding\",\"page\":\"p4\"}]"},"b":{"axis":"granularity","why":"One table puts the ask beside the finding, for a partner who reads the closing list before the story.","html":"{\"columns\":[\"Area\",\"Finding\",\"Ask at closing\"],\"rows\":[{\"cells\":[\"Founder stock\",\"No 83(b) proof for the CEO's 3,900,000 shares\",\"A search, then an indemnity if nothing surfaces\"],\"page\":\"p6\"},{\"cells\":[\"Formation\",\"Good standing certificate dated August 23, 2024\",\"A current certificate, dated within ten days of closing\"],\"page\":\"p3\"},{\"cells\":[\"Formation\",\"$185,000 founder raises with no board approval on file\",\"A ratifying board consent and a compensation representation\"],\"page\":\"p4\"}]}"}},"edits":{"a":null,"b":null},"placeholder":"[One card or row per finding that needs attention.]"},"u-dash-areas":{"kind":"table","selected":"a","variants":{"a":{"axis":"single","why":"","html":"[{\"n\":\"01\",\"tag\":\"FORMATION\",\"title\":\"Formation and corporate records\",\"checked\":14,\"attention\":3,\"page\":\"p2\"},{\"n\":\"02\",\"tag\":\"FOUNDER STOCK\",\"title\":\"Founder stock and 83(b) elections\",\"checked\":8,\"attention\":2,\"page\":\"p5\"},{\"n\":\"03\",\"tag\":\"EQUITY PLAN\",\"title\":\"Equity plan and option grants\",\"checked\":14,\"attention\":8,\"page\":null,\"note\":\"Not in this sample\"},{\"n\":\"04\",\"tag\":\"SECURITIES\",\"title\":\"Financings and securities filings\",\"checked\":21,\"attention\":8,\"page\":null,\"note\":\"Not in this sample\"},{\"n\":\"05\",\"tag\":\"PEOPLE\",\"title\":\"Employees, contractors and advisors\",\"checked\":15,\"attention\":8,\"page\":null,\"note\":\"Not in this sample\"},{\"n\":\"06\",\"tag\":\"TAX AND FILINGS\",\"title\":\"Tax, financial and state filings\",\"checked\":8,\"attention\":8,\"page\":null,\"note\":\"Not in this sample\"}]"}},"edits":{"a":null},"placeholder":"[Reusable report content.]"},"u-p2-title":{"kind":"text","role":"title","selected":"a","claim":"Of fourteen formation documents, eleven are in order, two are incomplete, and one is missing.","relationship":"part-of-whole","variants":{"a":{"axis":"framing","why":"The area name as the title keeps the checklist pages parallel.","html":"Formation and corporate records."},"b":{"axis":"framing","why":"Leads with the exceptions, for a reader who skips the rows that are fine.","html":"Two formation records are incomplete and one is missing."}},"edits":{"a":null,"b":null},"placeholder":"[The area, or its exceptions, in one sentence.]"},"u-p2-register":{"kind":"table","selected":"a","variants":{"a":{"axis":"single","why":"","html":"[{\"doc\":\"Certificate of incorporation\",\"line\":\"The current charter is the restated certificate filed April 14, 2025, located and read in this review.\",\"status\":\"RESOLVED\"},{\"doc\":\"Bylaws\",\"line\":\"Adopted and certified August 22, 2024.\",\"status\":\"IN ORDER\"},{\"doc\":\"Action of sole incorporator\",\"line\":\"Appoints Sarah Okafor and James Whitfield as the initial board.\",\"status\":\"IN ORDER\"},{\"doc\":\"Initial board consent\",\"line\":\"Ratifies the bylaws, elects officers, authorizes the founder stock, sets the fiscal year, authorizes bank accounts.\",\"status\":\"IN ORDER\"},{\"doc\":\"EIN confirmation (IRS CP 575)\",\"line\":\"Issued August 27, 2024.\",\"status\":\"IN ORDER\"},{\"doc\":\"Delaware certificate of good standing\",\"line\":\"The only certificate is dated August 23, 2024.\",\"status\":\"INCOMPLETE\",\"page\":\"p3\",\"action\":\"Open the finding\"},{\"doc\":\"Bank accounts\",\"line\":\"Two accounts documented.\",\"status\":\"IN ORDER\"},{\"doc\":\"California foreign qualification\",\"line\":\"Registered September 4, 2024.\",\"status\":\"IN ORDER\"},{\"doc\":\"Stock issuance records\",\"line\":\"Uncertificated shares by board election; issuance notices on file.\",\"status\":\"IN ORDER\"},{\"doc\":\"Minute book index\",\"line\":\"A running index of every board and stockholder action from August 20, 2024 to July 14, 2026.\",\"status\":\"IN ORDER\"},{\"doc\":\"Officer and director list\",\"line\":\"Both founder directors, Marcus Levin in the preferred seat, and the officers elected August 22, 2024.\",\"status\":\"IN ORDER\"},{\"doc\":\"Indemnification agreements\",\"line\":\"None on file for any director or officer. The bylaws' indemnification article is the only cover, and Harbor Lane Ventures will designate a director.\",\"status\":\"MISSING\"},{\"doc\":\"Cap table\",\"line\":\"The August 19, 2026 export, read and reconciled in this review.\",\"status\":\"RESOLVED\"},{\"doc\":\"Board and stockholder consents since inception\",\"line\":\"A substantial set is on file. Marcus Levin's board seat is confirmed; the founders' salary increases have no board approval on file.\",\"status\":\"INCOMPLETE\",\"page\":\"p4\",\"action\":\"Open the finding\"}]"}},"edits":{"a":null},"placeholder":"[What was found, with the date.]"},"u-p3-title":{"kind":"text","role":"title","selected":"a","claim":"The only Delaware good standing certificate on file is dated August 23, 2024, and two annual reports have come due since.","relationship":"time","variants":{"a":{"axis":"framing","why":"Names the defect in the document, which is what the checklist row promised.","html":"The Delaware good standing certificate is nearly two years old."},"b":{"axis":"framing","why":"Leads with the closing consequence, for a reader drafting the conditions.","html":"Foxhill cannot give a good standing representation at closing off a 2024 certificate."}},"edits":{"a":null,"b":null},"placeholder":"[The finding in one sentence.]"},"u-p3-finding":{"kind":"text","role":"finding","selected":"a","claim":"Good standing lapses when the annual report and franchise tax stop being filed; Delaware's March 1 deadline has passed twice since the certificate issued.","relationship":"time","variants":{"a":{"axis":"register","why":"The three-part finding is the grammar every page shares, so a reader can compare findings.","html":"[{\"n\":\"1\",\"head\":\"What we found\",\"html\":\"The only certificate in the file is dated August 23, 2024, three days after incorporation. Nothing more recent appears, and no Delaware annual report or franchise tax confirmation appears in the file either.\"},{\"n\":\"2\",\"head\":\"Why it matters\",\"html\":\"Good standing lapses when the annual report and franchise tax stop being filed, and Delaware's March 1 deadline has now passed twice. A 24-month-old certificate says nothing about the status today, and the company cannot give a good standing representation at closing off this document.\"},{\"n\":\"3\",\"head\":\"Recommendation\",\"html\":\"Make a current Delaware certificate of good standing, dated within ten days of closing, a closing deliverable. If it does not issue cleanly, an annual report is outstanding: require the overdue reports filed and the tax paid before the certificate is reordered.\"}]"},"b":{"axis":"register","why":"The shorter register for a partner who reads the recommendation and skips the rest.","html":"[{\"n\":\"1\",\"head\":\"What we found\",\"html\":\"One certificate, dated August 23, 2024. No annual report and no franchise tax confirmation since.\"},{\"n\":\"2\",\"head\":\"Why it matters\",\"html\":\"Two March 1 deadlines have passed. The certificate proves nothing about today, and no closing representation can rest on it.\"},{\"n\":\"3\",\"head\":\"Recommendation\",\"html\":\"A current certificate, dated within ten days of closing, as a closing deliverable. If it will not issue, the overdue reports and tax come first.\"}]"}},"edits":{"a":null,"b":null},"placeholder":"[Reusable report content.]"},"u-p3-figure":{"kind":"figure","selected":"a","claim":"August 23, 2024 certificate issued; March 1, 2025 and March 1, 2026 annual reports due with no filing evidence; August 28, 2026 the certificate is 24 months old.","relationship":"time","variants":{"a":{"axis":"form","why":"Dates on a scale show two missed deadlines sitting between the certificate and today.","component":"timeline","params":{"marks":[{"when":"Aug 23, 2024","title":"Certificate issued","sub":"The only one on file","detail":"ev-p3-cert"},{"when":"Mar 1, 2025","title":"First annual report due","sub":"No filing evidence"},{"when":"Mar 1, 2026","title":"Second annual report due","sub":"No filing evidence"},{"when":"Aug 28, 2026","title":"Today","sub":"Certificate is 24 months old"}],"nowAt":3}},"b":{"axis":"form","why":"Three dates fit in one sentence; the timeline is for a reader who wants to see the gap.","html":"\u003cp>The certificate is dated August 23, 2024. Annual reports came due on March 1, 2025 and March 1, 2026, and the file holds no evidence of either. Today the certificate is 24 months old.\u003c/p>"}},"edits":{"a":null,"b":null},"placeholder":"[Each date with what happened or came due on it.]"},"u-p3-expected":{"kind":"text","role":"expected","selected":"a","variants":{"a":{"axis":"single","why":"","html":"Expected: a current certificate of good standing from Delaware, dated within ten days of closing."}},"edits":{"a":null},"placeholder":"[Expected record: the document, approval, or filing that would resolve the point.]"},"u-p4-title":{"kind":"text","role":"title","selected":"a","claim":"A stockholder consent approves $185,000 salaries for both founders from May 1, 2025; no board consent approving the raise is on file, and both founders are directors.","relationship":"interaction","variants":{"a":{"axis":"framing","why":"Names the gap in the record, which is what the checklist row promised.","html":"The founders' salary increases have stockholder approval but no board approval on file."},"b":{"axis":"framing","why":"Leads with the self-interest point, which is why the gap matters.","html":"Two directors set their own pay, and the board never approved it on the record."}},"edits":{"a":null,"b":null},"placeholder":"[The finding in one sentence.]"},"u-p4-finding":{"kind":"text","role":"finding","selected":"a","claim":"Both founders sit on the board and both benefit, so the raise is a self-interested decision that needs a clean disinterested approval; the stockholder consent on file ties its effect to a different subject and lacks the founders' signature pages.","relationship":"interaction","variants":{"a":{"axis":"register","why":"The three-part finding is the grammar every page shares, so a reader can compare findings.","html":"[{\"n\":\"1\",\"head\":\"What we found\",\"html\":\"A stockholder written consent signed by the two seed funds approves $185,000 salaries for both founders effective May 1, 2025. The consent takes effect only after board resolutions approving the restated charter, a different subject, and no board consent approving the salary increase itself appears anywhere in the file. The founders' own signature pages are not with the consent either.\"},{\"n\":\"2\",\"head\":\"Why it matters\",\"html\":\"Both founders sit on the board and both benefit from the raise, so this is pay the directors set for themselves. Delaware treats that as a self-interested decision: it holds up when disinterested stockholders approve it on a clean record, and this record ties the approval to the wrong subject. The exposure sits with the company Harbor Lane Ventures is buying into, and it runs from May 1, 2025 forward.\"},{\"n\":\"3\",\"head\":\"Recommendation\",\"html\":\"Require the board consent ratifying both salary increases from May 1, 2025, and the completed stockholder consent with the founders' signature pages, as pre-closing deliverables. Add a compensation representation to the Series A purchase agreement covering approval of all officer pay, and put officer compensation on the list of actions needing the preferred director's vote.\"}]"},"b":{"axis":"register","why":"The shorter register for a partner who reads the recommendation and skips the rest.","html":"[{\"n\":\"1\",\"head\":\"What we found\",\"html\":\"$185,000 each from May 1, 2025, approved by the two seed funds in a consent that takes effect on a different subject. No board consent. No founder signature pages.\"},{\"n\":\"2\",\"head\":\"Why it matters\",\"html\":\"Directors setting their own pay need a clean disinterested approval. This record does not give one, and the exposure runs from May 2025.\"},{\"n\":\"3\",\"head\":\"Recommendation\",\"html\":\"A ratifying board consent and the completed stockholder consent before closing. A compensation representation in the purchase agreement. Officer pay on the preferred director's vote list.\"}]"}},"edits":{"a":null,"b":null},"placeholder":"[Reusable report content.]"},"u-p4-figure":{"kind":"figure","selected":"a","claim":"Four approvals bear on the raise: the stockholder consent (signed, tied to another subject), the board consent (none), the founders' signature pages (missing), and the disinterested director (Marcus Levin, no vote on file).","relationship":"interaction","variants":{"a":{"axis":"form","why":"One decision at the centre and four approvals pointing at it, three of them absent.","component":"hub","params":{"centre":"$185,000 each from May 1, 2025","toward":"centre","spokes":[{"title":"Stockholder consent","edge":"signed by the two seed funds","detail":"ev-p4-consent"},{"title":"Board consent","edge":"none on file"},{"title":"Founders' signature pages","edge":"missing"},{"title":"Disinterested director","edge":"no vote on file"}]}},"b":{"axis":"form","why":"Expected against found on three rows, for a reader drafting the cure.","component":"compareTwo","params":{"a":"Expected","b":"Found","highlight":"none","rows":[{"label":"Board approval","a":"Consent by the directors who do not benefit","b":"None on file","winner":"none"},{"label":"Stockholder approval","a":"Consent on a clean record","b":"Consent tied to the charter resolutions","winner":"none"},{"label":"Signature pages","a":"All holders","b":"Founders' pages missing","winner":"none"}]}}},"edits":{"a":null,"b":null},"placeholder":"[The decision and the approvals that bear on it, each marked present or absent.]"},"u-p4-expected":{"kind":"text","role":"expected","selected":"a","variants":{"a":{"axis":"single","why":"","html":"Expected: for a transaction with directors on both sides, approval by the directors who do not benefit from it, or approval by the stockholders, normally with the board acting first."}},"edits":{"a":null},"placeholder":"[Expected record: the document, approval, or filing that would resolve the point.]"},"u-p5-title":{"kind":"text","role":"title","selected":"a","claim":"Of eight founder-stock documents, six are in order, one is missing, and one is incomplete.","relationship":"part-of-whole","variants":{"a":{"axis":"framing","why":"The area name as the title keeps the checklist pages parallel.","html":"Founder stock and 83(b) elections."},"b":{"axis":"framing","why":"Leads with the exceptions, for a reader who skips the rows that are fine.","html":"One founder's 83(b) proof is missing and the vesting amendments were never signed."}},"edits":{"a":null,"b":null},"placeholder":"[The area, or its exceptions, in one sentence.]"},"u-p5-register":{"kind":"table","selected":"a","variants":{"a":{"axis":"single","why":"","html":"[{\"doc\":\"Okafor restricted stock purchase agreement\",\"line\":\"Signed both sides, August 26, 2024. Sells 3,900,000 shares and assigns her IP to the company now, not as a future promise.\",\"status\":\"IN ORDER\"},{\"doc\":\"Whitfield restricted stock purchase agreement\",\"line\":\"Signed both sides, August 26, 2024. Sells 3,820,000 shares with the same present-tense IP assignment.\",\"status\":\"IN ORDER\"},{\"doc\":\"Notices of stock issuance\",\"line\":\"On file for both founders.\",\"status\":\"IN ORDER\"},{\"doc\":\"Founder IP assignment\",\"line\":\"Each purchase agreement assigns the founder's pre-incorporation work to the company in the present tense.\",\"status\":\"IN ORDER\"},{\"doc\":\"Founder vesting and repurchase schedule\",\"line\":\"One schedule for both founders: 1/72 monthly from August 26, 2024, no cliff.\",\"status\":\"IN ORDER\"},{\"doc\":\"Okafor 83(b) election\",\"line\":\"Only the unsigned form attached to her purchase agreement is on file. The filing window closed September 25, 2024.\",\"status\":\"MISSING\",\"page\":\"p6\",\"action\":\"Open the finding\"},{\"doc\":\"Whitfield 83(b) election\",\"line\":\"Signed August 26, mailed August 28, IRS-stamped received September 9, 2024.\",\"status\":\"RESOLVED\"},{\"doc\":\"Founder vesting amendments\",\"line\":\"The Series Seed purchase agreement required six-year vesting amendments; no signed amendment is on file for either founder.\",\"status\":\"INCOMPLETE\",\"note\":\"Finding not in this sample\"}]"}},"edits":{"a":null},"placeholder":"[What was found, with the date.]"},"u-p6-title":{"kind":"text","role":"title","selected":"a","claim":"No signed, filed, or acknowledged 83(b) election exists in the data room for Sarah Okafor's 3,900,000 founder shares, and the 30-day window closed September 25, 2024.","relationship":"sequence","variants":{"a":{"axis":"framing","why":"Names the missing document, which is what the checklist row promised.","html":"No 83(b) election proof is on file for Sarah Okafor's founder stock."},"b":{"axis":"framing","why":"Leads with the tax consequence, which is what the partner will price.","html":"Until an 83(b) election surfaces, every vesting date on the CEO's founder stock is a taxable event."}},"edits":{"a":null,"b":null},"placeholder":"[The finding in one sentence.]"},"u-p6-finding":{"kind":"text","role":"finding","selected":"a","claim":"Without a timely election, each monthly vesting date on the 3,900,000 shares through August 2030 is compensation income, with company withholding exposure and a personal tax bill for the CEO.","relationship":"sequence","variants":{"a":{"axis":"register","why":"The three-part finding is the grammar every page shares, so a reader can compare findings.","html":"[{\"n\":\"1\",\"head\":\"What we found\",\"html\":\"Okafor bought 3,900,000 shares of restricted stock on August 26, 2024, for $390.00 in total. Her purchase agreement, p. 9, recites that she has decided to make an 83(b) election and is submitting an executed form. The only election in the data room is the form at Exhibit B, p. 16: filled in, unsigned, and undated. No IRS-stamped copy, no certified-mail receipt, and no tracking appears anywhere in the file. The window closed Wednesday, September 25, 2024. James Whitfield's package is the contrast: signed, mailed, and IRS-stamped inside the window.\"},{\"n\":\"2\",\"head\":\"Why it matters\",\"html\":\"Filed on time, the election would have taxed Okafor once, on zero: she paid exactly what the shares were worth. Without it, each vesting date is new pay, monthly, on 3,900,000 shares that finish vesting in August 2030. The company has to value its stock and withhold through payroll for every month already passed, and its own deduction depends on the W-2 reporting. The unpaid withholding, penalties, and interest sit inside the company Harbor Lane Ventures is investing in, and the CEO carries a personal tax bill that rises with the share price.\"},{\"n\":\"3\",\"head\":\"Recommendation\",\"html\":\"Require the search before signing: Okafor's own records and email, formation counsel's file, a certified-mail receipt, an IRS-stamped copy. If the proof surfaces, it goes into the data room and this point closes. If it does not, seek a specific indemnity for the founder-level and company-level payroll tax exposure, escrowed, and make the payroll correction plan and the valuation record for each vesting date closing deliverables.\"}]"},"b":{"axis":"register","why":"The shorter register for a partner who reads the recommendation and skips the rest.","html":"[{\"n\":\"1\",\"head\":\"What we found\",\"html\":\"3,900,000 shares bought August 26, 2024 for $390.00. The agreement recites an executed election; the file holds an unsigned, undated form and nothing from the IRS. The window closed September 25, 2024. Whitfield's package is complete.\"},{\"n\":\"2\",\"head\":\"Why it matters\",\"html\":\"Without the election, every monthly vesting date through August 2030 is pay. The company owes withholding for each month already passed, and the CEO owes tax that grows with the share price.\"},{\"n\":\"3\",\"head\":\"Recommendation\",\"html\":\"Search first: her records, formation counsel's file, a mail receipt, a stamped copy. If nothing surfaces, an escrowed specific indemnity, a payroll correction plan, and a valuation record for every vesting date.\"}]"}},"edits":{"a":null,"b":null},"placeholder":"[Reusable report content.]"},"u-p6-figure":{"kind":"figure","selected":"a","claim":"August 26, 2024 purchase; September 25, 2024 filing window closed; monthly vesting from August 26, 2024; August 2030 vesting ends.","relationship":"sequence","variants":{"a":{"axis":"form","why":"Left-to-right stages with named gaps show where the election had to happen and what follows without it.","component":"flow","params":{"stages":[{"title":"Purchase","sub":"August 26, 2024 · 3,900,000 shares for $390","detail":"ev-p6-recital"},{"title":"Filing window","sub":"Closed September 25, 2024 · nothing filed","detail":"ev-p6-form"},{"title":"Each vesting date","sub":"Monthly from August 26, 2024 · taxed as pay"},{"title":"Vesting ends","sub":"August 2030"}],"edges":["30 days","no election on file","72 months"]}},"b":{"axis":"form","why":"Four numbers, for a reader who prices the exposure before reading the story.","html":"[{\"value\":\"3,900,000\",\"label\":\"shares bought August 26, 2024\"},{\"value\":\"$390\",\"label\":\"paid, equal to their value\"},{\"value\":\"Sep 25, 2024\",\"label\":\"the day the 30-day window closed\"},{\"value\":\"72\",\"label\":\"monthly vesting dates through August 2030, each taxable without the election\"}]"}},"edits":{"a":null,"b":null},"placeholder":"[The stages in order, with the one that failed marked.]"},"u-p6-expected":{"kind":"text","role":"expected","selected":"a","variants":{"a":{"axis":"single","why":"","html":"Expected: a signed election plus proof it reached the IRS inside the 30-day window, an IRS-stamped copy or a certified-mail receipt. A recollection of mailing is not proof."}},"edits":{"a":null},"placeholder":"[Expected record: the document, approval, or filing that would resolve the point.]"}},"evidence":{"ev-p3-cert":{"cite":"Formation · certificate of good standing","locator":"Formation folder, item 6","excerpt":"Certificate of good standing · Foxhill Energy Analytics, Inc. · August 23, 2024","link":null,"image":{"status":"not-included"},"status":"supplied"},"ev-p4-consent":{"cite":"Formation · stockholder consent","locator":"Consents folder, May 2025, p. 2","excerpt":"This consent shall take effect upon the adoption by the Board of Directors of resolutions approving the Restated Certificate.","link":null,"image":{"status":"not-included"},"status":"supplied"},"ev-p6-recital":{"cite":"Founder stock · purchase agreement, p. 9","locator":"Purchase agreement, §8, p. 9","excerpt":"Purchaser has decided to make an 83(b) Election and is submitting to the Company an executed form entitled \"Election Under Section 83(b) of the Internal Revenue Code of 1986,\" attached hereto as Exhibit B.","link":null,"image":{"status":"not-included"},"status":"supplied"},"ev-p6-form":{"cite":"Founder stock · Exhibit B, p. 16","locator":"Purchase agreement, Exhibit B, p. 16","excerpt":"Election Under Section 83(b) of the Internal Revenue Code of 1986 · Taxpayer signature: ______ · Date: ______","link":null,"image":{"status":"not-included"},"status":"supplied"}},"review":{},"history":[]}</script>
<!-- legaldesign:runtime -->
<style id="ld-tokens">
:root,
[data-theme="light"] {
color-scheme: light;
--bg: #fbfbfb;
--card: #ffffff;
--card-strong: #ffffff;
--ink: #111111;
--muted: #565656;
--faint: #6f6f6f;
--line: rgba(17, 17, 17, 0.13);
--line-strong: rgba(17, 17, 17, 0.3);
--red: #c92014;
--red-strong: #a01a10;
--red-wash: rgba(201, 32, 20, 0.055);
--insert: #2456d6;
--interaction: #767676;
--detail-feedback: var(--interaction);
--tint: #f3f3f3;
--scrim: rgba(17, 17, 17, 0.52);
--shadow-soft:
0 1px 3px rgba(17, 17, 17, 0.08), 0 6px 18px rgba(17, 17, 17, 0.05);
--shadow: 0 22px 60px rgba(17, 17, 17, 0.18);
--sans: "Helvetica Neue", Helvetica, Arial, sans-serif;
--serif: Charter, "Iowan Old Style", Georgia, serif;
--label: var(--sans);
/* One authored reading scale. Window resizing changes layout, not type. */
--ld-type-title: 28px;
--ld-type-section: 20px;
--ld-type-body: 16px;
--ld-type-caption: 12px;
--ld-space: 8px;
--ld-content-gap: 16px;
--ld-content-padding: 24px;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #000000;
--card: #111111;
--card-strong: #171717;
--ink: #f2f2f0;
--muted: #b4b4b0;
--faint: #8d8d89;
--line: rgba(242, 242, 240, 0.14);
--line-strong: rgba(242, 242, 240, 0.32);
--red: #ff5a4e;
--red-strong: #ff7d73;
--red-wash: rgba(255, 90, 78, 0.12);
--insert: #6fa0ff;
--interaction: #a0a09c;
--tint: #161616;
--scrim: rgba(0, 0, 0, 0.68);
--shadow-soft: 0 1px 3px rgba(0, 0, 0, 0.5), 0 6px 18px rgba(0, 0, 0, 0.35);
--shadow: 0 22px 60px rgba(0, 0, 0, 0.62);
}
* {
box-sizing: border-box;
}
html,
body {
background: var(--bg);
}
html {
min-height: 100%;
color: var(--ink);
font-family: var(--sans);
}
body {
min-height: 100%;
margin: 0;
color: var(--ink);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
button,
input,
textarea,
select {
color: inherit;
font: inherit;
}
button,
select {
cursor: pointer;
}
button:disabled,
select:disabled {
cursor: default;
opacity: 0.42;
}
a {
color: var(--red);
text-decoration: underline;
text-underline-offset: 0.12em;
}
a:hover {
text-decoration-thickness: 2px;
}
html :focus-visible,
html #ld-page-approach button:focus-visible {
outline: 2px solid var(--interaction);
outline-offset: 2px;
}
[data-detail]:focus-visible,
[data-evidence]:focus-visible {
outline: 2px solid var(--detail-feedback);
outline-offset: 2px;
}
svg [data-detail]:focus-visible {
outline: none;
}
/* Text-led chart rows and comparison headers have no leading card to stroke.
Their keyboard focus belongs to the single group, never to each child. */
html:not([data-input-modality="pointer"])
svg
[data-detail]:focus-visible:not(
:has(> :is(rect, path, circle, ellipse, polygon, polyline):first-child)
) {
outline: 2px solid var(--detail-feedback);
outline-offset: 3px;
}
html:not([data-input-modality="pointer"])
svg
[data-detail]:focus-visible
> :is(rect, path, circle, ellipse, polygon, polyline):first-child {
stroke: var(--detail-feedback);
stroke-width: 2px;
}
.ld-control:focus:not(:focus-visible),
.ld-tool:focus:not(:focus-visible),
.ld-ab button:focus:not(:focus-visible),
.ld-why-toggle:focus:not(:focus-visible),
.ld-popup-close:focus:not(:focus-visible) {
outline: none;
}
[hidden] {
display: none !important;
}
.doc-text {
font-family: var(--serif);
}
.ld-bar {
position: sticky;
top: 0;
z-index: 40;
display: flex;
min-height: 44px;
align-items: center;
gap: 10px;
padding: 5px 10px;
border-bottom: 1px solid var(--line);
background: var(--card);
}
.ld-matter {
min-width: 0;
flex: 1 1 auto;
margin: 0;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ld-controls,
.ld-editor-tools {
display: flex;
align-items: center;
gap: 2px;
}
.ld-controls {
max-width: calc(100vw - 220px);
overflow-x: auto;
border: 1px solid var(--line-strong);
background: var(--card);
padding: 2px;
scrollbar-width: none;
}
.ld-controls::-webkit-scrollbar {
display: none;
}
.ld-control,
.ld-ab button,
.ld-why-toggle,
.ld-popup-close {
min-height: 27px;
border: 0;
border-radius: 2px;
background: transparent;
padding: 5px 9px;
font-size: 11.5px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.ld-tool,
.ld-palette {
min-width: 27px;
min-height: 27px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--card);
padding: 0 8px;
color: var(--ink);
font-size: 10px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.ld-control:hover,
.ld-tool:hover,
.ld-ab button:hover,
.ld-why-toggle:hover {
background: var(--tint);
color: var(--ink);
}
.ld-control[aria-pressed="true"],
.ld-ab button[aria-pressed="true"] {
background: var(--tint);
color: var(--ink);
}
#theme-toggle {
display: inline-grid;
width: 30px;
min-width: 30px;
place-items: center;
padding: 0;
}
.ld-theme-icon {
width: 15px;
height: 15px;
}
.ld-theme-sun {
display: none;
}
html[data-theme="dark"] .ld-theme-moon {
display: none;
}
html[data-theme="dark"] .ld-theme-sun {
display: block;
}
.ld-tool[aria-pressed="true"] {
border-color: var(--interaction);
color: var(--interaction);
}
.ld-editor-tools {
display: none;
}
html[data-mode="edit"] .ld-editor-tools {
display: flex;
}
.ld-overflow-wrap {
position: relative;
display: contents;
}
#editor-overflow-toggle {
display: none;
}
.ld-overflow-menu {
display: contents;
}
.ld-palette {
max-width: 88px;
appearance: auto;
}
.ld-form-nav {
position: sticky;
top: 56px;
z-index: 30;
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 24px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--card);
padding: 8px;
}
.ld-form-nav [data-walkthrough-status] {
min-width: 64px;
flex: 1 1 auto;
color: var(--muted);
font-size: 12px;
text-align: center;
}
.ld-form-nav [data-report-section] {
overflow: hidden;
text-overflow: ellipsis;
}
.ld-form-nav [aria-current="true"] {
border-color: var(--interaction);
}
.ld-unit {
position: relative;
}
.ld-unit-tools {
position: absolute;
top: 0;
right: 0;
z-index: 70;
display: none;
width: max-content;
align-items: center;
gap: 3px;
margin: 0;
transform: translateY(calc(-100% - 4px));
box-shadow: none;
}
html[data-mode="edit"]
.ld-unit:is(:hover, :focus-within, .ld-selected)
> .ld-unit-tools,
html[data-mode="edit"] .ld-unit:has(.ld-selected) > .ld-unit-tools {
display: flex;
}
.ld-ab {
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--card);
padding: 2px;
}
.ld-ab::before {
content: "‹";
padding: 0 3px;
color: var(--faint);
}
.ld-ab::after {
content: "›";
padding: 0 3px;
color: var(--faint);
}
.ld-ab button {
min-width: 27px;
min-height: 25px;
border: 0;
padding: 3px 6px;
font-size: 10px;
}
.ld-ab button + button::before {
content: "·";
margin-right: 8px;
color: var(--faint);
}
.ld-unit-tools .ld-why-toggle {
min-height: 27px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--card);
padding: 3px 8px;
font-size: 10px;
}
.ld-why {
margin: 8px 0 0;
border-top: 1px solid var(--line);
padding: 9px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.ld-review,
.ld-sheet-open {
display: none !important;
}
[data-editable][contenteditable="true"] {
cursor: text;
}
#ld-editor-layer {
position: fixed;
inset: 0;
z-index: 140;
pointer-events: none;
}
#ld-selection-box {
position: fixed;
display: none;
border: 1px solid var(--interaction);
border-radius: 2px;
}
.ld-handle {
position: absolute;
width: 10px;
height: 10px;
border: 1px solid var(--interaction);
border-radius: 2px;
background: var(--card);
pointer-events: auto;
}
/* Text labels can be shorter than a handle. Keep handles outside their ink
bounds so selecting a label never blocks the second click used to edit it. */
#ld-selection-box[data-diagram-label-selection]
.ld-handle:is([data-handle="nw"], [data-handle="n"], [data-handle="ne"]) {
top: -12px;
}
#ld-selection-box[data-diagram-label-selection]
.ld-handle:is([data-handle="sw"], [data-handle="s"], [data-handle="se"]) {
bottom: -12px;
}
#ld-selection-box[data-diagram-label-selection]
.ld-handle:is([data-handle="nw"], [data-handle="w"], [data-handle="sw"]) {
left: -12px;
}
#ld-selection-box[data-diagram-label-selection]
.ld-handle:is([data-handle="ne"], [data-handle="e"], [data-handle="se"]) {
right: -12px;
}
.ld-handle[data-handle="nw"] {
left: -5px;
top: -5px;
cursor: nwse-resize;
}
.ld-handle[data-handle="n"] {
left: calc(50% - 5px);
top: -5px;
cursor: ns-resize;
}
.ld-handle[data-handle="ne"] {
right: -5px;
top: -5px;
cursor: nesw-resize;
}
.ld-handle[data-handle="e"] {
right: -5px;
top: calc(50% - 5px);
cursor: ew-resize;
}
.ld-handle[data-handle="se"] {
right: -5px;
bottom: -5px;
cursor: nwse-resize;
}
.ld-handle[data-handle="s"] {
left: calc(50% - 5px);
bottom: -5px;
cursor: ns-resize;
}
.ld-handle[data-handle="sw"] {
left: -5px;
bottom: -5px;
cursor: nesw-resize;
}
.ld-handle[data-handle="w"] {
left: -5px;
top: calc(50% - 5px);
cursor: ew-resize;
}
#ld-marquee {
position: fixed;
display: none;
border: 1px dashed var(--interaction);
border-radius: 2px;
background: color-mix(in srgb, var(--interaction) 7%, transparent);
}
[data-fill-token="card"] {
--ld-object-fill: var(--card);
background: var(--card) !important;
}
[data-fill-token="card-strong"] {
--ld-object-fill: var(--card-strong);
background: var(--card-strong) !important;
}
[data-fill-token="tint"] {
--ld-object-fill: var(--tint);
background: var(--tint) !important;
}
[data-fill-token="bg"] {
--ld-object-fill: var(--bg);
background: var(--bg) !important;
}
[data-fill-token="none"] {
--ld-object-fill: none;
background: transparent !important;
}
[data-text-token="ink"] {
--ld-object-text: var(--ink);
color: var(--ink) !important;
}
[data-text-token="muted"] {
--ld-object-text: var(--muted);
color: var(--muted) !important;
}
[data-text-token="faint"] {
--ld-object-text: var(--faint);
color: var(--faint) !important;
}
[data-text-token="red"] {
--ld-object-text: var(--red);
color: var(--red) !important;
}
[data-text-token="red-strong"] {
--ld-object-text: var(--red-strong);
color: var(--red-strong) !important;
}
/* Object paint follows the selected object in HTML and SVG alike. */
svg [data-fill-token] > :is(rect, circle, ellipse, polygon) {
fill: var(--ld-object-fill) !important;
}
[data-text-token] :is(h1, h2, h3, h4, p, span, li),
[data-text-token]:is(text, tspan),
[data-text-token] :is(text, tspan) {
color: var(--ld-object-text) !important;
fill: var(--ld-object-text) !important;
}
html[data-mode="edit"] .ld-bar {
z-index: 150;
}
html[data-mode="edit"] .ld-scrim {
padding-top: 70px;
}
html[data-mode="edit"] .ld-scrim .pop {
max-height: calc(100dvh - 92px);
}
html[data-mode="edit"] [data-diagram-object] {
cursor: move;
}
.ld-added-text {
min-height: 28px;
padding: 6px;
font-size: 18px;
line-height: 1.4;
color: var(--ink);
overflow-wrap: anywhere;
}
#ld-color-palette {
position: fixed;
z-index: 200;
width: 232px;
padding: 16px;
background: var(--card);
color: var(--ink);
border: 1px solid var(--line-strong);
border-radius: 10px;
box-shadow: var(--shadow);
}
#ld-color-palette strong {
display: block;
font-size: 13px;
margin-bottom: 12px;
}
#ld-color-palette small {
display: block;
margin-top: 12px;
font-size: 11px;
color: var(--muted);
}
.ld-swatch-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.ld-color-swatch {
width: 32px;
height: 32px;
border: 1px solid var(--line-strong);
border-radius: 4px;
padding: 0;
}
.ld-color-swatch:hover,
.ld-color-swatch[aria-pressed="true"] {
outline: 2px solid var(--interaction);
outline-offset: 2px;
}
.ld-popup-authoring {
display: none;
}
html[data-mode="edit"] .ld-popup-authoring {
display: flex;
margin-top: 20px;
gap: 8px;
}
/* Titles fill their own frame. The close button owns the top-right corner,
not a permanent empty strip beside the whole popup's content. */
html .pop h2,
html .pop .ld-popup-lede {
max-width: none;
width: 100%;
margin-right: 0;
text-wrap: wrap;
}
html .pop h2 {
padding-right: 32px;
}
html .ld-composed-unit[data-role="summary"] {
max-width: none;
}
html .ld-composed-unit[data-role="title"] h1 {
width: 100%;
text-wrap: wrap;
}
html .sb-title {
padding-right: 0;
}
html .sb-title h1,
html .sb-title .sb-sub,
html .ld-title h1,
html .subtitle {
width: 100%;
max-width: none;
text-wrap: wrap;
}
html .ld-figure-title {
max-width: none;
margin-right: 0;
}
html[data-theme="dark"] .ld-composed-unit[data-kind="figure"] {
background: var(--bg);
}
/* Dark document cards sit slightly above the pure-black page. Structure uses a neutral outline;
semantic emphasis changes the outline, never the whole fill. */
html[data-theme="dark"]
:is(.ld-card, .sb-card, .ld-composed-unit[data-kind="card"]):not(
[data-fill-token]
) {
--ink: #ffffff;
--muted: #d0d0d0;
--faint: #b4b4b4;
color: var(--ink);
background: var(--card);
border: 1.5px solid rgba(255, 255, 255, 0.58);
box-shadow: none;
}
html[data-theme="dark"]
:is(.ld-card, .sb-card, .ld-composed-unit[data-kind="card"]):not(
[data-fill-token]
):is(.accent, .open, [data-accent-role]) {
background: var(--card);
border-color: var(--red);
}
html[data-theme="dark"]
:is(.ld-card, .sb-card, .ld-composed-unit[data-kind="card"]):not(
[data-fill-token]
)
:is(a, .cite, .tag, .action) {
color: var(--red);
}
html[data-theme="dark"]
:is(.ld-card, .sb-card, .ld-composed-unit[data-kind="card"]):not(
[data-fill-token]
):is(.accent, .open, [data-accent-role])
:is(a, .cite, .tag, .action) {
color: var(--red);
}
html[data-theme="dark"] :is([data-kind="text"], [data-editor-object="text"]) {
border: 0;
box-shadow: none;
}
html[data-theme="dark"]
.ld-composed-unit[data-kind="card"]:not([data-detail]):not(
[data-accent-role]
):not(.accent):not(.open) {
border: 0;
box-shadow: none;
}
.ld-scrim {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
padding: 22px;
background: var(--scrim);
}
.pop {
position: relative;
width: min(720px, 100%);
max-height: calc(100dvh - 44px);
overflow: auto;
border: 1px solid var(--line-strong);
border-radius: 5px;
background: var(--card);
box-shadow: var(--shadow);
padding: 25px;
}
.pop:has(.exhibit-shot) {
width: min(880px, 100%);
}
.pop .ld-popup-section p {
max-width: 72ch;
}
html[data-theme="dark"] .pop :is(.ld-popup-lede, .ld-popup-section p, .desc) {
color: var(--ink);
}
.pop[data-scroll-more] {
box-shadow:
inset 0 -16px 12px -14px var(--ink),
var(--shadow);
}
.ld-popup-close {
position: absolute;
top: 10px;
right: 10px;
width: 38px;
padding: 0;
font-size: 22px;
}
.pop .tag {
margin: 0 48px 7px 0;
color: var(--red);
font-size: 11px;
letter-spacing: 0.04em;
}
.pop h2 {
margin: 0 48px 8px 0;
font-size: 22px;
line-height: 1.15;
}
.pop .desc {
margin: 0 0 16px;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.doc {
overflow: hidden;
border: 1px solid var(--line-strong);
border-radius: 4px;
background: var(--card-strong);
}
.doc-text {
padding: 16px 18px;
font-size: 14px;
line-height: 1.6;
}
.doc blockquote.doc-text {
margin: 0;
font-family: Charter, "Bitstream Charter", "Sitka Text", Cambria, serif;
white-space: pre-wrap;
}
.ld-source-excerpt > summary {
padding: 12px 0;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.15em;
}
.doc details p {
overflow-wrap: anywhere;
}
.doc-cl,
.doc-foot {
margin: 0;
border-top: 1px solid var(--line);
padding: 9px 18px;
color: var(--muted);
font-family: var(--sans);
font-size: 11px;
line-height: 1.4;
}
.doc-foot {
background: var(--tint);
}
.exhibit-shot {
display: block;
width: auto;
max-width: 100%;
height: auto;
border-top: 1px solid var(--line);
}
.pop .out {
margin: 15px 0 0;
font-size: 13px;
}
.ld-detail {
cursor: pointer;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* Popup affordances follow the actual target, including user-added text. A
card's child labels do not become links merely because the card opens detail. */
html :is([data-detail], [data-evidence]) {
cursor: pointer;
}
html
:is(p, span, h1, h2, h3, h4, h5, h6, em, strong, a, text, tspan):is(
[data-detail],
[data-evidence]
):not(.ld-card, .sb-card) {
text-decoration-line: underline;
text-underline-offset: 0.15em;
text-decoration-thickness: 1px;
}
html[data-mode="edit"] [data-editable][contenteditable="true"] {
cursor: text;
}
html
.ld-composed-unit[data-kind="text"]:is([data-detail], [data-evidence])
> .ld-variant
> [data-variant-body],
html .ld-reader-popup-text {
text-decoration-line: underline;
text-underline-offset: 0.15em;
text-decoration-thickness: 1px;
}
.ld-inline-detail {
display: inline;
border: 0;
border-radius: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: inherit;
white-space: normal;
text-decoration: underline;
text-underline-offset: 0.15em;
text-decoration-thickness: 1px;
}
.ld-inline-detail:hover {
color: var(--detail-feedback);
}
.ld-detail-linkline {
margin-top: 6px;
font-size: 0.85em;
}
html
:is(.ld-card, .sb-card, .ld-composed-unit[data-kind="card"]):is(
[data-detail],
[data-evidence]
):is(:hover, :focus-visible) {
border-color: var(--detail-feedback);
}
.ld-detail text {
-webkit-user-select: none;
user-select: none;
}
html:not([data-mode="edit"]) .ld-detail text {
pointer-events: none;
}
.ld-detail:hover > :is(.box, .band, .leaf, rect):first-child,
html:not([data-input-modality="pointer"])
.ld-detail:focus-visible
> :is(.box, .band, .leaf, rect):first-child {
stroke: var(--detail-feedback) !important;
stroke-width: 1.5 !important;
}
.ld-restore,
.ld-save-banner {
position: sticky;
top: 48px;
z-index: 35;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-bottom: 1px solid var(--line-strong);
background: var(--card);
padding: 8px 12px;
color: var(--ink);
font-size: 12px;
}
.ld-restore button {
min-height: 36px;
border: 1px solid var(--line-strong);
border-radius: 4px;
background: var(--card);
padding: 6px 10px;
}
.ld-save-banner {
color: var(--muted);
flex-wrap: wrap;
}
html[data-fixed-pages] .ld-save-banner {
position: fixed;
top: var(--ld-result-top, 64px);
right: 12px;
left: 12px;
}
.ld-save-banner .ld-export-preview {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 8px;
color: var(--ink);
text-decoration: underline;
flex-shrink: 0;
}
@media (max-width: 820px) {
/* The editor bar can wrap to several rows on touch-sized screens. Context
controls reserve their own space inside the selected unit, so even the
first title's controls remain reachable below that bar at scrollY=0. */
.ld-unit-tools {
position: relative;
top: auto;
right: auto;
max-width: 100%;
margin: 0 0 8px auto;
transform: none;
}
.ld-matter {
display: none;
}
.ld-bar {
overflow: visible;
}
.ld-controls {
flex-wrap: wrap;
margin-left: auto;
max-width: 100%;
overflow: visible;
}
html[data-mode="edit"] .ld-controls {
max-width: 100%;
}
.ld-overflow-menu {
display: none;
position: absolute;
top: 44px;
right: 8px;
z-index: 60;
flex-wrap: wrap;
width: min(330px, calc(100vw - 16px));
border: 1px solid var(--line-strong);
border-radius: 4px;
background: var(--card);
padding: 7px;
}
html[data-mode="edit"] #editor-overflow-toggle {
display: inline-flex;
}
html[data-editor-menu="open"] .ld-overflow-menu {
display: flex;
}
}
@media (max-width: 480px) {
#theme-toggle {
width: 44px;
min-width: 44px;
}
.ld-bar {
flex-wrap: wrap;
padding-right: max(6px, env(safe-area-inset-right));
padding-left: max(6px, env(safe-area-inset-left));
}
html[data-mode="edit"] .ld-bar {
flex-wrap: wrap;
}
.ld-editor-tools {
flex: 0 0 100%;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
.ld-editor-tools .ld-tool,
.ld-editor-tools select {
min-width: 44px;
min-height: 44px;
}
.ld-unit-tools .ld-ab button,
.ld-unit-tools .ld-why-toggle {
min-width: 44px;
min-height: 44px;
}
.ld-controls {
gap: 3px;
}
.ld-control {
min-width: 44px;
min-height: 44px;
padding: 6px 7px;
font-size: 11px;
}
.ld-form-nav {
top: 54px;
overflow-x: auto;
}
.ld-scrim {
align-items: flex-end;
padding: 0;
}
.pop {
width: 100%;
max-height: 88dvh;
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 5px 5px 0 0;
padding: 20px 14px max(16px, env(safe-area-inset-bottom));
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition: none !important;
animation: none !important;
}
}
/* 1200×900 is an authoring reference. The visible page uses the available
window dimensions independently, with no fixed-ratio letterboxing.
Popup detail retains ordinary viewport-sized text and its own scrolling. */
html[data-fixed-pages],
html[data-fixed-pages] body {
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
html[data-fixed-pages] .ld-fixed-page {
position: fixed !important;
inset: auto !important;
left: var(--ld-page-left, 0px) !important;
top: var(--ld-page-top, 100px) !important;
width: var(--ld-page-width, 1200px) !important;
height: var(--ld-page-height, 900px) !important;
min-width: 0 !important;
max-width: none !important;
min-height: 0 !important;
max-height: none !important;
margin: 0 !important;
padding: 44px 48px !important;
box-sizing: border-box !important;
transform: scale(var(--ld-page-scale, 1));
transform-origin: top left;
overflow: visible !important;
background: var(--bg);
font-size: 18px;
line-height: 1.45;
}
html[data-fixed-pages] .ld-composed-main {
width: auto !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
}
html[data-fixed-pages][data-stable-type] .ld-fixed-page {
padding: var(--ld-content-padding) !important;
font-size: var(--ld-type-body);
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
:is(h1, .sb-title h1, .ld-title h1) {
font-size: var(--ld-type-title);
font-weight: 600;
margin: 0;
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
:is(h2, h3, .ld-figure-title) {
font-size: var(--ld-type-section);
font-weight: 600;
margin-top: 0;
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
.ld-composed-grid
> :first-child
:is(h1, h2) {
font-size: var(--ld-type-title);
line-height: 1.15;
letter-spacing: -0.018em;
color: var(--ink);
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
:is([data-variant-body], .ld-composed-unit[data-role="summary"]) {
font-size: var(--ld-type-body);
line-height: 1.5;
}
html[data-fixed-pages][data-stable-type] .ld-fixed-page .ld-composed-grid {
gap: var(--ld-content-gap);
align-content: start;
}
@media (min-width: 761px) {
/* A short slide still needs room for Contents, one reachable index row,
and its pager. The rail and content frame retain the same bottom edge. */
html[data-fixed-pages][data-stable-type][data-has-slide-index="true"]
.ld-fixed-page
.ld-composed-grid {
min-height: 192px;
}
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
.ld-composed-unit:is(
[data-kind="card"],
[data-kind="decision"],
[data-kind="figure"]
) {
padding: calc(var(--ld-space) * 2.5);
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
.ld-composed-unit[data-role="scope"]
[data-variant-body] {
font-size: var(--ld-type-caption);
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
[data-variant-body]
> :last-child {
margin-bottom: 0;
}
html[data-fixed-pages][data-stable-type] .ld-fixed-page .ld-diagram svg {
width: min(100%, 880px);
max-height: none;
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
.ld-diagram
svg.narrow-viz {
width: min(100%, 400px);
}
html[data-fixed-pages] #ld-page-approach {
position: fixed;
top: var(--ld-approach-top, 56px);
left: var(--ld-page-left, 12px);
z-index: 36;
margin: 0;
width: max-content;
max-width: calc(100vw - 24px);
flex-wrap: nowrap;
}
html[data-fixed-pages] .ld-fixed-page .ld-composed-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 20px;
align-items: start;
}
html[data-fixed-pages] .ld-fixed-page .ld-composed-grid > .ld-composed-unit {
grid-column: var(--ld-column) / span var(--ld-span) !important;
grid-row: var(--ld-row) / span var(--ld-row-span, 1) !important;
}
html[data-fixed-pages]
.ld-fixed-page
.ld-composed-section
+ .ld-composed-section {
margin-top: 20px;
}
html[data-fixed-pages] .ld-fixed-page :is(h1, .sb-title h1, .ld-title h1) {
font-size: 32px;
line-height: 1.15;
letter-spacing: -0.018em;
margin-top: 0;
}
html[data-fixed-pages] .ld-fixed-page :is(h2, .ld-figure-title) {
font-size: 22px;
line-height: 1.25;
}
html[data-fixed-pages]
.ld-fixed-page
:is([data-variant-body], .ld-composed-unit[data-role="summary"]) {
font-size: 18px;
line-height: 1.45;
}
html[data-fixed-pages] .ld-fixed-page .ld-composed-unit[data-kind="figure"] {
padding: 20px;
margin: 0;
border-radius: 6px;
}
html[data-fixed-pages] .ld-fixed-page :is(.ld-diagram, .ld-diagram svg) {
min-height: 0;
}
html[data-fixed-pages] .ld-fixed-page .ld-diagram svg {
width: min(100%, 800px);
max-width: 100%;
height: auto;
max-height: calc(var(--ld-page-height, 900px) * 0.52);
}
html[data-fixed-pages][data-composed-shell] .ld-fixed-page .ld-diagram svg {
width: min(100%, 1000px);
}
/* Portrait source rails occupy their spanning column, not the generic
landscape-figure height cap. Explicit editor sizes still take precedence. */
html[data-fixed-pages][data-composed-shell]
.ld-fixed-page
.ld-diagram
svg.timeline-vertical {
width: min(100%, 470px);
max-height: none;
margin-inline: 0;
}
/* Keep the approved compact composition. On wide windows, portrait rails and
prose cards stop growing once their content reaches a useful reading width.
Inline editor widths/max-width overrides remain authoritative. */
html[data-fixed-pages][data-composed-shell]
.ld-fixed-page
.ld-composed-unit[data-kind="figure"]:has(svg.timeline-vertical) {
width: 100%;
max-width: 510px;
justify-self: start;
}
/* A spanning portrait rail ties adjacent narrative rows into one reading group.
Its tighter vertical rhythm also leaves room for the dark-theme card edges. */
html[data-fixed-pages][data-composed-shell]
.ld-fixed-page
.ld-composed-grid:has(svg.timeline-vertical) {
row-gap: 16px;
}
@media (min-width: 1600px) and (min-aspect-ratio: 8 / 5) {
html[data-fixed-pages][data-composed-shell]
.ld-fixed-page
.ld-composed-unit[data-kind="card"] {
font-size: 18px;
width: 100%;
max-width: calc(72ch + 40px);
justify-self: start;
}
html[data-fixed-pages][data-composed-shell]
.ld-fixed-page
.ld-composed-unit[data-kind="card"]
[data-variant-body] {
max-width: 72ch;
}
}
html[data-fixed-pages] .ld-composed-unit[data-kind="card"][data-role="action"] {
padding: 20px;
border-top: 2px solid var(--red);
}
/* Issue briefs have a fixed anatomy, not LLM-authored row geometry. Both
columns fill their track; native type and saved object sizes stay intact. */
.ld-issue-body {
grid-column: 1 / -1;
grid-row: var(--ld-row, 2);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--ld-content-gap, 16px);
min-width: 0;
align-items: start;
}
.ld-issue-analysis,
.ld-issue-support {
display: grid;
gap: var(--ld-content-gap, 16px);
min-width: 0;
align-content: start;
}
html[data-fixed-pages][data-composed-shell] .ld-issue-body .ld-composed-unit {
grid-column: auto !important;
grid-row: auto !important;
width: 100%;
max-width: 100%;
min-width: 0;
justify-self: stretch;
margin: 0;
}
html[data-fixed-pages][data-composed-shell]
.ld-issue-analysis
> .ld-composed-unit[data-kind="card"] {
background: var(--card);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: var(--card-shadow, 0 2px 8px #0000000c);
}
html[data-fixed-pages][data-composed-shell]
.ld-issue-analysis
> .ld-composed-unit[data-role="action"] {
border-top: 2px solid var(--red);
}
html[data-theme="dark"][data-fixed-pages][data-composed-shell]
.ld-issue-analysis
> .ld-composed-unit[data-kind="card"] {
border-color: var(--line-strong);
}
html[data-theme="dark"][data-fixed-pages][data-composed-shell]
.ld-issue-analysis
> .ld-composed-unit[data-role="action"] {
border-top-color: var(--red);
}
html[data-fixed-pages][data-composed-shell]
.ld-issue-analysis
> .ld-composed-unit[data-kind="card"]:is([data-detail], [data-evidence]):is(
:hover,
:focus-visible
) {
border-color: var(--detail-feedback);
}
html[data-fixed-pages][data-composed-shell]
.ld-issue-analysis
> .ld-composed-unit[data-role="action"]:is(:hover, :focus-visible) {
border-top-color: var(--red);
}
.ld-source-preview {
margin: 0;
min-width: 0;
padding: 20px;
background: var(--card);
border: 1px solid var(--line-strong);
border-radius: 6px;
}
.ld-source-preview :is(img, svg) {
display: block;
width: auto;
max-width: 100%;
height: auto;
margin-inline: auto;
}
.ld-source-preview blockquote {
margin: 0;
padding: 0;
font-family: Charter, "Bitstream Charter", Georgia, serif;
font-size: 18px;
line-height: 1.45;
color: var(--ink);
}
.ld-source-preview :is(figcaption, .ld-source-caption) {
display: block;
margin-top: 12px;
font-size: var(--ld-type-caption, 12px);
line-height: 1.4;
color: var(--muted);
}
.ld-page-reader .ld-issue-body {
display: flex;
flex-direction: column;
}
.ld-page-reader :is(.ld-issue-analysis, .ld-issue-support) {
width: 100%;
}
html[data-fixed-pages] .ld-composed-unit[data-kind="table"] table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
html[data-fixed-pages] .ld-composed-unit[data-kind="table"] :is(th, td) {
border-bottom: 1px solid var(--line);
padding: 10px 12px;
vertical-align: top;
text-align: left;
}
html[data-fixed-pages] .ld-composed-unit[data-kind="table"] th {
background: var(--tint);
font-size: 15px;
font-weight: 600;
}
html[data-fixed-pages] .ld-composed-unit[data-kind="table"] tr:last-child td {
border-bottom: 0;
}
html[data-fixed-pages]
.ld-composed-unit[data-role="scope"]
[data-variant-body] {
font-size: 13px;
line-height: 1.4;
}
html[data-fixed-pages] .ld-fixed-page .ld-decision-inputs {
flex-direction: row;
}
html[data-fixed-pages] .ld-reference-page > .ld-figure {
width: 100%;
margin-top: 24px;
padding: 20px;
}
html[data-fixed-pages] .ld-reference-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas: none;
gap: 20px;
margin-top: 0;
align-items: start;
}
html[data-fixed-pages] .ld-reference-cards > .ld-card {
grid-area: auto;
width: auto;
}
html[data-fixed-pages] .ld-reference-page .ld-card-copy {
font-size: 16px;
line-height: 1.4;
}
/* Legacy template compositions use intrinsic page width too; viewport media
queries may change chrome and popup layout, never stack page columns. */
html[data-fixed-pages] .ld-fixed-page .finding-grid {
grid-template-columns: minmax(0, 1.28fr) minmax(310px, 0.9fr);
}
html[data-fixed-pages] .ld-fixed-page .dash-head {
grid-template-columns: minmax(180px, 0.65fr) minmax(0, 2fr);
gap: 24px;
}
html[data-fixed-pages] .ld-fixed-page .dash-facts :has(> .df) {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
html[data-fixed-pages]
.ld-fixed-page
:is(.issue-cards, .area-register, .sb-cards, .sb-open-rows, .sb-decisions) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
html[data-fixed-pages] .ld-fixed-page .issue-row {
min-width: 0;
}
html[data-fixed-pages] .ld-fixed-page .reg .rrow {
grid-template-columns: 18px minmax(0, 1fr) auto;
}
html[data-fixed-pages] .ld-fixed-page .report-register .reg .rrow {
padding-block: 5px;
}
html[data-fixed-pages] .ld-fixed-page .reg .stw {
grid-column: auto;
}
html[data-fixed-pages] .ld-fixed-page .foot {
position: absolute;
bottom: 12px;
left: 48px;
right: 48px;
flex-direction: row;
}
html[data-fixed-pages] .ld-fixed-page .sb-figure {
padding: 20px;
}
html[data-fixed-pages] .ld-fixed-page .sb-figure .ld-diagram {
width: min(920px, 100%);
}
html[data-fixed-pages] .ld-fixed-page .sb-body {
margin-top: 24px;
}
html[data-fixed-pages] .ld-fixed-page .sb-foot {
flex-direction: row;
margin-top: 24px;
}
.ld-reference-evidence {
margin-top: 14px;
min-height: 40px;
}
html[data-fixed-pages] .ld-fixed-page .ld-unit-tools {
position: absolute;
inset: 0 0 auto auto;
width: max-content;
margin: 0;
transform: scale(calc(1 / var(--ld-page-scale, 1))) translateY(-100%)
translateY(var(--ld-context-offset, 0px));
transform-origin: top right;
}
.ld-page-fit-error {
position: fixed;
z-index: 90;
left: max(12px, var(--ld-page-left, 12px));
right: 12px;
bottom: 12px;
padding: 14px 18px;
border: 1px solid var(--red);
border-radius: 6px;
background: var(--bg);
color: var(--ink);
font: 14px / 1.45 var(--sans);
}
.ld-read-page {
position: fixed;
z-index: 40;
right: 16px;
bottom: 12px;
min-height: 44px;
width: 44px;
padding: 0;
display: grid;
place-items: center;
border-radius: 6px;
background: var(--card);
font-size: 14px;
}
html .pop.ld-page-reader {
width: min(760px, 100%);
max-height: calc(100dvh - 24px);
overflow: auto;
padding: 46px 22px 24px;
font-size: 18px;
line-height: 1.5;
}
.ld-page-reader .ld-reader-section {
display: block;
width: 100%;
max-width: none;
margin: 0 0 24px;
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
}
.ld-page-reader .ld-reader-unit {
display: block;
width: 100%;
margin: 0 0 22px;
padding: 0;
}
.ld-page-reader
:is(
.ld-composed-grid,
.ld-core,
.finding-grid,
.dash-head,
.sb-cards,
.sb-open-rows,
.sb-decisions,
.ld-reference-cards
) {
display: block;
}
.ld-page-reader :is(.foot, .sb-foot, .ld-foot) {
position: static;
margin-top: 24px;
}
.ld-page-reader :is(h1, h2, h3) {
font-size: 22px;
line-height: 1.25;
margin: 0 0 12px;
}
.ld-page-reader :is(p, li, span, td, th) {
font-size: 18px;
line-height: 1.5;
}
.ld-page-reader .ld-diagram {
display: block;
width: 100%;
overflow: auto;
}
.ld-page-reader .ld-diagram svg {
width: 100%;
max-width: none;
height: auto;
}
@media (max-width: 600px) {
html .pop.ld-page-reader {
padding-inline: 14px;
}
}
@media print {
html[data-fixed-pages],
html[data-fixed-pages] body {
overflow: visible;
height: auto;
}
html[data-fixed-pages] .ld-fixed-page {
position: relative !important;
left: 0 !important;
top: 0 !important;
transform: none;
break-after: page;
}
}
/* Persistent section index: shared by composed and legacy walkthroughs. */
html[data-has-slide-index="true"] .ld-slide-index {
position: fixed;
inset: var(--ld-index-top, 72px) auto 0 var(--ld-index-left, 0px);
z-index: 35;
display: flex;
flex-direction: column;
align-items: stretch;
width: 206px;
margin: 0;
overflow: auto;
border: 0;
border-right: 1px solid var(--line);
border-radius: 0;
background: var(--bg);
padding: 8px 12px 20px;
}
.ld-slide-index [data-index-list] {
display: flex;
flex-direction: column;
flex: 1;
gap: 3px;
}
.ld-slide-index [data-index-list][hidden] {
display: none;
}
.ld-slide-index .ld-index-toggle {
width: 100%;
min-height: 44px;
margin-bottom: 8px;
border-bottom: 1px solid var(--line);
text-align: left;
}
.ld-slide-index [data-report-section] {
width: 100%;
display: block;
min-height: 28px;
padding: 4px 6px;
flex-shrink: 0;
white-space: normal;
text-align: left;
overflow-wrap: anywhere;
font-size: 12px;
font-weight: 450;
line-height: 1.35;
border-color: transparent;
background: transparent;
color: var(--muted);
}
.ld-slide-index [data-report-section]:has(> .ld-index-number) {
display: grid;
grid-template-columns: 14px 1fr;
gap: 6px;
}
.ld-index-group {
border: 0;
flex-shrink: 0;
margin-top: 6px;
}
.ld-index-group > summary {
display: flex;
align-items: baseline;
gap: 6px;
padding: 5px 6px;
color: var(--muted);
font-size: 10px;
font-weight: 600;
line-height: 1.4;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
list-style: none;
}
.ld-index-group > summary::-webkit-details-marker {
display: none;
}
.ld-index-group > summary::before {
content: "›";
width: 8px;
flex-shrink: 0;
}
.ld-index-group[open] > summary::before {
content: "⌄";
}
.ld-index-group:has([aria-current="true"]) > summary {
color: var(--ink);
}
.ld-index-group > summary:focus-visible {
outline: 2px solid var(--interaction);
outline-offset: -2px;
}
.ld-index-group-items {
display: flex;
flex-direction: column;
gap: 1px;
}
.ld-slide-index[data-index-expanded="false"] .ld-index-pager {
display: none;
}
@media (max-width: 760px), (pointer: coarse) {
.ld-slide-index [data-report-section],
.ld-index-group > summary {
min-height: 44px;
}
}
[data-section-target] {
cursor: pointer;
}
[data-section-target]:hover {
box-shadow:
inset 0 0 0 1px var(--detail-feedback),
var(--shadow-soft);
}
[data-section-target]:focus-visible {
outline: 2px solid var(--detail-feedback);
outline-offset: 3px;
}
.ld-overview-groups {
grid-column: var(--ld-column, 1) / span var(--ld-span, 12);
grid-row: var(--ld-row, auto);
min-width: 0;
align-self: start;
border-top: 1px solid var(--line-strong);
}
.ld-overview-group {
margin: 0;
border-bottom: 1px solid var(--line-strong);
}
.ld-overview-group > summary {
display: flex;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 7px 10px;
color: var(--ink);
cursor: pointer;
list-style: none;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
}
.ld-overview-group > summary::-webkit-details-marker {
display: none;
}
.ld-overview-group > summary::before {
content: "+";
display: inline-block;
width: 14px;
color: var(--muted);
font-weight: 450;
}
.ld-overview-group[open] > summary::before {
content: "−";
}
.ld-overview-group > summary:hover {
background: var(--tint);
}
.ld-overview-group > summary:focus-visible {
outline: 2px solid var(--interaction);
outline-offset: -2px;
}
.ld-overview-group-count {
margin-left: auto;
color: var(--muted);
font-size: 12px;
font-weight: 450;
white-space: nowrap;
}
.ld-overview-group-label {
min-width: 0;
overflow-wrap: anywhere;
}
.ld-overview-topic-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 4px 10px 12px;
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
.ld-overview-topic-grid
> section.ld-composed-unit {
grid-area: auto !important;
/* The wide-page prose measure sets generic cards to justify-self:start.
Topic tiles instead fill their track; explicit inline widths still win. */
justify-self: stretch;
width: auto;
max-width: none;
min-width: 0;
padding: 10px 12px;
background: var(--card);
box-shadow: var(--shadow-soft);
border: 1px solid var(--line);
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
.ld-overview-topic-grid
:is(h2, h3, p) {
font-size: 16px;
line-height: 1.35;
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
.ld-overview-topic-grid
:is(h2, h3) {
margin: 0;
font-weight: 600;
color: var(--ink);
}
html[data-fixed-pages][data-stable-type]
.ld-fixed-page
.ld-overview-topic-grid
p {
margin-top: 4px;
color: var(--muted);
}
.ld-page-reader .ld-overview-topic-grid {
grid-template-columns: 1fr;
padding: 4px 0 12px;
}
.ld-page-reader .ld-overview-group > summary {
min-height: 44px;
font-size: 18px;
}
.ld-page-reader .ld-overview-topic-grid :is(h2, h3, p) {
font-size: 18px;
}
.ld-page-reader .ld-overview-topic-grid > section {
padding: 12px;
border: 1px solid var(--line);
border-radius: 4px;
}
.ld-slide-index [data-report-section][aria-current="true"] {
color: var(--ink);
background: var(--tint);
border-color: var(--line-strong);
font-weight: 600;
}
.ld-index-number {
color: var(--faint);
font-size: 10px;
padding-top: 2px;
}
[aria-current="true"] > .ld-index-number {
color: var(--red);
}
.ld-slide-index [data-report-section]:hover {
color: var(--ink);
background: var(--card);
}
.ld-slide-index .ld-index-pager {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.ld-slide-index .ld-index-pager [data-walkthrough-status] {
grid-column: 1 / -1;
grid-row: 1;
padding: 8px 0;
}
html[data-stable-type] .ld-slide-index {
overflow: hidden;
}
html[data-stable-type] .ld-slide-index .ld-index-toggle {
flex-shrink: 0;
}
html[data-stable-type] .ld-slide-index [data-index-list] {
min-height: 0;
overflow-y: auto;
}
html[data-stable-type] .ld-slide-index .ld-index-pager {
flex-shrink: 0;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 4px;
margin-top: 10px;
padding-top: 8px;
}
html[data-stable-type]
.ld-slide-index
.ld-index-pager
[data-walkthrough-status] {
grid-column: 2;
grid-row: 1;
padding: 4px 0;
font-size: 11px;
}
html[data-stable-type] .ld-slide-index [data-walkthrough-previous] {
grid-column: 1;
grid-row: 1;
}
html[data-stable-type] .ld-slide-index [data-walkthrough-next] {
grid-column: 3;
grid-row: 1;
}
@media (max-width: 760px) {
html[data-stable-type]
.ld-slide-index:not([data-index-expanded="true"])
.ld-index-pager {
display: none;
}
}
html[data-has-slide-index="true"] .ld-composed-main,
html[data-has-slide-index="true"] .sb-stage {
width: calc(100% - 250px);
max-width: 1320px;
margin-left: 226px;
margin-right: 24px;
}
html[data-has-slide-index="true"] .sb-stage {
padding-inline: 0;
}
.ld-slide-index .sb-navigation {
display: flex;
flex-wrap: wrap;
margin-top: 16px;
gap: 8px;
}
.ld-slide-index .sb-navigation .sb-pills {
display: none;
}
.ld-slide-index .sb-navigation #position-label {
display: block;
order: -1;
width: 100%;
text-align: center;
border: 0;
background: transparent;
}
.ld-slide-index .sb-navigation button {
flex: 1;
min-height: 44px;
}
@media (max-width: 760px) {
html[data-has-slide-index="true"] .ld-slide-index {
left: 12px;
width: 44px;
overflow-x: hidden;
background: var(--bg);
border: 0;
padding: 0;
}
html[data-has-slide-index="true"]
.ld-slide-index[data-index-expanded="true"] {
z-index: 38;
width: min(280px, calc(100vw - 24px));
padding: 12px;
background: var(--card);
border-radius: 12px;
box-shadow: var(--shadow);
bottom: auto;
max-height: calc(100dvh - var(--ld-index-top, 72px) - 12px);
}
.ld-slide-index .ld-index-toggle {
text-align: center;
padding-inline: 4px;
}
html[data-has-slide-index="true"] .ld-composed-main,
html[data-has-slide-index="true"] .sb-stage {
width: calc(100% - 80px);
margin-left: 68px;
margin-right: 12px;
}
}
@media print {
html[data-has-slide-index="true"] .ld-slide-index {
display: none;
}
html[data-has-slide-index="true"] .ld-composed-main,
html[data-has-slide-index="true"] .sb-stage {
width: 100%;
max-width: none;
margin: 0;
}
}
</style>
<script id="ld-components">
(() => {
window.LegalDesign = window.LegalDesign || {};
var LD = window.LegalDesign;
var REGISTRY = [
"flow",
"timeline",
"hierarchy",
"zones",
"beforeAfter",
"hub",
"compareTwo",
"funnel",
"loop",
"matrix",
"figurePath",
"stairSteps",
"pyramid",
"gears",
"rungBars",
"hairlineLine",
"hairlineArea",
"tickDonut",
"tickRows",
"pairedRungs",
"stackedRungs",
"plumbScatter",
"rungWaterfall",
"dotHeat",
"tickGauge",
"dumbbell",
"treemap",
"rungHistogram",
"tickBox",
"streamRibbon",
"rangeBars",
];
var C = Object.create(null);
var NS = "http://www.w3.org/2000/svg";
var ACCENT_ROLES = Object.freeze([
"open",
"incomplete",
"uncovered",
"recommendation",
"action",
"decision",
"active",
]);
function esc(t) {
return String(t)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function known(value, name, allowed) {
object(value, name);
Object.keys(value).forEach((key) => {
if (!allowed.includes(key))
throw new TypeError(`${name}.${key} is an unknown field`);
});
return value;
}
function object(value, name = "data") {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new TypeError(`${name} must be an object`);
return value;
}
function text(value, name, optional = false) {
if (optional && value == null) return "";
if (typeof value !== "string" || (!optional && !value.trim()))
throw new TypeError(
`${name} must be ${optional ? "a string when supplied" : "a non-empty string"}`,
);
return value;
}
function list(value, name, min, max) {
if (!Array.isArray(value) || value.length < min || value.length > max)
throw new TypeError(
`${name} must contain ${min === max ? min : `${min}–${max}`} items`,
);
return value;
}
function choice(value, name, choices, optional = false) {
if (optional && value == null) return;
if (!choices.includes(value))
throw new TypeError(`${name} must be one of ${choices.join(", ")}`);
}
function strings(value, name, min, max = min) {
list(value, name, min, max);
value.forEach((v, i) => {
text(v, `${name}[${i}]`);
});
return value;
}
function records(value, name, min, max, required, optional = []) {
list(value, name, min, max);
value.forEach((v, i) => {
known(v, `${name}[${i}]`, required.concat(optional));
required.forEach((k) => {
text(v[k], `${name}[${i}].${k}`);
});
optional.forEach((k) => {
if (k === "accentRole") {
choice(v[k], `${name}[${i}].${k}`, ACCENT_ROLES, true);
} else if (k !== "leaves") text(v[k], `${name}[${i}].${k}`, true);
});
});
return value;
}
function checkOpts(opts) {
known(opts, "opts", [
"narrow",
"compact",
"density",
"width",
"theme",
"ariaLabel",
]);
if (opts.density != null && !["standard", "compact"].includes(opts.density))
throw new TypeError("opts.density must be standard or compact");
if (opts.narrow != null && typeof opts.narrow !== "boolean")
throw new TypeError("opts.narrow must be boolean");
if (opts.compact != null && typeof opts.compact !== "boolean")
throw new TypeError("opts.compact must be boolean");
}
function detailWrap(markup, detail, hitbox) {
// Text-only SVG groups have no painted shape. Labels intentionally ignore
// pointer events in view mode, so give them an explicit transparent target.
const hit =
detail && hitbox
? `<rect class="detail-hit" x="${hitbox[0]}" y="${hitbox[1]}" width="${hitbox[2]}" height="${hitbox[3]}" fill="transparent" stroke="none" pointer-events="all"/>`
: "";
return typeof detail === "string" && detail
? `<g data-diagram-object class="ld-detail" role="button" tabindex="0" aria-haspopup="dialog" aria-controls="${esc(detail)}" data-detail="${esc(detail)}">${hit}${markup}</g>`
: `<g data-diagram-object>${markup}</g>`;
}
function sentence(data) {
const words = [];
const visit = (value) => {
if (typeof value === "string" && value.trim()) words.push(value.trim());
else if (Array.isArray(value)) value.forEach(visit);
else if (value && typeof value === "object")
Object.keys(value)
.filter((key) => key !== "detail")
.forEach((key) => {
visit(value[key]);
});
};
visit(data);
return `Diagram showing ${words.slice(0, 10).join(", ")}.`;
}
function lines(value, maxChars = 20, maxLines = 3) {
const words = String(value).trim().split(/\s+/),
out = [];
words.forEach((word) => {
if (!out.length || `${out.at(-1)} ${word}`.length > maxChars)
out.push(word);
else out[out.length - 1] += ` ${word}`;
});
if (out.length > maxLines)
return [...out.slice(0, maxLines - 1), out.slice(maxLines - 1).join(" ")];
return out;
}
function txt(value, x, y, width, cls = "", maxLines = 2, anchor = "middle") {
if (value == null || value === "") return "";
// Uppercase relationship labels include tracking, so budget their real
// letter width instead of letting them intrude into adjacent cards.
const chars = Math.max(
5,
Math.floor(width / (cls === "edge" ? 8.25 : 6.2)),
),
rows = lines(value, chars, maxLines),
first = y - (rows.length - 1) * 7;
return `<text class="${cls}" x="${x}" y="${first}" text-anchor="${anchor}">${rows.map((row, i) => `<tspan x="${x}" dy="${i ? 14 : 0}">${esc(row)}</tspan>`).join("")}</text>`;
}
function svgMarkup(body, opts, viewBox, extraClass = "", data = {}) {
checkOpts(opts);
const aria = esc(
typeof opts.ariaLabel === "string" && opts.ariaLabel
? opts.ariaLabel
: sentence(data),
);
const width = opts.width == null ? "100%" : esc(opts.width);
const theme = opts.theme == null ? "" : ` data-theme="${esc(opts.theme)}"`;
const role = body.includes('data-detail="') ? "group" : "img";
return `<svg xmlns="${NS}" class="library-viz diagram-viz${extraClass ? ` ${extraClass}` : ""}" viewBox="${viewBox}" width="${width}" role="${role}" aria-label="${aria}"${theme}>
<style>
.diagram-viz{display:block;color:var(--ink);overflow:visible}.diagram-viz text{font-family:var(--sans);font-size:14px;font-weight:600;fill:var(--ink);stroke:none}.diagram-viz .sub{font-size:12.25px;font-weight:400;fill:var(--muted)}.diagram-viz .zone,.diagram-viz .edge{font-family:var(--sans);font-size:12.25px;font-weight:650;letter-spacing:0;text-transform:none}.diagram-viz .edge{fill:var(--muted)}.diagram-viz .line,.diagram-viz .hair{fill:none;stroke:var(--line-strong);stroke-width:1;vector-effect:non-scaling-stroke}.diagram-viz .box{fill:var(--card);stroke:transparent;stroke-width:1.5;filter:drop-shadow(0 1px 2px rgba(0,0,0,.06)) drop-shadow(0 7px 16px rgba(0,0,0,.07));vector-effect:non-scaling-stroke}.diagram-viz .box.tint{fill:var(--tint);stroke:var(--line-strong)}.diagram-viz .box.accent{fill:var(--red-wash);stroke:var(--red)}.diagram-viz .tint{fill:var(--tint)}.diagram-viz .inkfill{fill:var(--ink);fill-opacity:.1;stroke:var(--ink);stroke-width:1;vector-effect:non-scaling-stroke}.diagram-viz .band{fill:var(--tint);stroke:var(--line-strong);stroke-width:1;vector-effect:non-scaling-stroke}.diagram-viz .edge-band{fill:var(--tint);stroke:none}.diagram-viz .open{stroke:var(--red);fill:var(--red-wash)}.diagram-viz .leaf{fill:var(--tint);stroke:transparent;stroke-width:1;vector-effect:non-scaling-stroke}.diagram-viz .ld-detail{cursor:pointer;outline:none}.diagram-viz .ld-detail text{user-select:none}
.chart-viz text{font-size:11.75px}.chart-viz .chart-title{font-size:12.5px;font-weight:650;fill:var(--ink)}.chart-viz .axis-title{font-size:11.75px;font-weight:600;fill:var(--ink)}.chart-viz .value{font-size:12px;font-weight:500;fill:var(--ink)}.chart-viz .muted,.chart-viz .source{fill:var(--muted)}.chart-viz .source{font-size:11.75px}.chart-viz .quiet{fill:none;stroke:var(--muted);stroke-width:1;vector-effect:non-scaling-stroke}.chart-viz .faint{fill:none;stroke:currentColor;stroke-opacity:.18;stroke-width:1;vector-effect:non-scaling-stroke}.chart-viz .fill,.chart-viz .tone-1,.chart-viz .tone-2,.chart-viz .tone-3,.chart-viz .tone-4{stroke:currentColor;stroke-width:1;vector-effect:non-scaling-stroke}.chart-viz .label-pad{stroke:none}.chart-viz .fill,.chart-viz .tone-1{fill:color-mix(in srgb,currentColor 14%,var(--card))}.chart-viz .tone-2{fill:color-mix(in srgb,currentColor 28%,var(--card))}.chart-viz .tone-3{fill:color-mix(in srgb,currentColor 48%,var(--card))}.chart-viz .tone-4{fill:color-mix(in srgb,currentColor 72%,var(--card))}.chart-viz .open{stroke:var(--red);fill:var(--red-wash)}.chart-viz .open-line{stroke:var(--red)}
.diagram-viz .ld-detail:hover>.box:first-child,.diagram-viz .ld-detail:hover>.leaf:first-child,html:not([data-input-modality="pointer"]) .diagram-viz .ld-detail:focus-visible>.box:first-child,html:not([data-input-modality="pointer"]) .diagram-viz .ld-detail:focus-visible>.leaf:first-child{stroke:var(--interaction)}
:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]){--ld-node-fill:var(--card,#111);--ld-node-ink:#fff;--ld-node-muted:#fff;--ld-node-accent-ink:#fff;--ld-node-outline:#999}
:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]):not(.chart-viz) :is(.box,.leaf,.band,.tint,.inkfill){fill:var(--ld-node-fill);fill-opacity:1;stroke:var(--ld-node-outline);stroke-width:2;filter:none}
:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]):not(.chart-viz) :is(.box,.inkfill)+circle.line{stroke:var(--ld-node-muted)}
:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]):not(.chart-viz) :is(.box,.band,.leaf).accent,:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]):not(.chart-viz) .open{fill:var(--ld-node-fill);fill-opacity:1;stroke:var(--red)}
:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]):not(.chart-viz) text{fill:var(--ld-node-ink)}
:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]):not(.chart-viz) :is(.line,.hair){stroke:#fff;stroke-width:1.5}
:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]):not(.chart-viz) [data-diagram-object]:has(> :is(.box,.leaf,.band,.tint,.inkfill))>text,:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]):not(.chart-viz) .surface-text{fill:var(--ld-node-ink)}
:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]):not(.chart-viz) [data-diagram-object]:has(> :is(.box,.leaf,.band,.tint))>text.sub{fill:var(--ld-node-muted)}
:is(html[data-theme="dark"] .diagram-viz:not([data-theme="light"]),.diagram-viz[data-theme="dark"]):not(.chart-viz) [data-diagram-object]:has(> :is(.accent,.open))>text:is(.sub,:not(.sub)){fill:var(--ld-node-accent-ink)}
html[data-theme="dark"] .chart-viz .fill.open,html[data-theme="dark"] .chart-viz .tone-1.open,.chart-viz[data-theme="dark"] .fill.open,.chart-viz[data-theme="dark"] .tone-1.open{fill:color-mix(in srgb,currentColor 14%,var(--card))}
html[data-theme="dark"] .chart-viz .tone-2.open,.chart-viz[data-theme="dark"] .tone-2.open{fill:color-mix(in srgb,currentColor 28%,var(--card))}
html[data-theme="dark"] .chart-viz .tone-3.open,.chart-viz[data-theme="dark"] .tone-3.open{fill:color-mix(in srgb,currentColor 48%,var(--card))}
html[data-theme="dark"] .chart-viz .tone-4.open,.chart-viz[data-theme="dark"] .tone-4.open{fill:color-mix(in srgb,currentColor 72%,var(--card))}
</style>${body}</svg>`;
}
function svg(body, opts, frame = "pane", data = {}) {
const viewBox =
frame === "wideTall"
? "0 0 880 420"
: frame === "wide"
? "0 0 880 360"
: "0 0 400 400";
return svgMarkup(body, opts, viewBox, "", data);
}
function accentAttr(role) {
return role ? ` data-accent-role="${esc(role)}"` : "";
}
function box(x, y, w, h, title, sub = "", opts = {}) {
const cls = `box${opts.tint ? " tint" : ""}${opts.accentRole ? " accent" : ""}${opts.open ? " open" : ""}`;
const cy = y + h / 2;
return detailWrap(
`<rect class="${cls}"${accentAttr(opts.accentRole)} x="${x}" y="${y}" width="${w}" height="${h}" rx="7"/>${txt(title, x + w / 2, cy - (sub ? 8 : 0), w - 18, opts.titleClass || "", 2)}${sub ? txt(sub, x + w / 2, cy + 19, w - 18, "sub", 2) : ""}`,
opts.detail,
);
}
function flowCard(x, y, w, h, stage) {
return box(x, y, w, h, stage.title, stage.sub, stage);
}
function hierarchyLeaf(x, y, w, h, leaf) {
return detailWrap(
`<rect class="leaf" x="${x}" y="${y}" width="${w}" height="${h}" rx="5"/>${txt(leaf.title, x + w / 2, y + h / 2 + 4, w - 12, "sub", 2)}`,
leaf.detail,
);
}
function arrowH(x1, x2, y, edge, reverse = false) {
const from = reverse ? x2 : x1,
to = reverse ? x1 : x2,
sign = to > from ? 1 : -1,
end = to - sign * 7;
return `<path class="line" d="M${from} ${y}H${end}"/><path class="line" d="M${to - sign * 7} ${y - 4}L${to} ${y}L${to - sign * 7} ${y + 4}"/>${txt(edge, (x1 + x2) / 2, y - 25, Math.abs(x2 - x1) - 32, "edge", 3)}`;
}
function arrowV(y1, y2, x, edge) {
const sign = y2 > y1 ? 1 : -1,
end = y2 - sign * 7;
return `<path class="line" d="M${x} ${y1}V${end}"/><path class="line" d="M${x - 4} ${y2 - sign * 7}L${x} ${y2}L${x + 4} ${y2 - sign * 7}"/>${txt(edge, x + 18, (y1 + y2) / 2 + 3, 120, "edge", 2, "start")}`;
}
// A thumbnail may scale a diagram, but must not remove facts or change its
// relationships. Keep compact as a compatibility option for saved files.
function compactDiagram(id, data, opts) {
return C[id](data, { ...opts, compact: false });
}
function narrowSvg(body, opts, height = 400, data = {}) {
return svgMarkup(body, opts, `0 0 400 ${height}`, "narrow-viz", data);
}
function narrowDiagram(id, data, opts) {
let body = "";
let height = 400;
if (id === "flow") {
const boxH = 68,
gap = opts.density === "compact" ? 48 : 62;
height =
28 + data.stages.length * boxH + (data.stages.length - 1) * gap + 28;
data.stages.forEach((stage, i) => {
const y = 28 + i * (boxH + gap);
body += box(40, y, 320, boxH, stage.title, stage.sub, stage);
if (i < data.stages.length - 1)
body += arrowV(y + boxH + 6, y + boxH + gap - 6, 200, data.edges[i]);
});
} else if (id === "timeline") {
const boxH = 66,
step = 94;
height = 24 + (data.marks.length - 1) * step + boxH + 24;
body = `<path class="line" d="M104 38V${height - 38}M100 ${height - 45}L104 ${height - 38}L108 ${height - 45}"/>`;
data.marks.forEach((mark, i) => {
const y = 24 + i * step,
cy = y + boxH / 2,
active = i === data.nowAt;
body += `${txt(mark.when, 84, cy + 4, 70, active ? "zone" : "edge", 1, "end")}<circle class="${active ? "box accent" : "box"}"${accentAttr(active ? "active" : undefined)} cx="104" cy="${cy}" r="4.5"/>${box(122, y, 256, boxH, mark.title, mark.sub, { accentRole: active ? "active" : undefined, detail: mark.detail })}`;
});
} else if (id === "hierarchy") {
const gap = opts.density === "compact" ? 28 : 62,
branchHeights = data.branches.map((branch) => {
const leaves = branch.leaves || [];
if (!leaves.length) return branch.sub ? 82 : 70;
return branch.sub ? 122 : 104;
});
height =
26 +
68 +
data.branches.reduce(
(total, _branch, index) => total + gap + branchHeights[index],
0,
) +
24;
body = box(142, 26, 236, 68, data.root, data.rootSub, {
detail: data.rootDetail,
});
const lastBranchY =
26 +
68 +
branchHeights.slice(0, -1).reduce((sum, h) => sum + h + gap, 0) +
gap;
// Each sibling is connected to the root's shared bus. A vertical row
// must never turn a parent/child tree into a chain of dependencies.
body += `<path class="line hierarchy-root-bus" d="M142 60H24V${lastBranchY + branchHeights.at(-1) / 2}"/>`;
let branchY = 26 + 68 + gap;
data.branches.forEach((branch, i) => {
const branchH = branchHeights[i],
leaves = branch.leaves || [],
titleY =
branchY + (branch.sub ? 28 : leaves.length ? 31 : branchH / 2 + 4),
subY = branchY + 51;
let branchMarkup = `<rect class="box${branch.accentRole ? " accent" : ""}"${accentAttr(branch.accentRole)} x="142" y="${branchY}" width="236" height="${branchH}" rx="7"/>${txt(branch.title, 260, titleY, 202, "", 2)}${branch.sub ? txt(branch.sub, 260, subY, 202, "sub", 2) : ""}`;
let leafTargets = "";
if (leaves.length) {
const leafW = (216 - 10 * (leaves.length - 1)) / leaves.length,
leafY = branchY + (branch.sub ? 70 : 52),
leafH = branchH - (leafY - branchY) - 10;
leaves.forEach((leaf, j) => {
const leafMarkup = hierarchyLeaf(
152 + j * (leafW + 10),
leafY,
leafW,
leafH,
leaf,
);
if (leaf.detail) leafTargets += leafMarkup;
else branchMarkup += leafMarkup;
});
}
body +=
`<g class="hierarchy-branch-link" data-parent="root" data-branch="${i}">${arrowH(24, 134, branchY + branchH / 2, branch.edge)}</g>` +
detailWrap(branchMarkup, branch.detail) +
leafTargets;
branchY += branchH + gap;
});
} else if (id === "zones") {
const zones = [
{ title: data.zoneA, items: data.itemsA },
{ title: data.zoneB, items: data.itemsB },
];
const itemHeight = (item) => {
const titleRows = lines(item.title, 51, 2).length;
const subRows = item.sub ? lines(item.sub, 51, 3).length : 0;
return Math.max(
54,
26 + titleRows * 14 + (subRows ? 7 + subRows * 14 : 0),
);
};
const itemGap = 12;
const zoneHeights = zones.map(
(zone) =>
42 +
zone.items.reduce((sum, item) => sum + itemHeight(item), 0) +
Math.max(0, zone.items.length - 1) * itemGap +
14,
);
const gap = 70;
height = 18 + zoneHeights[0] + gap + zoneHeights[1] + 18;
let y = 18;
zones.forEach((zone, zi) => {
const zh = zoneHeights[zi];
body += `<rect class="band" x="16" y="${y}" width="368" height="${zh}" rx="4"/>${txt(zone.title, 30, y + 25, 330, "zone surface-text", 1, "start")}`;
let itemY = y + 42;
zone.items.forEach((item) => {
const h = itemHeight(item);
body += box(30, itemY, 340, h, item.title, item.sub, item);
itemY += h + itemGap;
});
if (!zi)
body += arrowV(y + zh + 8, y + zh + gap - 8, 200, data.crossing);
y += zh + gap;
});
} else if (id === "beforeAfter") {
height = 350;
body =
box(40, 16, 320, 110, data.beforeLabel, data.beforeSub, {}) +
arrowV(136, 214, 200, data.edge) +
box(40, 224, 320, 110, data.afterLabel, data.afterSub);
} else if (id === "hub") {
const rowH = 58,
gap = 12;
height =
24 +
70 +
30 +
data.spokes.length * rowH +
(data.spokes.length - 1) * gap +
24;
body = box(100, 24, 200, 70, data.centre);
// All satellites join the same continuous bus. Splitting the bus at
// each row leaves later spokes disconnected from the central node.
body += `<path class="hair hub-bus" d="M200 94V${124 + (data.spokes.length - 1) * (rowH + gap) + rowH / 2}"/>`;
data.spokes.forEach((spoke, i) => {
const y = 124 + i * (rowH + gap),
cy = y + rowH / 2,
towardCentre = data.toward === "centre",
arrowTip = towardCentre ? 200 : 218,
arrowBase = towardCentre ? 207 : 211;
body += `<path class="line" d="M200 ${cy}H218"/><path class="line hub-arrowhead" data-direction="${towardCentre ? "centre" : "satellites"}" d="M${arrowBase} ${cy - 4}L${arrowTip} ${cy}L${arrowBase} ${cy + 4}"/>${txt(spoke.edge, 190, cy + 4, 154, "edge", 1, "end")}${box(224, y, 154, rowH, spoke.title, "", { detail: spoke.detail })}`;
});
} else if (id === "compareTwo") {
const rowH = 102;
height = 70 + data.rows.length * rowH + 20;
body = `${detailWrap(txt(data.a, 112, 36, 168, "zone", 1), data.aDetail, [20, 8, 168, 44])}${detailWrap(txt(data.b, 288, 36, 168, "zone", 1), data.bDetail, [212, 8, 168, 44])}`;
data.rows.forEach((row, i) => {
const y = 62 + i * rowH;
body += `${txt(row.label, 20, y + 14, 360, "zone", 1, "start")}${box(20, y + 26, 168, 62, row.a, "", { tint: data.highlight === "a", titleClass: row.winner === "a" ? "zone" : "" })}${box(212, y + 26, 168, 62, row.b, "", { tint: data.highlight === "b", titleClass: row.winner === "b" ? "zone" : "" })}`;
});
} else if (id === "funnel") {
const widths = data.tiers.length === 3 ? [352, 260, 168] : [332, 206],
boxH = 70,
gap = 58;
height =
24 + data.tiers.length * boxH + (data.tiers.length - 1) * gap + 82;
data.tiers.forEach((tier, i) => {
const w = widths[i],
x = 200 - w / 2,
y = 24 + i * (boxH + gap);
if (i) body += arrowV(y - gap + 6, y - 6, 200, data.edges[i - 1]);
body += box(x, y, w, boxH, tier.title, tier.sub, {
detail: tier.detail,
});
});
body +=
arrowV(height - 72, height - 44, 200, "") +
txt(data.outcome, 200, height - 18, 340, "zone", 2);
} else if (id === "loop") {
const boxH = 58,
gap = 50;
height =
70 + data.steps.length * boxH + (data.steps.length - 1) * gap + 54;
body = txt(data.centre, 200, 30, 340, "zone", 2);
data.steps.forEach((step, i) => {
const y = 58 + i * (boxH + gap);
body += box(78, y, 284, boxH, step.title, "", {
detail: step.detail,
});
if (i < data.steps.length - 1)
body += arrowV(y + boxH + 6, y + boxH + gap - 6, 220, step.edge);
});
const lastY = 58 + (data.steps.length - 1) * (boxH + gap);
body += `<path class="line loop-return" d="M78 ${lastY + boxH / 2}H38V${58 + boxH / 2}H71M64 ${58 + boxH / 2 - 4}L71 ${58 + boxH / 2}L64 ${58 + boxH / 2 + 4}"/><g transform="translate(18 ${(lastY + 58 + boxH) / 2}) rotate(-90)">${txt(data.steps.at(-1).edge, 0, 0, 220, "edge", 1)}</g>`;
} else if (id === "matrix") {
height = 430;
const x = 20,
y = 36,
size = 360,
half = 180;
data.quads.forEach((quad, i) => {
const qx = x + (i % 2) * half,
qy = y + Math.floor(i / 2) * half;
body += detailWrap(
`<rect class="${i === data.markAt ? "band" : "box"}" x="${qx}" y="${qy}" width="${half}" height="${half}"/>${txt(quad.title, qx + half / 2, qy + half / 2 - (quad.sub ? 12 : 0), half - 26, "zone", 2)}${quad.sub ? txt(quad.sub, qx + half / 2, qy + half / 2 + 22, half - 28, "sub", 3) : ""}`,
quad.detail,
);
});
body += `${txt(data.yAxis, 20, 22, 170, "zone", 1, "start")}${txt(data.xAxis, 380, 418, 170, "zone", 1, "end")}`;
} else if (id === "figurePath") {
height = 430;
const spots = [
[82, 330],
[174, 236],
[266, 142],
];
body = `<path class="line" d="M46 374C88 356 105 304 150 268S220 204 250 164S300 104 350 64M343 60L350 64L346 72"/>${txt(data.goal, 346, 42, 112, "zone", 1, "end")}`;
data.marks.forEach((mark, i) => {
body += `<circle class="inkfill" cx="${spots[i][0]}" cy="${spots[i][1]}" r="5"/>${txt(mark, spots[i][0] + 13, spots[i][1] + 4, 108, "", 1, "start")}`;
});
body += txt(data.caption, 200, 408, 350, "sub", 2);
} else if (id === "stairSteps") {
height = 430;
const tread = 344 / data.steps.length,
rise = 48,
x0 = 28,
y0 = 354;
data.steps.forEach((step, i) => {
const x = x0 + i * tread,
y = y0 - i * rise;
body += `<path class="line" d="M${x} ${i ? y + rise : y}V${y}H${x + tread}"/>${txt(step.title, x + tread / 2, y - (step.sub ? 30 : 11), tread - 10, "", 2)}${step.sub ? txt(step.sub, x + tread / 2, y - 9, tread - 10, "sub", 1) : ""}`;
});
if (data.caption) body += txt(data.caption, 200, 410, 350, "sub", 2);
} else if (id === "pyramid") {
height = 430;
const baseY = 342,
blockH = 48;
data.blocks.forEach((block, i) => {
const w = 352 - i * 48,
x = 200 - w / 2,
y = baseY - i * (blockH + 4);
body += detailWrap(
`<rect class="band" x="${x}" y="${y}" width="${w}" height="${blockH}" rx="3"/>${txt(block.title, 200, y + (block.sub ? 19 : 29), w - 12, "", 1)}${block.sub ? txt(block.sub, 200, y + 36, w - 12, "sub", 1) : ""}`,
);
});
if (data.caption) body += txt(data.caption, 200, 408, 350, "sub", 2);
} else if (id === "gears") {
height = 430;
const gs = [
{ cx: 106, cy: 210, r: 42, t: 10 },
{ cx: 200, cy: 158, r: 61, t: 14 },
{ cx: 302, cy: 222, r: 46, t: 11 },
];
[1, 0, 2].forEach((i) => {
const gear = gs[i];
body += `<path class="${i === 2 ? "inkfill" : "box"}" d="${gearPath(gear.cx, gear.cy, gear.r, gear.t, i * 0.22)}"/><circle class="line" cx="${gear.cx}" cy="${gear.cy}" r="${(gear.r * 0.22).toFixed(2)}"/>`;
});
const labels = [
[106, 292],
[200, 70],
[302, 310],
];
data.labels.forEach((value, i) => {
body += txt(value, labels[i][0], labels[i][1], 116, "", 2);
});
if (data.caption) body += txt(data.caption, 200, 404, 350, "sub", 2);
}
return narrowSvg(body, opts, height, data);
}
C.flow = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["stages", "edges"]);
const stages = records(
data.stages,
"stages",
2,
6,
["title"],
["sub", "detail", "accentRole"],
);
const edges = strings(data.edges, "edges", stages.length - 1);
if (opts.compact) return compactDiagram("flow", data, opts);
if (opts.narrow === true) return narrowDiagram("flow", data, opts);
let body = "";
if (stages.length <= 4) {
const gap = stages.length === 2 ? 160 : stages.length === 3 ? 148 : 116,
w = (832 - gap * (stages.length - 1)) / stages.length,
y = 88,
h = 178;
stages.forEach((s, i) => {
const x = 24 + i * (w + gap);
body += box(x, y, w, h, s.title, s.sub, {
accentRole: s.accentRole,
detail: s.detail,
});
if (i < stages.length - 1)
body += arrowH(x + w + 8, x + w + gap - 8, y + h / 2, edges[i]);
});
} else {
const w = 200,
h = 112,
xs = [22, 340, 658],
top = 44,
bottom = 218,
positions = [
[xs[0], top],
[xs[1], top],
[xs[2], top],
[xs[2], bottom],
[xs[1], bottom],
[xs[0], bottom],
];
body += arrowH(xs[0] + w + 8, xs[1] - 8, top + h / 2, edges[0]);
body += arrowH(xs[1] + w + 8, xs[2] - 8, top + h / 2, edges[1]);
body += arrowV(top + h + 8, bottom - 8, xs[2] + w / 2, edges[2]);
body += arrowH(xs[1] + w + 8, xs[2] - 8, bottom + h / 2, edges[3], true);
if (stages.length === 6)
body += arrowH(
xs[0] + w + 8,
xs[1] - 8,
bottom + h / 2,
edges[4],
true,
);
stages.forEach((stage, i) => {
const [x, y] = positions[i];
body += flowCard(x, y, w, h, stage);
});
}
return svg(body, opts, "wide", data);
};
C.timeline = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["marks", "nowAt", "orientation"]);
choice(data.orientation, "orientation", ["horizontal", "vertical"], true);
const marks = records(
data.marks,
"marks",
3,
5,
["when", "title"],
["sub", "detail"],
);
if (
data.nowAt != null &&
(!Number.isInteger(data.nowAt) ||
data.nowAt < 0 ||
data.nowAt >= marks.length)
)
throw new TypeError("nowAt must index marks");
if (data.orientation === "vertical") {
// This rail encodes order, not elapsed duration. Event depth comes from
// its prose, so no text is dropped or forced into equally sized boxes.
const wrap = (value, columns) => lines(value || "", columns, Infinity);
const blocks = marks.map((mark) => ({
when: wrap(mark.when, 43),
title: wrap(mark.title, 37),
sub: mark.sub ? wrap(mark.sub, 43) : [],
}));
let cursor = 26;
const positions = blocks.map((block) => {
const y = cursor;
const height =
block.when.length * 16 +
8 +
block.title.length * 18 +
(block.sub.length ? 8 + block.sub.length * 17 : 0);
cursor += height + 28;
return { y, height };
});
const writeLines = (rows, y, cls, leading) =>
`<text class="${cls}" x="58" y="${y}" text-anchor="start">${rows.map((row, index) => `<tspan x="58" dy="${index ? leading : 0}">${esc(row)}</tspan>`).join("")}</text>`;
let body = `<style>.timeline-vertical .timeline-hit{fill:transparent;stroke:none}.timeline-vertical .timeline-dot{fill:var(--card);stroke:var(--muted);stroke-width:1.5;vector-effect:non-scaling-stroke}.timeline-vertical .timeline-dot[data-accent-role="active"]{fill:var(--red);stroke:var(--red)}</style><path class="line timeline-rail" d="M28 ${positions[0].y + 5}V${positions.at(-1).y + 5}"/>`;
blocks.forEach((block, index) => {
const { y, height } = positions[index];
const titleY = y + block.when.length * 16 + 8;
const subY = titleY + block.title.length * 18 + 8;
const content = `<rect class="timeline-hit" x="12" y="${y - 10}" width="376" height="${height + 14}" rx="4"/><circle class="timeline-dot"${accentAttr(index === data.nowAt ? "active" : undefined)} cx="28" cy="${y + 5}" r="4.5"/>${writeLines(block.when, y + 9, "edge", 16)}${writeLines(block.title, titleY + 11, "", 18)}${block.sub.length ? writeLines(block.sub, subY + 9, "sub", 17) : ""}`;
body += detailWrap(content, marks[index].detail).replace(
"<g data-diagram-object",
`<g data-timeline-event="${index}" data-diagram-object`,
);
});
return svgMarkup(
body,
opts,
`0 0 400 ${cursor - 12}`,
"timeline-vertical",
data,
);
}
if (opts.compact) return compactDiagram("timeline", data, opts);
if (opts.narrow === true) return narrowDiagram("timeline", data, opts);
const n = marks.length,
gap = 24,
w = (828 - gap * (n - 1)) / n,
axisY = 262;
let body = `<path class="line" d="M22 ${axisY}H858"/><path class="line" d="M851 ${axisY - 4}L858 ${axisY}L851 ${axisY + 4}"/>`;
marks.forEach((m, i) => {
const x = 26 + i * (w + gap),
cx = x + w / 2,
now = i === data.nowAt;
body +=
box(x, 54, w, 140, m.title, m.sub, {
accentRole: now ? "active" : undefined,
detail: m.detail,
}) +
`<path class="hair" d="M${cx} 194V257"/><circle class="${now ? "box accent" : "box"}"${accentAttr(now ? "active" : undefined)} cx="${cx}" cy="${axisY}" r="4.5"/>${txt(m.when, cx, 292, w, now ? "zone" : "edge", 1)}`;
});
return svg(body, opts, "wide", data);
};
C.hierarchy = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["root", "rootSub", "rootDetail", "branches"]);
text(data.root, "root");
text(data.rootSub, "rootSub", true);
text(data.rootDetail, "rootDetail", true);
const branches = records(
data.branches,
"branches",
2,
4,
["title", "edge"],
["sub", "detail", "leaves", "accentRole"],
);
branches.forEach((b, i) => {
if (b.leaves != null) {
list(b.leaves, `branches[${i}].leaves`, 1, 2);
b.leaves.forEach((leaf, j) => {
known(leaf, `branches[${i}].leaves[${j}]`, ["title", "detail"]);
text(leaf.title, `branches[${i}].leaves[${j}].title`);
text(leaf.detail, `branches[${i}].leaves[${j}].detail`, true);
});
}
});
if (opts.compact) return compactDiagram("hierarchy", data, opts);
if (opts.narrow === true) return narrowDiagram("hierarchy", data, opts);
const rootW = 300,
rootX = 290,
rootY = 18,
rootH = 82,
gap = 24,
branchY = 204,
branchH = 156,
bw = (820 - gap * (branches.length - 1)) / branches.length;
let body = "";
branches.forEach((b, i) => {
const x = 30 + i * (bw + gap),
cx = x + bw / 2;
// Break only around the label's ink: a few points of clear space on
// either side keeps the relationship continuous without drawing through it.
body += `<path class="line hierarchy-connector" data-label-y="160" d="M440 ${rootY + rootH}V124H${cx}V147M${cx} 166V${branchY - 8}"/><path class="line" d="M${cx - 4} ${branchY - 15}L${cx} ${branchY - 8}L${cx + 4} ${branchY - 15}"/>${txt(b.edge, cx, 160, bw - 8, "edge", 1)}`;
});
body += box(rootX, rootY, rootW, rootH, data.root, data.rootSub, {
detail: data.rootDetail,
});
branches.forEach((branch, i) => {
const x = 30 + i * (bw + gap),
leaves = branch.leaves || [],
rowY = branchY + 84,
rowGap = 8,
rowW =
(bw - 32 - rowGap * Math.max(0, leaves.length - 1)) /
Math.max(1, leaves.length),
titleY = leaves.length
? branchY + (branch.sub ? 35 : 50)
: branchY + branchH / 2 - (branch.sub ? 8 : 0),
subY = leaves.length ? branchY + 61 : branchY + branchH / 2 + 19;
let markup = `<rect class="box${branch.accentRole ? " accent" : ""}"${accentAttr(branch.accentRole)} x="${x}" y="${branchY}" width="${bw}" height="${branchH}" rx="7"/>${txt(branch.title, x + bw / 2, titleY, bw - 34, "", 2)}${branch.sub ? txt(branch.sub, x + bw / 2, subY, bw - 34, "sub", 2) : ""}`;
let leafTargets = "";
leaves.forEach((leaf, j) => {
const lx = x + 16 + j * (rowW + rowGap),
leafMarkup = hierarchyLeaf(lx, rowY, rowW, 45, leaf);
// A leaf with its own popup is a sibling target. A leaf without one
// belongs to the branch's single clickable surface and never gets a
// separate hover/focus outline.
if (leaf.detail) leafTargets += leafMarkup;
else markup += leafMarkup;
});
body += detailWrap(markup, branch.detail) + leafTargets;
});
return svg(body, opts, "wideTall", data);
};
C.zones = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["zoneA", "itemsA", "zoneB", "itemsB", "crossing"]);
text(data.zoneA, "zoneA");
text(data.zoneB, "zoneB");
text(data.crossing, "crossing");
const a = records(
data.itemsA,
"itemsA",
1,
3,
["title"],
["sub", "detail"],
),
b = records(data.itemsB, "itemsB", 1, 3, ["title"], ["sub", "detail"]);
if (opts.compact) return compactDiagram("zones", data, opts);
if (opts.narrow === true) return narrowDiagram("zones", data, opts);
let body = `<rect class="band" x="14" y="10" width="852" height="150" rx="4"/>${txt(data.zoneA, 34, 32, 300, "zone surface-text", 1, "start")}<rect class="band" x="14" y="242" width="852" height="168" rx="4"/>${txt(data.zoneB, 34, 266, 300, "zone surface-text", 1, "start")}`;
const row = (items, y) => {
const gap = 34,
w = (804 - gap * (items.length - 1)) / items.length;
return items
.map((v, i) =>
box(38 + i * (w + gap), y, w, 90, v.title, v.sub, {
detail: v.detail,
}),
)
.join("");
};
body += row(a, 52) + row(b, 286) + arrowV(160, 242, 440, data.crossing);
return svg(body, opts, "wideTall", data);
};
C.beforeAfter = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", [
"orientation",
"beforeLabel",
"beforeSub",
"edge",
"afterLabel",
"afterSub",
]);
["beforeLabel", "beforeSub", "edge", "afterLabel", "afterSub"].forEach(
(k) => {
text(data[k], k);
},
);
choice(data.orientation, "orientation", ["vertical", "horizontal"], true);
if (opts.compact) return compactDiagram("beforeAfter", data, opts);
if (opts.narrow === true) return narrowDiagram("beforeAfter", data, opts);
const horizontal = data.orientation === "horizontal";
let body = "";
if (horizontal) {
body +=
box(12, 70, 126, 220, data.beforeLabel, data.beforeSub, {}) +
arrowH(150, 250, 180, data.edge) +
box(262, 70, 126, 220, data.afterLabel, data.afterSub);
} else {
body +=
box(100, 18, 200, 104, data.beforeLabel, data.beforeSub, {}) +
arrowV(150, 220, 200, data.edge) +
box(100, 248, 200, 104, data.afterLabel, data.afterSub);
}
return svg(body, opts, "pane", data);
};
C.hub = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["centre", "spokes", "toward"]);
text(data.centre, "centre");
choice(data.toward, "toward", ["centre", "satellites"]);
const spokes = records(
data.spokes,
"spokes",
3,
6,
["title", "edge"],
["detail"],
);
if (opts.compact) return compactDiagram("hub", data, opts);
if (opts.narrow === true) return narrowDiagram("hub", data, opts);
// Aligned spokes preserve direction and leave every relationship label
// beside its own connector; radial labels otherwise drift onto neighbours.
const rowH = 64,
gap = 24,
height = 48 + spokes.length * rowH + (spokes.length - 1) * gap;
const centerY = height / 2;
let body = box(24, centerY - 48, 180, 96, data.centre);
body += `<path class="line" d="M204 ${centerY}H236M236 56V${24 + (spokes.length - 1) * (rowH + gap) + rowH / 2}"/>`;
spokes.forEach((spoke, i) => {
const y = 24 + i * (rowH + gap),
cy = y + rowH / 2;
body += `<g data-relationship="spoke" data-direction="${data.toward}">${arrowH(236, 622, cy, spoke.edge, data.toward === "centre")}</g>`;
body += box(630, y, 226, rowH, spoke.title, "", { detail: spoke.detail });
});
return svgMarkup(body, opts, `0 0 880 ${height}`, "", data);
};
C.compareTwo = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", [
"a",
"b",
"aDetail",
"bDetail",
"highlight",
"rows",
]);
text(data.a, "a");
text(data.b, "b");
text(data.aDetail, "aDetail", true);
text(data.bDetail, "bDetail", true);
choice(data.highlight, "highlight", ["a", "b", "none"]);
const rows = records(data.rows, "rows", 2, 4, [
"label",
"a",
"b",
"winner",
]);
rows.forEach((r, i) => {
choice(r.winner, `rows[${i}].winner`, ["a", "b", "none"]);
});
if (opts.compact) return compactDiagram("compareTwo", data, opts);
if (opts.narrow === true) return narrowDiagram("compareTwo", data, opts);
const x = [24, 216, 536, 856],
head = 58,
rowH = 68,
tableH = head + rowH * rows.length,
top = (360 - tableH) / 2,
bottom = top + tableH;
let body = "";
if (data.highlight === "a")
body += `<rect class="band" x="${x[1]}" y="${top}" width="${x[2] - x[1]}" height="${tableH}"/>`;
if (data.highlight === "b")
body += `<rect class="band" x="${x[2]}" y="${top}" width="${x[3] - x[2]}" height="${tableH}"/>`;
body += `<path class="hair" d="M${x[0]} ${top}H${x[3]}M${x[0]} ${top + head}H${x[3]}M${x[0]} ${top}V${bottom}M${x[1]} ${top}V${bottom}M${x[2]} ${top}V${bottom}M${x[3]} ${top}V${bottom}"/>${detailWrap(txt(data.a, (x[1] + x[2]) / 2, top + 34, x[2] - x[1] - 24, data.highlight === "a" ? "zone surface-text" : "zone", 2), data.aDetail, [x[1], top, x[2] - x[1], head])}${detailWrap(txt(data.b, (x[2] + x[3]) / 2, top + 34, x[3] - x[2] - 24, data.highlight === "b" ? "zone surface-text" : "zone", 2), data.bDetail, [x[2], top, x[3] - x[2], head])}`;
rows.forEach((r, i) => {
const y = top + head + i * rowH,
cy = y + rowH / 2;
body += `<path class="hair" d="M${x[0]} ${y + rowH}H${x[3]}"/>${txt(r.label, (x[0] + x[1]) / 2, cy, x[1] - x[0] - 24, "sub", 2)}${txt(r.a, (x[1] + x[2]) / 2, cy, x[2] - x[1] - 24, `${r.winner === "a" ? "zone" : "sub"}${data.highlight === "a" ? " surface-text" : ""}`, 2)}${txt(r.b, (x[2] + x[3]) / 2, cy, x[3] - x[2] - 24, `${r.winner === "b" ? "zone" : "sub"}${data.highlight === "b" ? " surface-text" : ""}`, 2)}`;
});
return svg(body, opts, "wide", data);
};
C.funnel = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["tiers", "edges", "outcome"]);
const tiers = records(
data.tiers,
"tiers",
2,
3,
["title"],
["sub", "detail"],
),
edges = strings(data.edges, "edges", tiers.length - 1);
text(data.outcome, "outcome");
if (opts.compact) return compactDiagram("funnel", data, opts);
if (opts.narrow === true) return narrowDiagram("funnel", data, opts);
const widths = tiers.length === 3 ? [320, 224, 132] : [300, 168],
ys = tiers.length === 3 ? [26, 138, 250] : [50, 190],
h = 76;
let body = "";
tiers.forEach((t, i) => {
const w = widths[i],
x = 200 - w / 2,
y = ys[i];
if (i) {
const pw = widths[i - 1],
px = 200 - pw / 2,
py = ys[i - 1];
body += `<path class="hair" d="M${px + 6} ${py + h}L${x + 6} ${y}M${px + pw - 6} ${py + h}L${x + w - 6} ${y}"/>${txt(edges[i - 1], 200, (py + h + y) / 2 + 3, w + 60, "edge", 1)}`;
}
body += box(x, y, w, h, t.title, t.sub, { detail: t.detail });
});
const lastY = ys.at(-1) + h;
body +=
arrowV(lastY, lastY + 34, 200, "") +
txt(data.outcome, 200, lastY + 57, 300, "zone", 2);
return svg(body, opts, "pane", data);
};
C.loop = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["centre", "steps"]);
text(data.centre, "centre");
const steps = records(
data.steps,
"steps",
3,
4,
["title", "edge"],
["detail"],
);
if (opts.compact) return compactDiagram("loop", data, opts);
if (opts.narrow === true) return narrowDiagram("loop", data, opts);
const cx = 200,
cy = 200,
r = 130,
ar = 84,
bw = 120,
bh = 50;
let body = txt(data.centre, cx, cy + 4, 108, "zone", 3);
steps.forEach((s, i) => {
const a = ((-90 + (i * 360) / steps.length) * Math.PI) / 180,
next = ((-90 + ((i + 1) * 360) / steps.length) * Math.PI) / 180,
a0 = a + 0.34,
a1 = next - 0.34,
x0 = cx + ar * Math.cos(a0),
y0 = cy + ar * Math.sin(a0),
x1 = cx + ar * Math.cos(a1),
y1 = cy + ar * Math.sin(a1),
endA = a1 - 0.09,
xe = cx + ar * Math.cos(endA),
ye = cy + ar * Math.sin(endA);
body += `<path class="line" d="M${x0} ${y0}A${ar} ${ar} 0 0 1 ${xe} ${ye}"/><path class="line" d="M${x1} ${y1}l${-8 * Math.cos(a1 - 0.55)} ${-8 * Math.sin(a1 - 0.55)}M${x1} ${y1}l${-8 * Math.cos(a1 + 0.55)} ${-8 * Math.sin(a1 + 0.55)}"/>${txt(s.edge, cx + (ar + 24) * Math.cos((a0 + a1) / 2), cy + (ar + 24) * Math.sin((a0 + a1) / 2), 80, "edge", 1)}${box(cx + r * Math.cos(a) - bw / 2, cy + r * Math.sin(a) - bh / 2, bw, bh, s.title, "", { detail: s.detail })}`;
});
return svg(body, opts, "pane", data);
};
C.matrix = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["xAxis", "yAxis", "quads", "markAt"]);
text(data.xAxis, "xAxis");
text(data.yAxis, "yAxis");
const quads = records(
data.quads,
"quads",
4,
4,
["title"],
["sub", "detail"],
);
if (!Number.isInteger(data.markAt) || data.markAt < 0 || data.markAt > 3)
throw new TypeError("markAt must be an index from 0 to 3");
if (opts.compact) return compactDiagram("matrix", data, opts);
if (opts.narrow === true) return narrowDiagram("matrix", data, opts);
const x = 66,
y = 24,
size = 304,
half = 152;
let body = "";
quads.forEach((q, i) => {
const qx = x + (i % 2) * half,
qy = y + Math.floor(i / 2) * half;
body += detailWrap(
`<rect class="${i === data.markAt ? "band" : "box"}" x="${qx}" y="${qy}" width="${half}" height="${half}"/>${txt(q.title, qx + half / 2, qy + half / 2 - (q.sub ? 12 : 0), half - 24, "zone", 2)}${q.sub ? txt(q.sub, qx + half / 2, qy + half / 2 + 24, half - 32, "sub", 3) : ""}`,
q.detail,
);
});
body += `<path class="line" d="M${x} ${y}V${y + size}H${x + size}"/><path class="hair" d="M${x + half} ${y}V${y + size}M${x} ${y + half}H${x + size}"/>${txt(data.xAxis, x + half, y + size + 48, 260, "zone", 1)}<g transform="translate(12 ${y + half}) rotate(-90)">${txt(data.yAxis, 0, 0, 260, "zone", 1)}</g>`;
return svg(body, opts, "pane", data);
};
C.figurePath = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["marks", "goal", "caption"]);
strings(data.marks, "marks", 3);
text(data.goal, "goal");
text(data.caption, "caption");
if (opts.compact) return compactDiagram("figurePath", data, opts);
if (opts.narrow === true) return narrowDiagram("figurePath", data, opts);
const spots = [
[118, 309],
[202, 241],
[277, 135],
];
let body = `<path class="line" d="M46 336C118 336 96 268 168 252S246 224 258 168S292 92 352 74"/><path class="line" d="M352 74V34"/><path class="inkfill" d="M352 34V56L386 45Z"/>${txt(data.goal, 348, 18, 96, "zone", 1)}`;
data.marks.forEach((m, i) => {
body += `<circle class="inkfill" cx="${spots[i][0]}" cy="${spots[i][1]}" r="5"/>${txt(m, spots[i][0] + 14, spots[i][1] + 18, 100, "edge", 1, "start")}`;
});
body += `<g transform="translate(326 82)"><circle class="line" cx="0" cy="-30" r="7.5"/><path class="line" d="M0 -22V-6M0 -16L-10 -9M0 -16L10 -9M0 -6L-8 6M0 -6L8 6"/></g>${txt(data.caption, 200, 374, 350, "sub", 2)}`;
return svg(body, opts, "pane", data);
};
C.stairSteps = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["steps", "caption"]);
const steps = records(data.steps, "steps", 3, 6, ["title"], ["sub"]);
text(data.caption, "caption", true);
const x0 = 34,
y0 = 326,
tread = 332 / steps.length,
rise = 46;
let body = "";
if (opts.compact) return compactDiagram("stairSteps", data, opts);
if (opts.narrow === true) return narrowDiagram("stairSteps", data, opts);
steps.forEach((s, i) => {
const x = x0 + i * tread,
y = y0 - i * rise;
body += `<path class="line" d="M${x} ${i ? y + rise : y}V${y}H${x + tread}"/>${txt(s.title, x + tread / 2, y - (s.sub ? 30 : 12), tread - 8, "edge", 2)}${s.sub ? txt(s.sub, x + tread / 2, y - 9, tread - 8, "sub", 1) : ""}`;
});
if (data.caption) body += txt(data.caption, 200, 374, 352, "sub", 2);
return svg(body, opts, "pane", data);
};
C.pyramid = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["blocks", "caption"]);
const blocks = records(data.blocks, "blocks", 6, 6, ["title"], ["sub"]);
text(data.caption, "caption", true);
const cells = [
{ x: 54, y: 286, w: 292, h: 56 },
{ x: 82, y: 226, w: 116, h: 56 },
{ x: 202, y: 226, w: 116, h: 56 },
{ x: 112, y: 166, w: 86, h: 56 },
{ x: 202, y: 166, w: 86, h: 56 },
{ x: 150, y: 58, w: 100, h: 104, tri: true },
];
let body = "";
if (opts.compact) return compactDiagram("pyramid", data, opts);
// Preserve the saved level structure at every width. The former narrow
// variant turned four levels into six, which changed the represented claim.
cells.forEach((c, i) => {
const cx = c.x + c.w / 2;
body += c.tri
? `<path class="band" d="M${cx} ${c.y}L${c.x + c.w} ${c.y + c.h}H${c.x}Z"/>`
: `<rect class="band" x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="3"/>`;
body += txt(
blocks[i].title,
cx,
c.tri ? c.y + 55 : c.y + (blocks[i].sub ? 22 : 32),
c.w - 10,
"edge surface-text",
c.tri ? 2 : 1,
);
if (blocks[i].sub)
body += txt(
blocks[i].sub,
cx,
c.tri ? c.y + 78 : c.y + 42,
c.w - 10,
"sub surface-text",
1,
);
});
if (data.caption) body += txt(data.caption, 200, 374, 352, "sub", 2);
return svg(body, opts, "pane", data);
};
function gearPath(cx, cy, r, teeth, phase = 0) {
const inner = r - 3.4,
outer = r + 3.4,
per = (Math.PI * 2) / teeth,
points = [];
for (let i = 0; i < teeth; i++) {
const a = phase + i * per;
[
[inner, -0.5],
[inner, -0.28],
[outer, -0.18],
[outer, 0.18],
[inner, 0.28],
[inner, 0.5],
].forEach(([rad, f]) => {
points.push([
cx + rad * Math.cos(a + per * f),
cy + rad * Math.sin(a + per * f),
]);
});
}
return (
points
.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`)
.join("") + "Z"
);
}
C.gears = (params, opts) => {
var data = params;
opts = opts || {};
known(data, "params", ["labels", "caption"]);
strings(data.labels, "labels", 3);
text(data.caption, "caption", true);
const gs = [
{ cx: 150, cy: 186, r: 33, t: 8 },
{ cx: 236, cy: 150, r: 58, t: 14 },
{ cx: 321, cy: 203, r: 41, t: 10 },
];
let body = "";
if (opts.compact) return compactDiagram("gears", data, opts);
if (opts.narrow === true) return narrowDiagram("gears", data, opts);
[1, 0, 2].forEach((i) => {
const g = gs[i],
d = gearPath(g.cx, g.cy, g.r, g.t, i * 0.22);
body += `<path class="${i === 2 ? "inkfill" : "box"}" d="${d}"/><circle class="line" cx="${g.cx}" cy="${g.cy}" r="${g.r * 0.22}"/>`;
});
const lp = [
[150, 268],
[236, 54],
[321, 280],
];
data.labels.forEach((v, i) => {
body += txt(v, lp[i][0], lp[i][1], 112, "edge", 1);
});
body += `<circle class="line" cx="92" cy="212" r="11"/><path class="line" d="M93 223Q98 252 101 280M101 280L91 316M101 280L124 316M80 319H103M118 319H136M94 238L138 192"/><circle class="inkfill" cx="138" cy="192" r="4"/>`;
if (data.caption) body += txt(data.caption, 200, 366, 352, "sub", 2);
return svg(body, opts, "pane", data);
};
/* Charts share a 360 × 200 viewBox and 12px outer padding. Quantitative
plots use x=54…346 and y=24…144; labels, axis titles, and the required
source-note line occupy the reserved margins rather than the plot. */
const CHART = Object.freeze({ left: 54, right: 346, top: 24, base: 144 });
function number(value, name, min = -Infinity, integer = false) {
if (
!Number.isFinite(value) ||
value < min ||
(integer && !Number.isInteger(value))
)
throw new TypeError(
`${name} must be ${integer ? "an integer" : "a finite number"}${min !== -Infinity ? ` at least ${min}` : ""}`,
);
return value;
}
function boolean(value, name) {
if (value != null && typeof value !== "boolean")
throw new TypeError(`${name} must be boolean when supplied`);
}
function detail(value, name) {
text(value, name, true);
}
function chartParams(data, allowed) {
known(data, "params", ["title", ...allowed]);
text(data.title, "title", true);
text(data.sourceNote, "sourceNote");
return data;
}
function chartItem(value, name, fields, required) {
known(value, name, fields);
required.forEach((key) => {
if (key === "label") text(value[key], `${name}.${key}`);
else number(value[key], `${name}.${key}`);
});
if (fields.includes("open")) boolean(value.open, `${name}.open`);
if (fields.includes("detail")) detail(value.detail, `${name}.detail`);
return value;
}
function fmt(value) {
if (Number.isInteger(value)) return String(value);
return String(Math.round(value * 100) / 100);
}
function scale(value, fromMin, fromMax, toMin, toMax) {
return (
toMin + ((value - fromMin) / (fromMax - fromMin || 1)) * (toMax - toMin)
);
}
function path(points) {
return points
.map(
(point, i) =>
`${i ? "L" : "M"}${point[0].toFixed(2)} ${point[1].toFixed(2)}`,
)
.join("");
}
function openClass(value, line = false) {
return value && value.open === true ? (line ? " open-line" : " open") : "";
}
function chartLabel(
value,
x,
y,
cls = "",
anchor = "middle",
baseline = "auto",
) {
const classes = /(^|\s)value(\s|$)/.test(cls) ? `${cls} data-label` : cls;
return `<text class="${classes}" x="${Number(x).toFixed(2)}" y="${Number(y).toFixed(2)}" text-anchor="${anchor}" dominant-baseline="${baseline}">${esc(value)}</text>`;
}
function categoryLabel(value, x, y, width, anchor = "middle") {
return txt(value, x, y, width, "muted", 2, anchor);
}
function unitTitle(title, unit) {
return unit == null ? title : `${title} (${unit})`;
}
function chartFrame(body, data, opts, baseline = "zero") {
const heading = data.title
? chartLabel(data.title, 12, 13, "chart-title", "start")
: "";
const source = chartLabel(data.sourceNote, 12, 195, "source", "start");
return svgMarkup(
`${heading}${body}${source}`,
opts,
"0 0 360 200",
"chart-viz",
data,
).replace(
" class=",
` data-kind="chart" data-baseline="${esc(baseline)}" class=`,
);
}
function axisX(title, unit, y = 177) {
return chartLabel(unitTitle(title, unit), 200, y, "axis-title");
}
function axisY(title, unit, x = 11, cy = 84) {
return `<g transform="translate(${x} ${cy}) rotate(-90)">${chartLabel(unitTitle(title, unit), 0, 0, "axis-title")}</g>`;
}
function niceTicks(min, max, count = 5) {
if (min === max) max = min + 1;
const rough = Math.abs(max - min) / Math.max(1, count - 1);
const magnitude = 10 ** Math.floor(Math.log10(rough || 1));
const normalized = rough / magnitude;
const step =
(normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) *
magnitude;
const first = Math.ceil(min / step) * step;
const values = [];
for (let value = first; value <= max + step * 1e-8; value += step) {
values.push(Math.round(value * 1e10) / 1e10);
if (values.length === 7) break;
}
if (!values.length || Math.abs(values[0] - min) > step * 0.1)
values.unshift(min);
if (values.length < 7 && Math.abs(values.at(-1) - max) > step * 0.1)
values.push(max);
return values.slice(0, 7);
}
function numericAxes(min, max, yTitle, unit, options = {}) {
const left = options.left == null ? CHART.left : options.left,
right = options.right == null ? CHART.right : options.right,
top = options.top == null ? CHART.top : options.top,
base = options.base == null ? CHART.base : options.base,
horizontal = options.horizontal === true;
let body = "";
niceTicks(min, max, options.tickCount || 5).forEach((tick) => {
if (horizontal) {
const x = scale(tick, min, max, left, right);
body += `<path class="faint" d="M${x.toFixed(2)} ${top}V${base}"/>${chartLabel(fmt(tick), x, base + 13, "muted")}`;
} else {
const y = scale(tick, min, max, base, top);
body += `<path class="faint" d="M${left} ${y.toFixed(2)}H${right}"/>${chartLabel(fmt(tick), left - 6, y + 3.5, "muted", "end")}`;
}
});
body += horizontal
? `<path class="hair" d="M${left} ${base}H${right}"/>`
: `<path class="hair" d="M${left} ${top}V${base}H${right}"/>`;
if (yTitle) body += axisY(yTitle, unit);
return body;
}
function unitRungs(x, y, width, height, value, horizontal = false) {
if (!Number.isInteger(value) || value <= 1) return "";
let body = "";
for (let i = 1; i < value; i++) {
if (horizontal) {
const xx = x + (width * i) / value;
body += `<path class="hair" d="M${xx.toFixed(2)} ${y}V${(y + height).toFixed(2)}"/>`;
} else {
const yy = y + height - (height * i) / value;
body += `<path class="hair" d="M${x} ${yy.toFixed(2)}H${(x + width).toFixed(2)}"/>`;
}
}
return body;
}
function pointData(
data,
min,
max,
fields = ["x", "y", "label", "open", "detail"],
) {
return list(data.points, "points", min, max).map((value, i) => {
chartItem(value, `points[${i}]`, fields, ["x", "y"]);
if (value.label != null) text(value.label, `points[${i}].label`, true);
return value;
});
}
function labeledData(data, key, min, max, numberKeys, fields) {
return list(data[key], key, min, max).map((value, i) => {
chartItem(value, `${key}[${i}]`, fields, ["label", ...numberKeys]);
numberKeys.forEach((name) => {
number(value[name], `${key}[${i}].${name}`, 0);
});
return value;
});
}
function wrapDetail(markup, item) {
return detailWrap(markup, item && item.detail);
}
function labelBoxes(points) {
const occupied = [];
return points.map((point, index) => {
const value = point.label || fmt(point.y),
width = Math.max(24, value.length * 6.2),
height = 13,
candidates = [
[8, -8, "start"],
[-8, -8, "end"],
[8, 14, "start"],
[-8, 14, "end"],
[0, -13, "middle"],
[0, 18, "middle"],
];
let chosen = candidates[index % candidates.length];
for (const candidate of candidates) {
const left =
point.px +
candidate[0] -
(candidate[2] === "end"
? width
: candidate[2] === "middle"
? width / 2
: 0),
top = point.py + candidate[1] - height;
const box = { left, top, right: left + width, bottom: top + height };
if (
left >= CHART.left &&
box.right <= CHART.right &&
top >= CHART.top - 10 &&
box.bottom <= CHART.base &&
!occupied.some(
(other) =>
box.left < other.right + 2 &&
box.right + 2 > other.left &&
box.top < other.bottom + 2 &&
box.bottom + 2 > other.top,
)
) {
occupied.push(box);
chosen = candidate;
break;
}
}
return chartLabel(
value,
point.px + chosen[0],
point.py + chosen[1],
"value",
chosen[2],
);
});
}
C.rungBars = (params, opts) => {
const data = chartParams(params, [
"items",
"unit",
"yAxisTitle",
"sourceNote",
]),
items = labeledData(
data,
"items",
2,
8,
["value"],
["label", "value", "open", "detail"],
);
opts = opts || {};
text(data.unit, "unit");
text(data.yAxisTitle, "yAxisTitle");
const max = Math.max(1, ...items.map((item) => item.value)),
slot = (CHART.right - CHART.left) / items.length,
width = Math.min(34, slot - 6);
let body = numericAxes(0, max, data.yAxisTitle, data.unit);
items.forEach((item, i) => {
const height = scale(item.value, 0, max, 0, CHART.base - CHART.top),
x = CHART.left + i * slot + (slot - width) / 2,
y = CHART.base - height;
body += wrapDetail(
`<rect class="fill${openClass(item)}" x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${width.toFixed(2)}" height="${height.toFixed(2)}"/>${unitRungs(x, y, width, height, item.value)}${chartLabel(fmt(item.value), x + width / 2, Math.max(20, y - 5), "value")}${categoryLabel(item.label, x + width / 2, 158, slot - 2)}`,
item,
);
});
return chartFrame(body, data, opts, "zero");
};
C.hairlineLine = (params, opts) => {
const data = chartParams(params, [
"points",
"xLabels",
"xAxisTitle",
"yAxisTitle",
"unit",
"baselineZero",
"sourceNote",
]),
points = pointData(data, 3, 30);
opts = opts || {};
strings(data.xLabels, "xLabels", points.length);
["xAxisTitle", "yAxisTitle", "unit"].forEach((key) => {
text(data[key], key);
});
choice(data.baselineZero, "baselineZero", ["yes", "no"], true);
const xmin = Math.min(...points.map((point) => point.x)),
xmax = Math.max(...points.map((point) => point.x)),
observedMin = Math.min(...points.map((point) => point.y)),
ymin =
data.baselineZero === "no" ? observedMin : Math.min(0, observedMin),
ymax = Math.max(...points.map((point) => point.y), ymin + 1),
xy = points.map((point) => ({
...point,
px: scale(point.x, xmin, xmax, CHART.left, CHART.right),
py: scale(point.y, ymin, ymax, CHART.base, CHART.top),
}));
let body = numericAxes(ymin, ymax, data.yAxisTitle, data.unit);
body += `<path class="line" d="${path(xy.map((point) => [point.px, point.py]))}"/>`;
xy.forEach((point) => {
body += wrapDetail(
`<circle class="tone-2${openClass(point)}" cx="${point.px.toFixed(2)}" cy="${point.py.toFixed(2)}" r="3"/>`,
point,
);
});
if (points.length <= 12) body += labelBoxes(xy).join("");
const stride = Math.max(1, Math.ceil(points.length / 7));
data.xLabels.forEach((value, i) => {
if (i % stride === 0 || i === points.length - 1)
body += chartLabel(value, xy[i].px, 157, "muted");
});
body += axisX(data.xAxisTitle);
return chartFrame(
body,
data,
opts,
data.baselineZero === "no" ? "observed-min" : "zero",
);
};
C.hairlineArea = (params, opts) => {
const data = chartParams(params, [
"points",
"xLabels",
"xAxisTitle",
"yAxisTitle",
"unit",
"sourceNote",
]),
points = pointData(data, 5, 60, ["x", "y", "label", "open"]);
opts = opts || {};
strings(data.xLabels, "xLabels", 2, 12);
["xAxisTitle", "yAxisTitle", "unit"].forEach((key) => {
text(data[key], key);
});
const xmin = Math.min(...points.map((point) => point.x)),
xmax = Math.max(...points.map((point) => point.x)),
ymax = Math.max(1, ...points.map((point) => point.y)),
xy = points.map((point) => ({
...point,
px: scale(point.x, xmin, xmax, CHART.left, CHART.right),
py: scale(point.y, 0, ymax, CHART.base, CHART.top),
}));
let body = numericAxes(0, ymax, data.yAxisTitle, data.unit),
area = `${path(xy.map((point) => [point.px, point.py]))}L${CHART.right} ${CHART.base}L${CHART.left} ${CHART.base}Z`;
body += `<path class="tone-1" d="${area}"/><path class="line" d="${path(xy.map((point) => [point.px, point.py]))}"/>`;
xy.forEach((point) => {
body += `<circle class="${point.open ? "open" : "tone-2"}" cx="${point.px.toFixed(2)}" cy="${point.py.toFixed(2)}" r="2.5"/>`;
});
body += labelBoxes(xy).join("");
data.xLabels.forEach((value, i) => {
body += chartLabel(
value,
scale(i, 0, data.xLabels.length - 1, CHART.left, CHART.right),
157,
"muted",
);
});
body += axisX(data.xAxisTitle);
return chartFrame(body, data, opts, "zero");
};
C.tickDonut = (params, opts) => {
const data = chartParams(params, ["parts", "unit", "total", "sourceNote"]);
opts = opts || {};
text(data.unit, "unit");
text(data.total, "total", true);
const parts = list(data.parts, "parts", 2, 6).map((value, i) => {
chartItem(
value,
`parts[${i}]`,
["label", "value", "open", "detail"],
["label", "value"],
);
number(value.value, `parts[${i}].value`, 0, true);
return value;
}),
total = parts.reduce((sum, part) => sum + part.value, 0);
if (!total || total > 120)
throw new TypeError("parts must total from 1 to 120 ticks");
const cx = 180,
cy = 83,
inner = 42,
outer = 53;
let body = "",
cursor = 0;
parts.forEach((part) => {
let marks = "";
const start = cursor;
for (let i = 0; i < part.value; i++, cursor++) {
const angle = ((cursor + 0.5) / total) * Math.PI * 2 - Math.PI / 2,
x1 = cx + Math.cos(angle) * inner,
y1 = cy + Math.sin(angle) * inner,
x2 = cx + Math.cos(angle) * outer,
y2 = cy + Math.sin(angle) * outer;
marks += `<path class="line${openClass(part, true)}" d="M${x1.toFixed(2)} ${y1.toFixed(2)}L${x2.toFixed(2)} ${y2.toFixed(2)}"/>`;
}
const mid =
((start + part.value / 2) / total) * Math.PI * 2 - Math.PI / 2,
lx = cx + Math.cos(mid) * 72,
ly = cy + Math.sin(mid) * 62,
anchor =
Math.cos(mid) > 0.2
? "start"
: Math.cos(mid) < -0.2
? "end"
: "middle";
marks += `${chartLabel(part.label, lx, ly, "muted", anchor)}${chartLabel(part.value, lx, ly + 12, "value", anchor)}`;
body += wrapDetail(marks, part);
});
body += `${chartLabel(data.total || total, cx, cy - 1, "value")}${chartLabel(data.unit, cx, cy + 14, "muted")}`;
return chartFrame(body, data, opts, "not-applicable");
};
C.tickRows = (params, opts) => {
const data = chartParams(params, [
"items",
"unit",
"xAxisTitle",
"sourceNote",
]),
items = labeledData(
data,
"items",
2,
8,
["value"],
["label", "value", "open", "detail"],
);
opts = opts || {};
text(data.unit, "unit");
text(data.xAxisTitle, "xAxisTitle");
items.forEach((item, i) => {
number(item.value, `items[${i}].value`, 0, true);
});
if (items.some((item) => item.value > 40))
throw new TypeError("items[].value must be at most 40 ticks");
const max = Math.max(1, ...items.map((item) => item.value)),
row = 116 / items.length,
tickGap = Math.min(7, 210 / max);
let body = `<path class="hair" d="M112 ${CHART.top}V${CHART.base}"/>`;
items.forEach((item, ri) => {
const y = CHART.top + row * (ri + 0.5);
let marks = categoryLabel(item.label, 104, y + 4, 88, "end");
for (let i = 0; i < item.value; i++) {
const x = 118 + i * tickGap;
marks += `<path class="line${openClass(item, true)}" d="M${x.toFixed(2)} ${(y - 5).toFixed(2)}V${(y + 6).toFixed(2)}"/>`;
}
marks += chartLabel(fmt(item.value), 346, y + 4, "value", "end");
body += wrapDetail(marks, item);
});
body += axisX(data.xAxisTitle, data.unit, 174);
return chartFrame(body, data, opts, "zero");
};
C.pairedRungs = (params, opts) => {
const data = chartParams(params, [
"aLabel",
"bLabel",
"items",
"unit",
"xAxisTitle",
"sourceNote",
]);
opts = opts || {};
["aLabel", "bLabel", "unit", "xAxisTitle"].forEach((key) => {
text(data[key], key);
});
const items = labeledData(
data,
"items",
2,
6,
["a", "b"],
["label", "a", "b", "open", "detail"],
),
max = Math.max(1, ...items.flatMap((item) => [item.a, item.b])),
row = 106 / items.length,
x0 = 96,
width = 238;
let body = `${chartLabel(data.aLabel, 220, 14, "axis-title", "end")}${chartLabel(data.bLabel, 334, 14, "axis-title", "end")}`;
body += numericAxes(0, max, "", data.unit, {
left: x0,
right: x0 + width,
top: CHART.top,
base: CHART.base,
horizontal: true,
});
items.forEach((item, i) => {
const y = CHART.top + 3 + i * row,
aw = scale(item.a, 0, max, 0, width),
bw = scale(item.b, 0, max, 0, width),
h = Math.max(5, Math.min(8, row / 2 - 2));
body += wrapDetail(
`${categoryLabel(item.label, x0 - 7, y + h + 2, 82, "end")}<rect class="tone-3${openClass(item)}" x="${x0}" y="${y.toFixed(2)}" width="${aw.toFixed(2)}" height="${h.toFixed(2)}"/>${unitRungs(x0, y, aw, h, item.a, true)}${chartLabel(fmt(item.a), x0 + aw + 4, y + h, "value", "start")}<rect class="tone-1${openClass(item)}" x="${x0}" y="${(y + h + 3).toFixed(2)}" width="${bw.toFixed(2)}" height="${h.toFixed(2)}"/>${unitRungs(x0, y + h + 3, bw, h, item.b, true)}${chartLabel(fmt(item.b), x0 + bw + 4, y + h * 2 + 3, "value", "start")}`,
item,
);
});
body += axisX(data.xAxisTitle, data.unit);
return chartFrame(body, data, opts, "zero");
};
C.stackedRungs = (params, opts) => {
const data = chartParams(params, [
"partLabels",
"items",
"unit",
"xAxisTitle",
"sourceNote",
]);
opts = opts || {};
strings(data.partLabels, "partLabels", 2, 4);
text(data.unit, "unit");
text(data.xAxisTitle, "xAxisTitle");
const items = list(data.items, "items", 2, 6).map((value, i) => {
chartItem(
value,
`items[${i}]`,
["label", "parts", "open", "detail"],
["label"],
);
list(
value.parts,
`items[${i}].parts`,
data.partLabels.length,
data.partLabels.length,
);
value.parts.forEach((part, j) => {
number(part, `items[${i}].parts[${j}]`, 0);
});
return value;
}),
totals = items.map((item) =>
item.parts.reduce((sum, value) => sum + value, 0),
),
max = Math.max(1, ...totals),
row = 108 / items.length,
x0 = 91,
plotW = 239,
showSegments = items.length * data.partLabels.length <= 12;
let body = data.partLabels
.map((label, i) =>
chartLabel(
label,
x0 + (i + 0.5) * (plotW / data.partLabels.length),
14,
`axis-title tone-name-${i + 1}`,
),
)
.join("");
body += numericAxes(0, max, "", data.unit, {
left: x0,
right: x0 + plotW,
top: CHART.top,
base: CHART.base,
horizontal: true,
});
items.forEach((item, ri) => {
const y = CHART.top + ri * row + 4,
height = Math.min(16, row - 5);
let x = x0,
marks = categoryLabel(
item.label,
x0 - 7,
y + height / 2 + 4,
77,
"end",
);
item.parts.forEach((value, pi) => {
const width = scale(value, 0, max, 0, plotW);
marks += `<rect class="tone-${pi + 1}${openClass(item)}" x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${width.toFixed(2)}" height="${height.toFixed(2)}"/>${unitRungs(x, y, width, height, value, true)}`;
if (showSegments && width >= 18) {
const label = fmt(value),
padW = label.length * 7 + 4;
marks += `<rect class="tone-${pi + 1} label-pad${openClass(item)}" x="${(x + width / 2 - padW / 2).toFixed(2)}" y="${(y + height / 2 - 6).toFixed(2)}" width="${padW}" height="12"/>`;
marks += chartLabel(
label,
x + width / 2,
y + height / 2 + 4,
"value",
);
}
x += width;
});
marks += chartLabel(
fmt(totals[ri]),
x + 4,
y + height / 2 + 4,
"value",
"start",
);
body += wrapDetail(marks, item);
});
body += axisX(data.xAxisTitle, data.unit);
return chartFrame(body, data, opts, "zero");
};
C.plumbScatter = (params, opts) => {
const data = chartParams(params, [
"points",
"xAxisTitle",
"yAxisTitle",
"xUnit",
"yUnit",
"sourceNote",
]),
points = pointData(data, 3, 20);
opts = opts || {};
["xAxisTitle", "yAxisTitle", "xUnit", "yUnit"].forEach((key) => {
text(data[key], key);
});
points.forEach((point, i) => {
text(point.label, `points[${i}].label`);
});
const xmin = Math.min(0, ...points.map((point) => point.x)),
xmax = Math.max(1, ...points.map((point) => point.x)),
ymin = Math.min(0, ...points.map((point) => point.y)),
ymax = Math.max(1, ...points.map((point) => point.y)),
xy = points.map((point) => ({
...point,
px: scale(point.x, xmin, xmax, CHART.left, CHART.right),
py: scale(point.y, ymin, ymax, CHART.base, CHART.top),
}));
let body = numericAxes(ymin, ymax, data.yAxisTitle, data.yUnit);
niceTicks(xmin, xmax, 5).forEach((tick) => {
const x = scale(tick, xmin, xmax, CHART.left, CHART.right);
body += `${chartLabel(fmt(tick), x, 157, "muted")}<path class="faint" d="M${x.toFixed(2)} ${CHART.top}V${CHART.base}"/>`;
});
xy.forEach((point) => {
body += wrapDetail(
`<path class="quiet${openClass(point, true)}" d="M${point.px.toFixed(2)} ${CHART.base}V${point.py.toFixed(2)}"/><circle class="tone-3${openClass(point)}" cx="${point.px.toFixed(2)}" cy="${point.py.toFixed(2)}" r="3.5"/>`,
point,
);
});
body += labelBoxes(xy).join("") + axisX(data.xAxisTitle, data.xUnit);
return chartFrame(body, data, opts, "zero");
};
C.rungWaterfall = (params, opts) => {
const data = chartParams(params, [
"start",
"startLabel",
"steps",
"endLabel",
"unit",
"yAxisTitle",
"sourceNote",
]);
opts = opts || {};
number(data.start, "start");
["startLabel", "endLabel", "unit", "yAxisTitle"].forEach((key) => {
text(data[key], key);
});
const steps = list(data.steps, "steps", 2, 6).map((value, i) =>
chartItem(
value,
`steps[${i}]`,
["label", "delta", "open", "detail"],
["label", "delta"],
),
),
cumulative = [data.start];
steps.forEach((step) => {
cumulative.push(cumulative.at(-1) + step.delta);
});
const ymin = Math.min(0, ...cumulative),
ymax = Math.max(1, ...cumulative),
count = steps.length + 2,
slot = (CHART.right - CHART.left) / count,
width = Math.min(28, slot - 5),
ypos = (value) => scale(value, ymin, ymax, CHART.base, CHART.top);
let body = numericAxes(ymin, ymax, data.yAxisTitle, data.unit);
const fullBar = (value, label, index, tone) => {
const x = CHART.left + index * slot + (slot - width) / 2,
y0 = ypos(0),
y1 = ypos(value),
y = Math.min(y0, y1),
height = Math.max(1, Math.abs(y1 - y0));
return `<rect class="${tone}" x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${width.toFixed(2)}" height="${height.toFixed(2)}"/>${chartLabel(fmt(value), x + width / 2, Math.max(20, y - 5), "value")}${categoryLabel(label, x + width / 2, 158, slot - 2)}`;
};
body += fullBar(data.start, data.startLabel, 0, "tone-3");
steps.forEach((step, i) => {
const before = cumulative[i],
after = cumulative[i + 1],
x = CHART.left + (i + 1) * slot + (slot - width) / 2,
yBefore = ypos(before),
yAfter = ypos(after),
y = Math.min(yBefore, yAfter),
height = Math.max(1, Math.abs(yAfter - yBefore));
body += wrapDetail(
`<rect class="tone-1${openClass(step)}" x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${width.toFixed(2)}" height="${height.toFixed(2)}"/>${unitRungs(x, y, width, height, Math.abs(step.delta))}${chartLabel(`${step.delta >= 0 ? "+" : ""}${fmt(step.delta)}`, x + width / 2, step.delta >= 0 ? Math.max(20, y - 5) : Math.min(141, y + height + 12), "value")}${categoryLabel(step.label, x + width / 2, 158, slot - 2)}`,
step,
);
if (i < steps.length - 1)
body += `<path class="faint" d="M${(x + width).toFixed(2)} ${yAfter.toFixed(2)}H${(x + slot).toFixed(2)}"/>`;
});
body += fullBar(cumulative.at(-1), data.endLabel, count - 1, "tone-3");
return chartFrame(body, data, opts, "zero");
};
C.dotHeat = (params, opts) => {
const data = chartParams(params, [
"rows",
"cols",
"values",
"rowAxisTitle",
"colAxisTitle",
"unit",
"sourceNote",
]);
opts = opts || {};
strings(data.rows, "rows", 2, 8);
strings(data.cols, "cols", 2, 12);
["rowAxisTitle", "colAxisTitle", "unit"].forEach((key) => {
text(data[key], key);
});
list(data.values, "values", data.rows.length, data.rows.length);
data.values.forEach((row, ri) => {
list(row, `values[${ri}]`, data.cols.length, data.cols.length);
row.forEach((value, ci) => {
number(value, `values[${ri}][${ci}]`, 0);
});
});
const max = Math.max(1, ...data.values.flat()),
left = 92,
top = 29,
width = 252,
height = 111,
cellW = width / data.cols.length,
cellH = height / data.rows.length;
let body = chartLabel(
`Dot area · ${data.unit}`,
344,
14,
"axis-title",
"end",
);
data.cols.forEach((value, i) => {
body += categoryLabel(value, left + (i + 0.5) * cellW, 24, cellW - 2);
});
data.rows.forEach((rowLabel, ri) => {
body += categoryLabel(
rowLabel,
left - 7,
top + (ri + 0.5) * cellH + 4,
76,
"end",
);
data.values[ri].forEach((value, ci) => {
const radius =
Math.sqrt(value / max) * Math.max(1, Math.min(8, cellH / 2 - 2)),
x = left + (ci + 0.5) * cellW,
y = top + (ri + 0.5) * cellH;
body += `<circle class="tone-2" cx="${x.toFixed(2)}" cy="${y.toFixed(2)}" r="${radius.toFixed(2)}"/>${chartLabel(value, x, y + 3.5, "value")}`;
});
});
body += `<g transform="translate(11 84) rotate(-90)">${chartLabel(data.rowAxisTitle, 0, 0, "axis-title")}</g>${chartLabel(data.colAxisTitle, 218, 158, "axis-title")}`;
return chartFrame(body, data, opts, "not-applicable");
};
C.tickGauge = (params, opts) => {
const data = chartParams(params, [
"value",
"max",
"label",
"unit",
"open",
"sourceNote",
]);
opts = opts || {};
number(data.value, "value", 0, true);
number(data.max, "max", 1, true);
if (data.max > 100) throw new TypeError("max must be at most 100 ticks");
if (data.value > data.max) throw new TypeError("value must not exceed max");
["label", "unit"].forEach((key) => {
text(data[key], key);
});
choice(data.open, "open", ["yes", "no"], true);
const cx = 180,
cy = 116,
inner = 56,
outer = 72;
let body = "";
for (let i = 0; i < data.max; i++) {
const angle = Math.PI + ((i + 0.5) / data.max) * Math.PI,
x1 = cx + Math.cos(angle) * inner,
y1 = cy + Math.sin(angle) * inner,
x2 = cx + Math.cos(angle) * outer,
y2 = cy + Math.sin(angle) * outer,
active = i < data.value,
open = active && data.open === "yes";
body += `<path class="${active ? "line" : "faint"}${open ? " open-line" : ""}" d="M${x1.toFixed(2)} ${y1.toFixed(2)}L${x2.toFixed(2)} ${y2.toFixed(2)}"/>`;
}
body += `${chartLabel(fmt(data.value), cx, 103, "value")}${chartLabel(`0`, cx - outer, 136, "muted")}${chartLabel(fmt(data.max), cx + outer, 136, "value")}${chartLabel(unitTitle(data.label, data.unit), cx, 160, "axis-title")}`;
return chartFrame(body, data, opts, "zero");
};
C.dumbbell = (params, opts) => {
const data = chartParams(params, [
"beforeLabel",
"afterLabel",
"items",
"unit",
"xAxisTitle",
"sourceNote",
]);
opts = opts || {};
["beforeLabel", "afterLabel", "unit", "xAxisTitle"].forEach((key) => {
text(data[key], key);
});
const items = labeledData(
data,
"items",
2,
6,
["before", "after"],
["label", "before", "after", "open", "detail"],
),
max = Math.max(1, ...items.flatMap((item) => [item.before, item.after])),
left = 103,
right = 336,
row = 105 / items.length;
let body = `${chartLabel(data.beforeLabel, 220, 14, "axis-title", "end")}${chartLabel(data.afterLabel, 336, 14, "axis-title", "end")}`;
body += numericAxes(0, max, "", data.unit, {
left,
right,
top: CHART.top,
base: CHART.base,
horizontal: true,
});
items.forEach((item, i) => {
const y = CHART.top + row * (i + 0.5),
before = scale(item.before, 0, max, left, right),
after = scale(item.after, 0, max, left, right);
body += wrapDetail(
`${categoryLabel(item.label, left - 8, y + 4, 88, "end")}<path class="quiet${openClass(item, true)}" d="M${before.toFixed(2)} ${y.toFixed(2)}H${after.toFixed(2)}"/><circle class="fill${openClass(item)}" cx="${before.toFixed(2)}" cy="${y.toFixed(2)}" r="4"/><circle class="tone-4${openClass(item)}" cx="${after.toFixed(2)}" cy="${y.toFixed(2)}" r="4"/>${chartLabel(fmt(item.before), before, y - 7, "value")}${chartLabel(fmt(item.after), after, y + 14, "value")}`,
item,
);
});
body += axisX(data.xAxisTitle, data.unit);
return chartFrame(body, data, opts, "zero");
};
function treemapNodes(values, name, depth = 0) {
return list(values, name, 2, depth ? 6 : 8).map((value, i) => {
chartItem(
value,
`${name}[${i}]`,
["label", "value", "children", "open", "detail"],
["label", "value"],
);
number(value.value, `${name}[${i}].value`, 0);
if (value.children != null) {
if (depth)
throw new TypeError(
`${name}[${i}].children may only be one level deep`,
);
treemapNodes(value.children, `${name}[${i}].children`, depth + 1);
}
return value;
});
}
function treemapRects(items, x, y, width, height, depth = 0) {
const total = items.reduce((sum, item) => sum + item.value, 0);
if (!total) throw new TypeError("treemap levels must total more than zero");
let cursor = depth % 2 ? y : x,
body = "";
items.forEach((item, i) => {
const ratio = item.value / total,
w = depth % 2 ? width : width * ratio,
h = depth % 2 ? height * ratio : height,
rx = depth % 2 ? x : cursor,
ry = depth % 2 ? cursor : y,
header = item.children ? Math.min(20, h * 0.3) : 0;
cursor += depth % 2 ? h : w;
body += wrapDetail(
`<rect class="tone-${(i % 4) + 1}${openClass(item)}" x="${rx.toFixed(2)}" y="${ry.toFixed(2)}" width="${w.toFixed(2)}" height="${h.toFixed(2)}"/>${categoryLabel(`${item.label} ${fmt(item.value)}`, rx + 5, ry + 14, Math.max(18, w - 10), "start")}`,
item,
);
if (item.children)
body += treemapRects(
item.children,
rx + 3,
ry + header,
Math.max(1, w - 6),
Math.max(1, h - header - 3),
depth + 1,
);
});
return body;
}
C.treemap = (params, opts) => {
const data = chartParams(params, ["items", "unit", "total", "sourceNote"]);
opts = opts || {};
text(data.unit, "unit");
text(data.total, "total", true);
const items = treemapNodes(data.items, "items"),
sum = items.reduce((total, item) => total + item.value, 0);
const body = `${chartLabel(data.total || `${fmt(sum)} ${data.unit}`, 348, 14, "value", "end")}${treemapRects(items, 14, 24, 332, 145)}`;
return chartFrame(body, data, opts, "not-applicable");
};
C.rungHistogram = (params, opts) => {
const data = chartParams(params, [
"bins",
"xAxisTitle",
"unit",
"sourceNote",
]),
bins = labeledData(
data,
"bins",
3,
12,
["count"],
["label", "count", "open", "detail"],
);
opts = opts || {};
text(data.xAxisTitle, "xAxisTitle");
text(data.unit, "unit");
bins.forEach((bin, i) => {
number(bin.count, `bins[${i}].count`, 0, true);
});
const max = Math.max(1, ...bins.map((bin) => bin.count)),
slot = (CHART.right - CHART.left) / bins.length,
width = slot - 1;
let body = numericAxes(0, max, "Count", data.unit);
bins.forEach((bin, i) => {
const height = scale(bin.count, 0, max, 0, CHART.base - CHART.top),
x = CHART.left + i * slot,
y = CHART.base - height;
body += wrapDetail(
`<rect class="tone-2${openClass(bin)}" x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${width.toFixed(2)}" height="${height.toFixed(2)}"/>${unitRungs(x, y, width, height, bin.count)}${chartLabel(bin.count, x + width / 2, Math.max(20, y - 5), "value")}${categoryLabel(bin.label, x + width / 2, 158, slot - 1)}`,
bin,
);
});
body += axisX(data.xAxisTitle, data.unit);
return chartFrame(body, data, opts, "zero");
};
C.tickBox = (params, opts) => {
const data = chartParams(params, [
"groups",
"xAxisTitle",
"unit",
"sourceNote",
]);
opts = opts || {};
text(data.xAxisTitle, "xAxisTitle");
text(data.unit, "unit");
const groups = list(data.groups, "groups", 1, 6).map((value, i) => {
chartItem(
value,
`groups[${i}]`,
[
"label",
"min",
"q1",
"median",
"q3",
"max",
"outliers",
"open",
"detail",
],
["label", "min", "q1", "median", "q3", "max"],
);
if (
!(
value.min <= value.q1 &&
value.q1 <= value.median &&
value.median <= value.q3 &&
value.q3 <= value.max
)
)
throw new TypeError(
`groups[${i}] five-number values must be ordered`,
);
if (value.outliers != null) {
list(value.outliers, `groups[${i}].outliers`, 0, 20);
value.outliers.forEach((outlier, j) => {
number(outlier, `groups[${i}].outliers[${j}]`);
});
}
return value;
}),
observed = groups.flatMap((group) => [
group.min,
group.max,
...(group.outliers || []),
]),
min = Math.min(0, ...observed),
max = Math.max(1, ...observed),
left = 91,
right = 340,
row = 108 / groups.length;
let body = numericAxes(min, max, "", data.unit, {
left,
right,
top: CHART.top,
base: CHART.base,
horizontal: true,
});
groups.forEach((group, i) => {
const y = CHART.top + row * (i + 0.5),
pos = (value) => scale(value, min, max, left, right),
boxH = Math.min(16, row - 5),
q1 = pos(group.q1),
q3 = pos(group.q3),
lineCls = `line${openClass(group, true)}`;
let marks = `${categoryLabel(group.label, left - 7, y + 4, 76, "end")}<path class="${lineCls}" d="M${pos(group.min).toFixed(2)} ${y.toFixed(2)}H${pos(group.max).toFixed(2)}M${pos(group.min).toFixed(2)} ${(y - 5).toFixed(2)}V${(y + 5).toFixed(2)}M${pos(group.max).toFixed(2)} ${(y - 5).toFixed(2)}V${(y + 5).toFixed(2)}"/><rect class="fill${openClass(group)}" x="${q1.toFixed(2)}" y="${(y - boxH / 2).toFixed(2)}" width="${Math.max(1, q3 - q1).toFixed(2)}" height="${boxH.toFixed(2)}"/><path class="${lineCls}" d="M${pos(group.median).toFixed(2)} ${(y - boxH / 2).toFixed(2)}V${(y + boxH / 2).toFixed(2)}"/>${chartLabel(fmt(group.min), pos(group.min), y - 10, "value")}${chartLabel(fmt(group.q1), pos(group.q1), y + 17, "value")}${chartLabel(fmt(group.median), pos(group.median), y - 10, "value")}${chartLabel(fmt(group.q3), pos(group.q3), y + 17, "value")}${chartLabel(fmt(group.max), pos(group.max), y - 10, "value")}`;
(group.outliers || []).forEach((outlier) => {
marks += `<circle class="tone-4${openClass(group)}" cx="${pos(outlier).toFixed(2)}" cy="${y.toFixed(2)}" r="2.5"/>${chartLabel(fmt(outlier), pos(outlier), y + 17, "value")}`;
});
body += wrapDetail(marks, group);
});
body += axisX(data.xAxisTitle, data.unit);
return chartFrame(body, data, opts, "zero");
};
C.streamRibbon = (params, opts) => {
const data = chartParams(params, [
"seriesLabels",
"xLabels",
"values",
"xAxisTitle",
"yAxisTitle",
"unit",
"sourceNote",
]);
opts = opts || {};
strings(data.seriesLabels, "seriesLabels", 2, 5);
strings(data.xLabels, "xLabels", 3, 12);
["xAxisTitle", "yAxisTitle", "unit"].forEach((key) => {
text(data[key], key);
});
list(
data.values,
"values",
data.seriesLabels.length,
data.seriesLabels.length,
);
data.values.forEach((series, si) => {
list(series, `values[${si}]`, data.xLabels.length, data.xLabels.length);
series.forEach((value, xi) => {
number(value, `values[${si}][${xi}]`, 0);
});
});
const totals = data.xLabels.map((_, xi) =>
data.values.reduce((sum, series) => sum + series[xi], 0),
),
max = Math.max(1, ...totals),
left = CHART.left,
right = 292,
x = (index) => scale(index, 0, data.xLabels.length - 1, left, right),
y = (value) => scale(value, 0, max, CHART.base, CHART.top),
cumulative = new Array(data.xLabels.length).fill(0);
let body = numericAxes(0, max, data.yAxisTitle, data.unit, { right });
data.values.forEach((series, si) => {
const bottom = cumulative.slice(),
top = series.map((value, xi) => bottom[xi] + value),
upper = top.map((value, xi) => [x(xi), y(value)]),
lower = bottom.map((value, xi) => [x(xi), y(value)]).reverse();
body += `<path class="tone-${si + 1}" d="${path([...upper, ...lower])}Z"/>`;
top.forEach((value, xi) => {
cumulative[xi] = value;
});
});
const railYs = data.seriesLabels.map(
(_, i) =>
CHART.top +
((i + 0.5) * (CHART.base - CHART.top)) / data.seriesLabels.length,
);
let atLast = 0;
data.seriesLabels.forEach((label, si) => {
const mid = atLast + data.values[si].at(-1) / 2,
fromY = y(mid);
atLast += data.values[si].at(-1);
body += `<path class="quiet" d="M${right} ${fromY.toFixed(2)}L302 ${railYs[si].toFixed(2)}"/>${chartLabel(label, 306, railYs[si] + 3.5, "axis-title", "start")}`;
});
const stride = Math.max(1, Math.ceil(data.xLabels.length / 7));
data.xLabels.forEach((label, i) => {
if (i % stride === 0 || i === data.xLabels.length - 1)
body += chartLabel(label, x(i), 157, "muted");
});
body += `${chartLabel(fmt(totals[0]), left + 4, Math.max(20, y(totals[0]) - 5), "value", "start")}${chartLabel(fmt(totals.at(-1)), right - 4, Math.max(20, y(totals.at(-1)) - 5), "value", "end")}${axisX(data.xAxisTitle)}`;
return chartFrame(body, data, opts, "zero");
};
C.rangeBars = (params, opts) => {
const data = chartParams(params, [
"items",
"unit",
"xAxisTitle",
"scaleMin",
"scaleMax",
"sourceNote",
]),
items = list(data.items, "items", 1, 8).map((value, i) => {
chartItem(
value,
`items[${i}]`,
["label", "low", "high", "mark", "markLabel", "open", "detail"],
["label", "low", "high"],
);
if (value.low > value.high)
throw new TypeError(`items[${i}].low must not exceed high`);
if (value.mark != null) number(value.mark, `items[${i}].mark`);
if (value.markLabel != null)
text(value.markLabel, `items[${i}].markLabel`, true);
return value;
});
opts = opts || {};
text(data.unit, "unit");
text(data.xAxisTitle, "xAxisTitle");
if (data.scaleMin != null) number(data.scaleMin, "scaleMin");
if (data.scaleMax != null) number(data.scaleMax, "scaleMax");
const observed = items.flatMap((item) => [
item.low,
item.high,
...(item.mark == null ? [] : [item.mark]),
]),
min = data.scaleMin == null ? Math.min(0, ...observed) : data.scaleMin,
max = data.scaleMax == null ? Math.max(1, ...observed) : data.scaleMax;
if (min >= max) throw new TypeError("scaleMin must be less than scaleMax");
if (observed.some((value) => value < min || value > max))
throw new TypeError(
"scaleMin and scaleMax must contain every item value",
);
const left = 94,
right = 338,
row = 107 / items.length,
pos = (value) => scale(value, min, max, left, right);
let body = numericAxes(min, max, "", data.unit, {
left,
right,
top: CHART.top,
base: CHART.base,
horizontal: true,
});
items.forEach((item, i) => {
const y = CHART.top + row * (i + 0.5),
low = pos(item.low),
high = pos(item.high);
let marks = `${categoryLabel(item.label, left - 7, y + 4, 80, "end")}<path class="quiet${openClass(item, true)}" d="M${low.toFixed(2)} ${y.toFixed(2)}H${high.toFixed(2)}"/><path class="line${openClass(item, true)}" d="M${low.toFixed(2)} ${(y - 5).toFixed(2)}V${(y + 5).toFixed(2)}M${high.toFixed(2)} ${(y - 5).toFixed(2)}V${(y + 5).toFixed(2)}"/>${chartLabel(fmt(item.low), low, y - 7, "value")}${chartLabel(fmt(item.high), high, y - 7, "value")}`;
if (item.mark != null) {
const mark = pos(item.mark);
marks += `<circle class="tone-4${openClass(item)}" cx="${mark.toFixed(2)}" cy="${y.toFixed(2)}" r="3.5"/>${chartLabel(item.markLabel || fmt(item.mark), mark, y + 13, "value")}`;
}
body += wrapDetail(marks, item);
});
body += axisX(data.xAxisTitle, data.unit);
return chartFrame(body, data, opts, `scale-min:${fmt(min)}`);
};
LD.registry = REGISTRY.slice();
// New work uses the reviewed core; saved files may still render all IDs.
LD.recommendedRegistry = [
"flow",
"timeline",
"hierarchy",
"zones",
"beforeAfter",
"hub",
"compareTwo",
"rungBars",
"hairlineLine",
"stackedRungs",
"rangeBars",
];
LD.components = C;
LD.render = (id, params, opts) => {
var fn = C[id];
if (!fn) throw new Error("unknown component: " + id);
return fn(params, opts || {});
};
})();
</script>
<script id="ld-runtime">
(() => {
function boot() {
window.LegalDesign = window.LegalDesign || {};
const LD = window.LegalDesign;
const root = document.documentElement;
const stateNode = document.getElementById("legaldesign-state");
if (!stateNode) return;
const TEXT_TOKENS = ["ink", "muted", "faint", "red", "red-strong"];
const FILL_TOKENS = ["card", "card-strong", "tint", "bg", "none"];
// Template export replaces matter-specific copy, but these values describe
// how a component is encoded rather than what the matter says. Preserve
// them so the sanitized template remains both intentional and renderable.
const TEMPLATE_STRUCTURAL_PARAM_KEYS = new Set([
"accentRole",
"baselineZero",
"highlight",
"markAt",
"nowAt",
"open",
"orientation",
"toward",
"winner",
]);
const TEMPLATE_HEAD_STYLE_BASELINES = Array.from(
document.head ? document.head.querySelectorAll("style") : [],
).map((style, index) => ({
id: style.id || null,
index: index,
attributes: Array.from(style.attributes).map((attribute) => [
attribute.name,
attribute.value,
]),
text: style.textContent,
}));
const TEMPLATE_CSS_TOKENS = new Set([
"bg",
"card",
"card-strong",
"faint",
"ink",
"insert",
"label",
"line",
"line-strong",
"muted",
"red",
"red-strong",
"red-wash",
"scrim",
"shadow",
"shadow-soft",
"tint",
]);
const PLACEHOLDERS = {
title: "[The claim in one sentence, ending with a period.]",
cardTitle: "[The open item in five words.]",
cardLine: "[One fact about it, under twenty words.]",
figureLabel: "[Stage name.]",
};
const RELATIONSHIPS = [
"sequence",
"time",
"hierarchy",
"containment",
"comparison",
"convergence",
"feedback",
"priority",
"progression",
"interaction",
"part-of-whole",
"distribution",
"correlation",
"change",
"quantity",
];
const VARIANT_AXES = [
"form",
"framing",
"granularity",
"emphasis",
"layout",
"register",
"single",
];
const LEGACY_LAYOUTS = ["cards", "zones", "agenda", "track"];
const VARIANT_FIELDS = new Set([
"agreed_line",
"axis",
"cards",
"component",
"encoding",
"html",
"items",
"layout",
"params",
"rows",
"segments",
"sub",
"text",
"title",
"why",
]);
const LEGACY_COLLECTION_FIELDS = {
cards: new Set(["action", "detail", "line", "tag", "title"]),
items: new Set(["line", "n", "title"]),
segments: new Set(["detail", "label", "line", "name", "state"]),
};
const UNIT_KINDS = [
"text",
"card",
"figure",
"table",
"evidence",
"decision",
];
const UNIT_ROLES = [
"title",
"answer",
"summary",
"action",
"scope",
"finding",
"expected",
"matter",
];
let selection = [];
const undo = [];
let redo = [];
let editing = false;
let gesture = null;
let textUndoArmed = false;
let returnFocus = null;
let returnFocusKey = null;
const popupTrail = [];
let fileHandle = null;
let suppressEditorClick = false;
const EDITOR_OBJECTS =
'[data-editable],[data-diagram-object],[data-editor-object],.ld-card,.sb-card,section.ld-unit[data-kind="card"],.ld-diagram svg g[role="button"]';
const popupFallbackNodes = new WeakSet();
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const MIN_FIGURE_TEXT_PX = 11;
const FIGURE_TEXT_ROUNDING_PX = 0.01;
const WIDE_FRAME_WIDTH = 880;
const WIDE_MIN_TEXT_PX = 12.25;
function announceReady() {
root.setAttribute("data-legaldesign-ready", "true");
window.dispatchEvent(new CustomEvent("legaldesign:ready"));
}
function scheduleReady(afterPageListeners) {
root.removeAttribute("data-legaldesign-ready");
if (!afterPageListeners) {
queueMicrotask(announceReady);
return;
}
requestAnimationFrame(() => {
requestAnimationFrame(announceReady);
});
}
function safeJSON(value) {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function same(a, b) {
return safeJSON(a) === safeJSON(b);
}
function all(selector, scope) {
return Array.prototype.slice.call(
(scope || document).querySelectorAll(selector),
);
}
function removeLegacyReviewChrome(scope) {
all(
".ld-review,.ld-sheet-open,.ld-bottom-save,#ld-phone-sheet,#ld-selection-popover",
scope || document,
).forEach((node) => {
node.remove();
});
}
function visible(node) {
const box = node.getBoundingClientRect();
return (
!node.hidden &&
box.width > 0 &&
box.height > 0 &&
getComputedStyle(node).visibility !== "hidden"
);
}
function sanitizeEditorTokens(scope) {
all("[data-fill-token]", scope || document).forEach((node) => {
if (!FILL_TOKENS.includes(node.getAttribute("data-fill-token")))
node.removeAttribute("data-fill-token");
});
all("[data-text-token]", scope || document).forEach((node) => {
if (!TEXT_TOKENS.includes(node.getAttribute("data-text-token")))
node.removeAttribute("data-text-token");
});
}
function restoreTrustedTemplateStyles(cloneRoot) {
all("style", cloneRoot).forEach((style) => {
if (!style.closest("head")) style.remove();
});
const head = cloneRoot.querySelector("head");
if (!head) return;
const candidates = all("style", head);
const claimed = new Set();
TEMPLATE_HEAD_STYLE_BASELINES.forEach((baseline) => {
let style = baseline.id
? candidates.find(
(candidate) =>
!claimed.has(candidate) && candidate.id === baseline.id,
)
: candidates[baseline.index];
if (style && claimed.has(style)) style = null;
if (!style)
style = candidates.find((candidate) => !claimed.has(candidate));
if (!style) {
style = document.createElement("style");
head.appendChild(style);
}
Array.from(style.attributes).forEach((attribute) => {
style.removeAttribute(attribute.name);
});
baseline.attributes.forEach(([name, value]) => {
style.setAttribute(name, value);
});
style.textContent = baseline.text;
claimed.add(style);
});
candidates.forEach((style) => {
if (!claimed.has(style)) style.remove();
});
}
function safeTemplateMeasurement(value, allowKeywords) {
const keywords = allowKeywords || [];
return String(value)
.trim()
.split(/\s+/)
.every(
(part) =>
keywords.includes(part) ||
/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|%|em|rem|vh|vw)?$/.test(part),
);
}
function safeTemplateTransform(value) {
value = String(value).trim();
if (value === "none") return true;
const number = "-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:px|%|deg)?";
const args = `${number}(?:\\s*(?:,|\\s)\\s*${number}){0,15}`;
const transform = new RegExp(
`(?:matrix3d|matrix|translate3d|translate|translateX|translateY|scale3d|scale|scaleX|scaleY|rotate)\\(\\s*${args}\\s*\\)`,
"gi",
);
return value.replace(transform, "").trim() === "";
}
function safeTemplateColor(value) {
value = String(value).trim();
if (["currentcolor", "inherit", "none", "transparent"].includes(value))
return true;
const match = /^var\(--([a-z0-9-]+)\)$/.exec(value);
return Boolean(match && TEMPLATE_CSS_TOKENS.has(match[1]));
}
function safeTemplateStyleValue(property, value) {
// Composed pages encode placement with numeric custom properties. These
// are structural coordinates, not matter content; stripping them collapses
// every unit into a single grid column in an exported template.
if (
["--ld-row", "--ld-column", "--ld-span", "--ld-row-span"].includes(
property,
)
)
return /^[1-9]\d{0,3}$/.test(String(value).trim());
const measurements = new Set([
"bottom",
"border-radius",
"column-gap",
"font-size",
"gap",
"height",
"left",
"max-height",
"max-width",
"min-height",
"min-width",
"right",
"row-gap",
"top",
"width",
]);
const boxMeasurements =
/^(?:margin|padding)(?:-(?:top|right|bottom|left))?$/;
if (measurements.has(property))
return safeTemplateMeasurement(value, ["auto", "none"]);
if (boxMeasurements.test(property))
return safeTemplateMeasurement(value, ["auto"]);
if (
["background", "background-color", "border-color", "color"].includes(
property,
)
)
return safeTemplateColor(value);
if (property === "transform") return safeTemplateTransform(value);
if (property === "transform-origin")
return safeTemplateMeasurement(value, [
"bottom",
"center",
"left",
"right",
"top",
]);
if (property === "position")
return ["absolute", "fixed", "relative", "static", "sticky"].includes(
value,
);
if (property === "display")
return [
"block",
"flex",
"grid",
"inline",
"inline-block",
"inline-flex",
"none",
].includes(value);
if (property === "justify-content")
return [
"center",
"end",
"flex-end",
"flex-start",
"space-around",
"space-between",
"space-evenly",
"start",
].includes(value);
if (property === "text-align")
return ["center", "end", "left", "right", "start"].includes(value);
if (property === "box-sizing")
return ["border-box", "content-box"].includes(value);
if (property === "transform-box")
return ["border-box", "content-box", "fill-box", "view-box"].includes(
value,
);
if (property === "font-style")
return ["italic", "normal", "oblique"].includes(value);
if (property === "font-weight")
return /^(?:normal|bold|[1-9]00)$/.test(value);
if (property === "text-decoration-line")
return /^(?:none|underline|overline|line-through)(?:\s+(?:underline|overline|line-through))*$/.test(
value,
);
if (property === "line-height")
return value === "normal" || safeTemplateMeasurement(value);
if (property === "z-index") return /^-?\d+$/.test(value);
return false;
}
function scrubTemplateMatterAttributes(cloneRoot) {
const idReferences = new Set([
"aria-controls",
"aria-describedby",
"aria-labelledby",
]);
const stateValues = {
"aria-current": new Set([
"date",
"false",
"location",
"page",
"step",
"time",
"true",
]),
"aria-disabled": new Set(["false", "true"]),
"aria-expanded": new Set(["false", "true"]),
"aria-haspopup": new Set([
"dialog",
"false",
"grid",
"listbox",
"menu",
"tree",
"true",
]),
"aria-hidden": new Set(["false", "true"]),
"aria-live": new Set(["assertive", "off", "polite"]),
"aria-modal": new Set(["false", "true"]),
"aria-pressed": new Set(["false", "mixed", "true"]),
};
const ids = new Set(
all("[id]", cloneRoot)
.map((node) => node.id)
.filter(Boolean),
);
all("*", cloneRoot).forEach((node) => {
if (node.localName === "style" || node.localName === "script") return;
if (node.hasAttribute("style")) {
const kept = Array.from(node.style)
.map((property) => ({
property: property,
value: node.style.getPropertyValue(property).trim(),
priority: node.style.getPropertyPriority(property),
}))
.filter((item) =>
safeTemplateStyleValue(item.property, item.value),
);
node.removeAttribute("style");
kept.forEach((item) => {
node.style.setProperty(item.property, item.value, item.priority);
});
}
if (node.hasAttribute("value")) {
const value = node.getAttribute("value");
const safeDecision =
node.hasAttribute("data-decision-option") &&
/^option-\d+$/.test(value);
const safeFill =
node.matches("#editor-fill-token option") &&
[""].concat(FILL_TOKENS).includes(value);
const safeText =
node.matches("#editor-text-token option") &&
[""].concat(TEXT_TOKENS).includes(value);
if (!safeDecision && !safeFill && !safeText)
node.removeAttribute("value");
}
if (node.hasAttribute("placeholder")) {
if (node.hasAttribute("data-decision-custom"))
node.setAttribute("placeholder", "Custom position");
else if (node.hasAttribute("data-decision-note"))
node.setAttribute("placeholder", "Note");
else if (node.matches("input,textarea"))
node.setAttribute("placeholder", "[Editable value.]");
else node.removeAttribute("placeholder");
}
Array.from(node.attributes).forEach((attribute) => {
const name = attribute.name.toLowerCase();
if (!name.startsWith("aria-")) return;
if (name === "aria-label" || name === "aria-description") {
if (name === "aria-description")
node.setAttribute(name, "[Accessible description.]");
return;
}
if (idReferences.has(name)) {
const references = attribute.value
.split(/\s+/)
.filter((id) => id && ids.has(id));
if (references.length)
node.setAttribute(name, references.join(" "));
else node.removeAttribute(name);
return;
}
if (stateValues[name] && stateValues[name].has(attribute.value))
return;
node.removeAttribute(name);
});
});
}
function normalizeChrome() {
["mode-toggle", "export-html", "export-template"].forEach((id) => {
const control = document.getElementById(id);
if (control) control.setAttribute("data-editor-only", "");
});
const controls = document.querySelector(".ld-controls");
const editorTools = document.querySelector(".ld-editor-tools");
if (controls) controls.classList.add("ld-pills");
if (controls && editorTools && editorTools.parentElement !== controls) {
const theme = document.getElementById("theme-toggle");
controls.insertBefore(editorTools, theme || controls.firstChild);
}
[
["editor-font-down", "A−", "Decrease type"],
["editor-font-up", "A+", "Increase type"],
["editor-bold", "B", "Bold"],
["editor-italic", "I", "Italic"],
["editor-underline", "U", "Underline"],
].forEach((entry) => {
const button = document.getElementById(entry[0]);
if (!button) return;
button.textContent = entry[1];
button.setAttribute("aria-label", entry[2]);
button.title = entry[2];
});
const theme = document.getElementById("theme-toggle");
if (theme) {
theme.setAttribute("aria-label", "Theme");
theme.title = "Theme";
theme.innerHTML =
'<svg class="ld-theme-icon ld-theme-moon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"></path></svg><svg class="ld-theme-icon ld-theme-sun" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path></svg>';
}
const overflow = document.getElementById("editor-overflow-toggle");
if (overflow) {
overflow.textContent = "More";
overflow.setAttribute("aria-label", "More");
}
[
["editor-fill-token", "Fill", FILL_TOKENS],
["editor-text-token", "Text", TEXT_TOKENS],
].forEach((entry) => {
const select = document.getElementById(entry[0]);
if (!select) return;
const value = select.value;
select.replaceChildren();
[""].concat(entry[2]).forEach((token) => {
const option = document.createElement("option");
option.value = token;
option.textContent = token || entry[1];
select.appendChild(option);
});
if ([""].concat(entry[2]).includes(value)) select.value = value;
});
sanitizeEditorTokens(document);
installAuthoringControls();
}
function rememberReturnFocus(source) {
const node = source || document.activeElement;
const unit = node && node.closest("[data-unit]");
returnFocus = node;
returnFocusKey = node
? {
id: node.id || null,
detail: node.getAttribute("data-detail"),
evidence: node.getAttribute("data-evidence"),
card: node.matches(".ld-card[data-evidence]"),
unit: unit && unit.getAttribute("data-unit"),
}
: null;
}
function restoreReturnFocus() {
let target = returnFocus;
const key = returnFocusKey;
if (target && !target.isConnected && key) {
if (key.id) target = document.getElementById(key.id);
if ((!target || !target.isConnected) && key.detail)
target = all("[data-detail]").find(
(node) =>
node.getAttribute("data-detail") === key.detail &&
!node.closest("[hidden]"),
);
if ((!target || !target.isConnected) && key.evidence)
target = all(
key.card ? ".ld-card[data-evidence]" : "[data-evidence]",
).find(
(node) =>
node.getAttribute("data-evidence") === key.evidence &&
!node.closest("[hidden]"),
);
}
if (target && target.isConnected) target.focus();
returnFocus = null;
returnFocusKey = null;
}
function parseDate(value) {
const time = Date.parse(value || "");
return Number.isFinite(time) ? time : 0;
}
let blockState;
try {
blockState = JSON.parse(stateNode.textContent);
if (blockState.schema !== "legaldesign.state.v1")
throw new TypeError("unsupported legaldesign state schema");
const roundTrip = JSON.parse(safeJSON(blockState));
if (!same(blockState, roundTrip))
throw new Error("legaldesign state round-trip mismatch");
} catch (error) {
console.error(
"LegalDesign refused to render stale or invalid state.",
error,
);
root.setAttribute("data-legaldesign-error", "state");
return;
}
function stableLocationHash(value) {
let hash = 2166136261;
value = String(value || "");
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
function stableTemplateLocation() {
try {
const url = new URL(window.location.href);
url.search = "";
url.hash = "";
return url.href;
} catch (_) {
return String(window.location.href || "").split(/[?#]/)[0];
}
}
let state = blockState;
const exportMode = root.getAttribute("data-exported");
if (exportMode === "client") {
const blueprintId =
root.getAttribute("data-client-blueprint-id") || state.artifactId;
const instanceId =
blueprintId +
"-client-copy-" +
stableLocationHash(stableTemplateLocation());
state.artifactId = instanceId;
root.setAttribute("data-client-blueprint-id", blueprintId);
root.setAttribute("data-client-id", instanceId);
stateNode.textContent = safeJSON(state);
}
if (
exportMode === "template" &&
["exported", "packaged"].includes(root.getAttribute("data-template-kind"))
) {
const blueprintId =
root.getAttribute("data-template-blueprint-id") || state.artifactId;
const instanceId =
blueprintId + "-copy-" + stableLocationHash(stableTemplateLocation());
state.artifactId = instanceId;
root.setAttribute("data-template-blueprint-id", blueprintId);
root.setAttribute("data-template-id", instanceId);
stateNode.textContent = safeJSON(state);
}
let storageKey = "legaldesign:" + state.artifactId + ":v1";
if (exportMode)
storageKey = "legaldesign:" + state.artifactId + ":" + exportMode + ":v1";
const domStorageKey = storageKey + ":dom";
function writeBlock() {
stateNode.textContent = safeJSON(state);
}
function mirror() {
writeBlock();
try {
localStorage.setItem(storageKey, safeJSON(state));
} catch (_) {
/* local files may deny storage */
}
}
function editorRoots() {
return [
document.querySelector("main"),
document.getElementById("popup-scrim"),
].filter(Boolean);
}
function cleanSnapshotHTML(node) {
const copy = node.cloneNode(true);
all("[contenteditable]", copy).forEach((editable) => {
editable.removeAttribute("contenteditable");
editable.removeAttribute("spellcheck");
});
all(".ld-selected,.ld-selection-mark", copy).forEach((selected) => {
selected.classList.remove("ld-selected", "ld-selection-mark");
});
return copy.innerHTML;
}
function editorDOMSnapshot() {
return {
schema: "legaldesign.editor-dom.v1",
artifactId: state.artifactId,
savedAt: state.savedAt,
roots: editorRoots().map((node, index) => ({
id: node.id || null,
index: index,
html: cleanSnapshotHTML(node),
})),
};
}
function storeEditorDOM() {
if (root.getAttribute("data-exported") === "client") return;
try {
localStorage.setItem(domStorageKey, safeJSON(editorDOMSnapshot()));
} catch (_) {
/* local files may deny storage */
}
}
function readEditorDOM() {
try {
const saved = JSON.parse(localStorage.getItem(domStorageKey) || "null");
if (
saved &&
saved.schema === "legaldesign.editor-dom.v1" &&
saved.artifactId === state.artifactId &&
Array.isArray(saved.roots)
)
return saved;
} catch (_) {
/* malformed recovery data is ignored */
}
return null;
}
function applyEditorDOM(saved) {
if (!saved || parseDate(saved.savedAt) > parseDate(state.savedAt)) return;
(saved.roots || []).forEach((item) => {
const current = item.id
? document.getElementById(item.id)
: editorRoots()[item.index];
if (current && typeof item.html === "string")
current.innerHTML = item.html;
});
sanitizeEditorTokens(document);
}
function captureSnapshot() {
syncEditorDOMToState();
return {
state: clone(state),
roots: editorRoots().map((node) => ({
id: node.id || null,
tag: node.tagName,
html: cleanSnapshotHTML(node),
})),
};
}
function checkpoint() {
undo.push(captureSnapshot());
if (undo.length > 100) undo.shift();
redo = [];
updateEditorButtons();
}
function change(fn, options) {
if (!options || options.history !== false) checkpoint();
fn(state);
state.savedAt = new Date().toISOString();
mirror();
renderAll();
storeEditorDOM();
}
function restoreSnapshot(next) {
state = clone(next.state);
(next.roots || []).forEach((saved, index) => {
const current = saved.id
? document.getElementById(saved.id)
: editorRoots()[index];
if (current) current.innerHTML = saved.html;
});
sanitizeEditorTokens(document);
selection = [];
gesture = null;
textUndoArmed = false;
mirror();
applyTheme((state.review && state.review.theme) || initialTheme(), false);
bindApproaches();
bindUnits();
bindDecisions();
bindPopupDOM();
installComposedForm();
stampEditorOrigins();
renderSelection();
storeEditorDOM();
}
function doUndo() {
if (!undo.length) return;
redo.push(captureSnapshot());
restoreSnapshot(undo.pop());
updateEditorButtons();
}
function doRedo() {
if (!redo.length) return;
undo.push(captureSnapshot());
restoreSnapshot(redo.pop());
updateEditorButtons();
}
function initialTheme() {
if (
state.review &&
(state.review.theme === "light" || state.review.theme === "dark")
)
return state.review.theme;
return window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function applyTheme(theme, persist) {
const next = theme === "dark" ? "dark" : "light";
root.setAttribute("data-theme", next);
if (!state.review) state.review = {};
state.review.theme = next;
if (persist) {
state.savedAt = new Date().toISOString();
mirror();
}
}
function installRestoreBanner() {
let stored = null;
try {
stored = JSON.parse(localStorage.getItem(storageKey) || "null");
} catch (_) {
stored = null;
}
if (root.getAttribute("data-exported") === "client") {
if (
stored &&
stored.schema === state.schema &&
parseDate(stored.savedAt) > parseDate(state.savedAt) &&
!same(stored, state)
) {
state = stored;
mirror();
renderAll({ sync: false });
}
return;
}
if (
!stored ||
stored.schema !== state.schema ||
parseDate(stored.savedAt) <= parseDate(state.savedAt) ||
same(stored, state)
)
return;
const banner = document.createElement("div");
banner.className = "ld-restore";
banner.id = "ld-restore-banner";
banner.innerHTML =
'<span>Restore unsaved changes?</span><button type="button" data-restore>Restore</button><button type="button" data-discard>Discard</button>';
const bar = document.querySelector(".ld-bar");
(bar ? bar.parentNode : document.body).insertBefore(
banner,
bar ? bar.nextSibling : document.body.firstChild,
);
banner.querySelector("[data-restore]").addEventListener("click", () => {
state = stored;
applyEditorDOM(readEditorDOM());
mirror();
banner.remove();
renderAll({ sync: false });
bindApproaches();
bindUnits();
bindDecisions();
bindPopupDOM();
installComposedForm();
stampEditorOrigins();
storeEditorDOM();
});
banner.querySelector("[data-discard]").addEventListener("click", () => {
try {
localStorage.removeItem(storageKey);
localStorage.removeItem(domStorageKey);
} catch (_) {}
banner.remove();
mirror();
});
}
function unitState(id) {
return state.units && state.units[id];
}
function selectedVariant(unit) {
return unit && unit.variants ? unit.variants[unit.selected] : null;
}
function setVariant(id, variant) {
const unit = unitState(id);
if (!unit || !unit.variants || !unit.variants[variant]) return;
change(() => {
unit.selected = variant;
});
}
function hasPageApproaches() {
return Boolean(
state.approaches &&
state.approaches.a &&
state.approaches.b &&
document.querySelector("[data-approach='a']") &&
document.querySelector("[data-approach='b']"),
);
}
function currentApproach() {
return state.review && state.review.approach === "b" ? "b" : "a";
}
function renderApproach() {
if (!hasPageApproaches()) return;
const selected = currentApproach();
root.setAttribute("data-page-approach", selected);
all("[data-approach]").forEach((page) => {
page.hidden = page.getAttribute("data-approach") !== selected;
});
all("[data-select-approach]").forEach((button) => {
button.setAttribute(
"aria-pressed",
button.getAttribute("data-select-approach") === selected
? "true"
: "false",
);
});
}
function setApproach(approach) {
if (!hasPageApproaches() || !["a", "b"].includes(approach)) return;
if (!state.review) state.review = {};
if (state.review.approach === approach) return;
syncEditorDOMToState();
state.review.approach = approach;
state.review.location = 0;
state.savedAt = new Date().toISOString();
const nav = document.getElementById("ld-form-navigation");
if (nav) nav.remove();
renderAll({ sync: false });
installComposedForm();
mirror();
storeEditorDOM();
}
function bindApproaches() {
const control = document.getElementById("ld-page-approach");
if (!control || control.__legalDesignApproachBound) return;
control.__legalDesignApproachBound = true;
control.addEventListener("click", (event) => {
const button = event.target.closest("[data-select-approach]");
if (!button) return;
setApproach(button.getAttribute("data-select-approach"));
});
control.addEventListener("keydown", (event) => {
if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
event.preventDefault();
setApproach(event.key === "ArrowLeft" ? "a" : "b");
const selected = control.querySelector(
'[data-select-approach="' + currentApproach() + '"]',
);
if (selected) selected.focus();
});
}
function pointerPart(value) {
return String(value).replace(/~/g, "~0").replace(/\//g, "~1");
}
function pointerValue(rootValue, pointer, nextValue) {
if (!rootValue || !pointer || pointer[0] !== "/") return false;
const parts = pointer
.slice(1)
.split("/")
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
let current = rootValue;
for (let index = 0; index < parts.length - 1; index += 1) {
if (current == null || typeof current !== "object") return false;
current = current[parts[index]];
}
if (current == null || typeof current !== "object") return false;
current[parts[parts.length - 1]] = nextValue;
return true;
}
function diagramLeaves(value, path, output) {
const leaves = output || [];
const pointer = path || "";
if (typeof value === "string" || typeof value === "number") {
leaves.push({
path: pointer,
value: String(value).replace(/\s+/g, " ").trim(),
used: false,
});
return leaves;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
diagramLeaves(item, pointer + "/" + index, leaves);
});
return leaves;
}
if (value && typeof value === "object") {
Object.keys(value).forEach((key) => {
diagramLeaves(value[key], pointer + "/" + pointerPart(key), leaves);
});
}
return leaves;
}
function diagramLabelValue(label) {
const lines = all("tspan", label);
return (
lines.length
? lines.map((line) => line.textContent.trim()).join(" ")
: label.textContent
)
.replace(/\s+/g, " ")
.trim();
}
function annotateDiagramLabels(scope, params) {
const leaves = diagramLeaves(params || {});
all(".ld-diagram svg text", scope || document).forEach((label) => {
label.setAttribute("data-diagram-label", "");
label.setAttribute("data-editable", "");
if (editing) {
label.setAttribute("tabindex", "0");
label.setAttribute("role", "textbox");
label.setAttribute("aria-label", "Editable diagram label");
} else {
label.removeAttribute("tabindex");
label.removeAttribute("role");
label.removeAttribute("aria-label");
}
if (!label.hasAttribute("data-placeholder")) {
label.setAttribute(
"data-placeholder",
diagramLabelValue(label) || PLACEHOLDERS.figureLabel,
);
}
if (!label.hasAttribute("data-param-path")) {
const value = diagramLabelValue(label);
const match = leaves.find(
(leaf) => !leaf.used && leaf.value === value,
);
if (match) {
match.used = true;
label.setAttribute("data-param-path", match.path);
}
}
});
}
function renderFigure(section, unit) {
all(".ld-variant", section).forEach((variant) => {
const id = variant.getAttribute("data-variant");
const spec = unit.variants && unit.variants[id];
const host = variant.querySelector(".ld-diagram");
if (!host || !spec || !spec.component) return;
if (
(host.hasAttribute("data-editor-preserve-dom") ||
root.getAttribute("data-exported") === "client") &&
host.querySelector("svg")
) {
annotateDiagramLabels(host, spec.params);
return;
}
// Page scaling is presentation only. Keep the authored diagram in its
// desktop geometry when the complete 4:3 page is fitted on a phone.
const hostWidth = host.closest(".ld-fixed-page")
? host.clientWidth
: host.getBoundingClientRect().width;
const narrow =
hostWidth <
(WIDE_FRAME_WIDTH * MIN_FIGURE_TEXT_PX) / WIDE_MIN_TEXT_PX;
host.innerHTML = LD.render(spec.component, spec.params, {
narrow: narrow,
density: singleComposition() ? "compact" : "standard",
ariaLabel: unit.claim,
});
const figure = host.querySelector("svg");
if (!figure) return;
annotateDiagramLabels(host, spec.params);
figure.setAttribute("data-frame", narrow ? "narrow" : "wide");
if (narrow && hostWidth > 480 && !host.closest(".ld-fixed-page")) {
const sectionBox = section.getBoundingClientRect();
const hostBox = host.getBoundingClientRect();
const source = section.querySelector(".ld-source-note");
let trailing = 0;
if (source) {
const style = getComputedStyle(source);
trailing =
source.getBoundingClientRect().height +
(parseFloat(style.marginTop) || 0) +
(parseFloat(style.marginBottom) || 0);
}
const sectionStyle = getComputedStyle(section);
const available =
sectionBox.bottom -
hostBox.top -
trailing -
(parseFloat(sectionStyle.paddingBottom) || 0) -
(parseFloat(sectionStyle.borderBottomWidth) || 0);
const viewHeight = figure.viewBox.baseVal.height;
const minimum =
(viewHeight * (MIN_FIGURE_TEXT_PX + FIGURE_TEXT_ROUNDING_PX)) /
WIDE_MIN_TEXT_PX;
figure.style.height = Math.max(available, minimum) + "px";
figure.style.maxHeight = "none";
}
});
}
function installInlineDetail(section, unit) {
// A prose block is not one giant hyperlink. Move its authored target to
// a short phrase, or a named detail link for older specifications. Added
// editor text keeps its existing whole-object popup behavior.
if (
unit.kind !== "text" ||
!section.matches(".ld-composed-unit[data-detail]")
)
return;
const detail = section.getAttribute("data-detail");
all(".ld-variant > [data-variant-body]", section).forEach((body) => {
const button = document.createElement("button");
button.type = "button";
button.className = "ld-inline-detail";
button.setAttribute("data-detail", detail);
button.setAttribute("aria-haspopup", "dialog");
button.setAttribute("aria-controls", detail);
const anchor = unit.detailAnchor;
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
for (
let node = walker.nextNode();
anchor && node;
node = walker.nextNode()
) {
const at = node.textContent.indexOf(anchor);
if (at < 0 || node.parentElement.closest("a,button")) continue;
const match = node.splitText(at);
match.splitText(anchor.length);
button.textContent = anchor;
match.replaceWith(button);
break;
}
if (!button.parentNode) {
const title = state.evidence?.[detail]?.popup?.title;
button.textContent =
title && title.length <= 72 ? title : "Read supporting detail";
const line = document.createElement("p");
line.className = "ld-detail-linkline";
line.appendChild(button);
body.appendChild(line);
}
});
[
"data-detail",
"role",
"tabindex",
"aria-haspopup",
"aria-controls",
].forEach((name) => {
section.removeAttribute(name);
});
section.classList.remove("ld-popup-trigger");
}
function renderUnit(section) {
const id = section.getAttribute("data-unit");
const unit = unitState(id);
if (!unit) return;
all(".ld-variant", section).forEach((variant) => {
const key = variant.getAttribute("data-variant");
variant.hidden = key !== unit.selected;
if (
unit.edits &&
unit.edits[key] != null &&
unit.kind !== "figure" &&
root.getAttribute("data-exported") !== "client"
) {
const body = variant.querySelector("[data-variant-body]");
if (body && body.innerHTML !== unit.edits[key])
body.innerHTML = unit.edits[key];
}
});
all(".ld-ab button", section).forEach((button) => {
button.setAttribute(
"aria-pressed",
button.getAttribute("data-select-variant") === unit.selected
? "true"
: "false",
);
});
const why = section.querySelector(".ld-why");
if (why) {
const current = selectedVariant(unit);
why.textContent = current && current.why ? current.why : "";
}
if (unit.kind === "figure") renderFigure(section, unit);
installInlineDetail(section, unit);
}
function renderAll(options) {
if (editing && (!options || options.sync !== false))
syncEditorDOMToState();
renderApproach();
all("section.ld-unit[data-unit]").forEach(renderUnit);
renderDecisions();
applyTheme((state.review && state.review.theme) || initialTheme(), false);
updateComposedForm();
}
function composedSections() {
if (!root.hasAttribute("data-composed-shell")) return [];
const selected = document.querySelector(
'#legaldesign-composed-root > [data-approach="' +
currentApproach() +
'"]',
);
if (selected)
return all(
":scope > .ld-composed-section[data-composition-section]",
selected,
);
return all(
"#legaldesign-composed-root > .ld-composed-section[data-composition-section]",
);
}
function composedForm() {
return state.brief && state.brief.form;
}
function singleComposition() {
return state.sourceSchemaVersion === "legaldesign.build.v4";
}
function pageScaleFor(node) {
return node && node.closest && node.closest(".ld-fixed-page")
? Number(root.style.getPropertyValue("--ld-page-scale")) || 1
: 1;
}
function layoutViewport() {
// WebKit may report the pinch-zoomed visual viewport through innerWidth
// and innerHeight. Refitting to those values counteracts native zoom.
// Root client dimensions describe the layout viewport, matching CSS.
return {
width: root.clientWidth || window.innerWidth,
height: root.clientHeight || window.innerHeight,
};
}
function onLayoutResize(callback) {
let previous = layoutViewport();
window.addEventListener("resize", () => {
const next = layoutViewport();
if (next.width === previous.width && next.height === previous.height)
return;
previous = next;
callback();
});
}
function fixedPageTargets() {
if (root.hasAttribute("data-composed-shell")) {
const approaches = all(
"#legaldesign-composed-root > .ld-page-approach",
);
if (composedForm() === "one-page") return approaches;
return all(".ld-page-approach > .ld-composed-section");
}
const slides = all("main > .sb-slide[data-frame]");
if (slides.length) return slides;
// Reference templates retain their own composition. The library is an
// authoring catalogue, not a deliverable, and is intentionally excluded.
return all("main.ld-page");
}
function paginateReferenceOverview() {
if (root.hasAttribute("data-composed-shell")) return;
const main = document.querySelector("main.ld-page");
const core = main && main.querySelector(":scope > .ld-core");
const figure = core && core.querySelector(":scope > .ld-figure");
if (!main || !core || !figure || main.querySelector(":scope > .sb-slide"))
return;
// Old single-sheet references held overview, diagram and four detailed
// cards in one tall area. Preserve every node, splitting at that existing
// semantic boundary rather than squeezing the diagram or dropping copy.
const overview = document.createElement("section");
overview.className = "sb-slide is-current ld-reference-page";
overview.setAttribute("data-frame", "1");
const details = document.createElement("section");
details.className = "sb-slide ld-reference-page";
details.setAttribute("data-frame", "2");
details.hidden = true;
let afterCore = false;
Array.from(main.children).forEach((node) => {
if (node === core) {
afterCore = true;
overview.appendChild(figure);
core.classList.add("ld-reference-cards");
details.appendChild(core);
} else (afterCore ? details : overview).appendChild(node);
});
main.append(overview, details);
if (!main.id) main.id = "slide-stage";
}
function referenceEvidenceDetails() {
const scrim = document.getElementById("popup-scrim");
if (!scrim || root.hasAttribute("data-composed-shell")) return;
all("main .finding-grid .evidence-panel").forEach((panel, index) => {
const page = panel.closest(".sb-slide[data-frame]");
const id =
"reference-evidence-" +
(page?.getAttribute("data-frame") || index + 1);
const title =
panel.querySelector("h2")?.textContent || "Evidence reviewed";
if (document.getElementById(id)) return;
state.evidence[id] = {
cite: "",
locator: "",
excerpt: "",
link: null,
status: "authored",
popup: { type: "detail", title, lede: "", sections: [] },
};
const pop = document.createElement("article");
pop.id = id;
pop.className = "pop";
pop.hidden = true;
pop.setAttribute("data-evidence-id", id);
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-modal", "true");
pop.setAttribute("aria-label", title);
const close = document.createElement("button");
close.className = "ld-popup-close";
close.type = "button";
close.textContent = "×";
close.setAttribute("aria-label", "Close evidence review");
pop.appendChild(close);
const button = document.createElement("button");
button.type = "button";
button.className = "ld-control ld-reference-evidence";
button.textContent = title;
button.setAttribute("data-detail", id);
button.setAttribute("aria-haspopup", "dialog");
button.setAttribute("aria-controls", id);
panel.replaceWith(button);
pop.appendChild(panel);
scrim.appendChild(pop);
});
}
let pageFitFrame = 0;
let pageFitInstalled = false;
function openPageReader(trigger) {
const page = visibleFixedPages()[0];
const scrim = document.getElementById("popup-scrim");
if (!page || !scrim) return;
document.getElementById("ld-page-reader")?.remove();
const reader = document.createElement("div");
reader.id = "ld-page-reader";
reader.className = "pop ld-page-reader";
reader.setAttribute("role", "dialog");
reader.setAttribute("aria-modal", "true");
reader.setAttribute("aria-label", "Read current page");
reader.innerHTML =
'<button type="button" class="ld-popup-close" aria-label="Close page reader">×</button>';
const nodes = Array.from(page.children);
let readerId = 0;
nodes.forEach((source) => {
const copy = source.cloneNode(true);
all("[hidden],.ld-unit-tools,.ld-why,[data-editor-only]", copy).forEach(
(node) => {
node.remove();
},
);
[copy, ...all(".ld-unit[data-unit]", copy)].forEach((figure) => {
const unit = unitState(figure.getAttribute("data-unit"));
const variant = unit && selectedVariant(unit);
if (
variant &&
variant.component &&
!figure.querySelector("[data-editor-preserve-dom]")
) {
const host = figure.querySelector(".ld-diagram");
if (host)
host.innerHTML = LD.render(variant.component, variant.params, {
narrow: true,
density: singleComposition() ? "compact" : "standard",
ariaLabel: unit.claim,
});
}
});
const svgIds = new Map();
all("[id]", copy)
.filter((node) => node instanceof SVGElement)
.forEach((node) => {
const next = "ld-reader-svg-" + ++readerId;
svgIds.set(node.id, next);
node.id = next;
});
const rewriteSVGReference = (value) =>
value.replace(
/url\(\s*(['"]?)#([^)'"\s]+)\1\s*\)/g,
(whole, quote, id) =>
svgIds.has(id) ? "url(#" + svgIds.get(id) + ")" : whole,
);
[copy, ...all("*", copy)].forEach((node) => {
if (
node.matches(
'.ld-composed-unit[data-kind="text"]:is([data-detail],[data-evidence])',
)
)
all(":scope > .ld-variant > [data-variant-body]", node).forEach(
(body) => {
body.classList.add("ld-reader-popup-text");
},
);
if (!(node instanceof SVGElement)) node.removeAttribute("id");
if (node instanceof SVGElement) {
Array.from(node.attributes).forEach((attribute) => {
let value = rewriteSVGReference(attribute.value);
if (
["href", "xlink:href"].includes(attribute.name) &&
value.startsWith("#") &&
svgIds.has(value.slice(1))
)
value = "#" + svgIds.get(value.slice(1));
if (value !== attribute.value)
node.setAttribute(attribute.name, value);
});
if (node.tagName.toLowerCase() === "style")
node.textContent = rewriteSVGReference(node.textContent);
}
node.removeAttribute("contenteditable");
node.removeAttribute("spellcheck");
node.removeAttribute("tabindex");
[
"aria-controls",
"aria-labelledby",
"aria-describedby",
"for",
].forEach((name) => {
node.removeAttribute(name);
});
Array.from(node.attributes).forEach((attribute) => {
if (
attribute.name.startsWith("data-") &&
!["data-detail", "data-evidence", "data-section-target"].includes(
attribute.name,
)
)
node.removeAttribute(attribute.name);
});
if (node.classList.contains("ld-unit"))
node.classList.add("ld-reader-unit");
node.classList.remove(
"ld-unit",
"ld-composed-unit",
"ld-fixed-page",
"ld-popup-trigger",
"ld-selected",
"ld-selection-mark",
);
if (!(node instanceof SVGElement)) node.removeAttribute("style");
});
all(
"input,textarea,select,button:not([data-detail]):not([data-evidence]):not([data-section-target])",
copy,
).forEach((control) => {
const text = document.createElement("p");
text.textContent = control.matches("input,textarea,select")
? (control.getAttribute("aria-label") ||
control.getAttribute("placeholder") ||
"Response") +
": " +
(control.value || "—")
: control.textContent +
(control.getAttribute("aria-pressed") === "true"
? " — selected"
: "");
control.replaceWith(text);
});
copy.className = "ld-reader-section";
reader.appendChild(copy);
});
scrim.appendChild(reader);
installSectionLinks();
bindPopupDOM();
openPopup(reader.id, trigger);
all(".ld-diagram svg", reader).forEach((svg) => {
const frameWidth = svg.viewBox.baseVal.width || 400;
const fontSizes = all("text", svg)
.map((text) => parseFloat(getComputedStyle(text).fontSize))
.filter((size) => size > 0);
const smallest = fontSizes.length ? Math.min(...fontSizes) : 13;
const available = svg.parentElement.clientWidth;
// Fit ordinary narrow diagrams completely. Pan only when the actual
// component would otherwise put a label below 11 screen pixels.
svg.style.width =
Math.max(available, (frameWidth * 11) / smallest) + "px";
svg.style.height = "auto";
svg.style.maxHeight = "none";
});
updatePopupScrollHint(reader);
}
function installPageReader() {
let button = document.getElementById("ld-read-page");
if (!button) {
button = document.createElement("button");
button.id = "ld-read-page";
button.className = "ld-control ld-read-page";
button.type = "button";
button.innerHTML =
'<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M12 5.5C9 3.5 5.5 3.5 2 4.5v14c3.5-1 7-1 10 1 3-2 6.5-2 10-1v-14c-3.5-1-7-1-10 1Zm0 0v14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
button.setAttribute("aria-label", "Read page");
button.title = "Read page — larger text and diagram details";
button.setAttribute("aria-haspopup", "dialog");
button.hidden = true;
document.body.appendChild(button);
}
if (!button.__legalDesignReaderBound) {
button.__legalDesignReaderBound = true;
button.addEventListener("click", () => openPageReader(button));
}
}
function visibleFixedPages() {
return all(".ld-fixed-page").filter(
(page) =>
!page.closest("[hidden]") &&
getComputedStyle(page).display !== "none",
);
}
function pageOverflow(page, diagnostics) {
const bounds = page.getBoundingClientRect();
const tolerance = Math.max(1, pageScaleFor(page) * 2);
const outside = (box) =>
box.width > 0 &&
box.height > 0 &&
(box.left < bounds.left - tolerance ||
box.right > bounds.right + tolerance ||
box.top < bounds.top - tolerance ||
box.bottom > bounds.bottom + tolerance);
const concealedByDisclosure = (node) => {
let closed = node.parentElement?.closest("details:not([open])");
while (closed && page.contains(closed)) {
if (!closed.querySelector(":scope > summary")?.contains(node))
return true;
closed = closed.parentElement?.closest("details:not([open])");
}
return false;
};
const candidates = all("*", page).filter(
(node) =>
!node.closest("[hidden],[data-editor-only],.ld-unit-tools") &&
!concealedByDisclosure(node) &&
!node.matches("script,style") &&
node.getClientRects().length,
);
if (
candidates.some((node) => {
const box = node.getBoundingClientRect();
const style = getComputedStyle(node);
const clipsContent =
!(node instanceof SVGElement) &&
((/auto|scroll|hidden|clip/.test(style.overflowY) &&
node.scrollHeight > node.clientHeight + 2) ||
(/auto|scroll|hidden|clip/.test(style.overflowX) &&
node.scrollWidth > node.clientWidth + 2));
const invalid = outside(box) || clipsContent;
if (invalid && diagnostics)
diagnostics.push({
page: page.id || page.getAttribute("data-composition-section"),
tag: node.tagName,
className: node.getAttribute("class"),
kind: clipsContent ? "internal clipping" : "box outside frame",
height: node.clientHeight,
scrollHeight: node.scrollHeight,
width: node.clientWidth,
scrollWidth: node.scrollWidth,
top: box.top - bounds.top,
bottom: box.bottom - bounds.top,
});
return invalid;
})
)
return true;
// A fixed-height text box can have in-bounds edges while its ink spills
// beyond the page. Check actual text geometry as well as container boxes.
return candidates.some((node) =>
Array.from(node.childNodes).some((child) => {
if (child.nodeType !== Node.TEXT_NODE || !child.textContent.trim())
return false;
const range = document.createRange();
range.selectNodeContents(child);
const box = range.getBoundingClientRect();
const invalid = outside(box);
if (invalid && diagnostics)
diagnostics.push({
page: page.id || page.dataset.compositionSection,
tag: node.tagName,
className: node.getAttribute("class"),
kind: "text ink outside frame",
top: box.top - bounds.top,
bottom: box.bottom - bounds.top,
});
return invalid;
}),
);
}
function validatePageFit(showNotice = true) {
const invalid = visibleFixedPages().some((page) => pageOverflow(page));
root.toggleAttribute("data-page-overflow", invalid);
let notice = document.getElementById("ld-page-fit-error");
if (invalid && showNotice && !notice) {
notice = document.createElement("div");
notice.id = "ld-page-fit-error";
notice.className = "ld-page-fit-error";
notice.setAttribute("data-editor-chrome", "");
notice.setAttribute("role", "alert");
notice.textContent =
"This page has more content than the available window can hold. Move detail into a popup or split it into another page before exporting. Your content has not been removed.";
document.body.appendChild(notice);
}
if (notice && !invalid && !notice.hasAttribute("data-export-fit-error"))
notice.remove();
return !invalid;
}
function validateExportPageFit(includeOtherApproaches) {
const issues = [];
const pages = fixedPageTargets().filter((page) => {
const approach = page.closest("[data-approach]");
return (
includeOtherApproaches ||
!approach ||
approach.dataset.approach === currentApproach()
);
});
const changed = new Map();
const reveal = (node) => {
if (!node || changed.has(node)) return;
changed.set(node, { hidden: node.hidden, display: node.style.display });
node.hidden = false;
if (getComputedStyle(node).display === "none")
node.style.display = "block";
};
let valid = true;
try {
for (const page of pages) {
reveal(page.closest("[data-approach]"));
reveal(page);
// Hidden approach figures have no measurable width until revealed.
// Keep authored SVG edits; only state-driven figures are rendered.
all('.ld-unit[data-kind="figure"]', page).forEach((section) => {
const unit = unitState(section.getAttribute("data-unit"));
if (unit) renderFigure(section, unit);
});
if (pageOverflow(page, issues)) valid = false;
// A collapsed dashboard must not conceal a group that cannot fit.
const groups = all("details.ld-overview-group", page);
const openStates = groups.map((group) => group.open);
try {
for (const active of groups) {
groups.forEach((group) => {
group.open = group === active;
});
if (pageOverflow(page, issues)) valid = false;
}
} finally {
groups.forEach((group, index) => {
group.open = openStates[index];
});
}
}
} finally {
changed.forEach((prior, node) => {
node.hidden = prior.hidden;
node.style.display = prior.display;
if (!node.getAttribute("style")) node.removeAttribute("style");
});
}
LD.lastPageFitIssues = issues;
if (!valid) {
let notice = document.getElementById("ld-page-fit-error");
if (!notice) {
notice = document.createElement("div");
notice.id = "ld-page-fit-error";
notice.className = "ld-page-fit-error";
notice.setAttribute("data-editor-chrome", "");
notice.setAttribute("role", "alert");
document.body.appendChild(notice);
}
notice.textContent =
"An output page exceeds its available window. Review every page and move detail into a popup or another page before exporting. Nothing has been deleted.";
notice.setAttribute("data-export-fit-error", "");
} else {
const notice = document.getElementById("ld-page-fit-error");
if (notice) notice.remove();
}
return valid;
}
function fitFixedPages() {
pageFitFrame = 0;
if (!root.hasAttribute("data-fixed-pages")) return;
const viewport = layoutViewport();
const bar = document.querySelector(".ld-bar");
const approach = document.getElementById("ld-page-approach");
const toolbarBottom = bar
? Math.max(0, bar.getBoundingClientRect().bottom)
: 0;
const approachHeight =
approach && !approach.hidden ? approach.offsetHeight + 10 : 0;
const rail = document.querySelector("nav.rail");
const railRight =
rail && rail.getClientRects().length
? rail.getBoundingClientRect().right + 12
: 0;
const leftGutter = root.hasAttribute("data-has-slide-index")
? viewport.width <= 760
? 68
: 214
: 12;
const stable =
root.hasAttribute("data-composed-shell") && singleComposition();
root.toggleAttribute("data-stable-type", stable);
const availableWidth = Math.max(
1,
viewport.width - Math.max(leftGutter, railRight) - 12,
);
// Desktop type is a reading size, not a percentage of the window. Limit
// the combined rail/page measure; extra width becomes outer margin.
const width =
stable && viewport.width > 760
? Math.min(availableWidth, 1440)
: availableWidth;
const shellOffset = (availableWidth - width) / 2;
const pageLeftGutter = Math.max(leftGutter, railRight) + shellOffset;
root.style.setProperty("--ld-index-left", shellOffset + "px");
const resultTop = toolbarBottom + 10 + approachHeight;
root.style.setProperty("--ld-result-top", resultTop + "px");
const result = document.querySelector(".ld-save-banner");
const resultHeight =
result && result.getClientRects().length
? result.getBoundingClientRect().height + 10
: 0;
const top = resultTop + resultHeight;
const reader = document.getElementById("ld-read-page");
// The book reader is always available, not a substitute for missing
// content. Its reserved corner belongs to chrome, not the page itself.
if (reader) reader.hidden = false;
const readerReserve = stable && viewport.width > 760 ? 0 : 52;
const height = Math.max(1, viewport.height - top - 12 - readerReserve);
let scale = Math.min(width / 1200, height / 900, 1);
if (stable && viewport.width > 760) scale = 1;
const baselineScale = scale;
// On short desktop windows, a sparse page need not become a thumbnail
// simply because the planning reference is 900px tall. Try a readable
// coordinate scale against actual content, leaving the approved roomier
// layouts and phone overview unchanged. Never crop to claim a fit.
// Authored reference pages have pinned footers and their own spacing;
// preserve that approved coordinate scale. This content-fit pass is for
// the flow-layout composer, whose children participate in natural flow.
if (
root.hasAttribute("data-composed-shell") &&
!stable &&
viewport.width > 760 &&
scale < 0.8
) {
const candidate = Math.min(width / 1200, 1);
for (let trial = candidate; trial > baselineScale; trial -= 0.02) {
root.style.setProperty("--ld-page-scale", String(trial));
root.style.setProperty("--ld-page-width", width / trial + "px");
root.style.setProperty("--ld-page-height", height / trial + "px");
const changed = new Map();
const reveal = (node) => {
if (!node || changed.has(node)) return;
changed.set(node, {
hidden: node.hidden,
display: node.style.display,
});
node.hidden = false;
if (getComputedStyle(node).display === "none")
node.style.display = "block";
};
let fits = true;
try {
for (const page of fixedPageTargets()) {
reveal(page.closest("[data-approach]"));
reveal(page);
if (pageOverflow(page)) {
fits = false;
break;
}
}
} finally {
changed.forEach((prior, node) => {
node.hidden = prior.hidden;
node.style.display = prior.display;
});
}
if (fits) {
scale = trial;
break;
}
}
}
root.style.setProperty("--ld-page-scale", String(scale));
// Keep one uniform coordinate scale for persisted edits, but let each
// dimension fill the window independently. 1200×900 remains a design
// reference, never a letterboxed aspect-ratio constraint.
root.style.setProperty("--ld-page-width", width / scale + "px");
root.style.setProperty("--ld-page-height", height / scale + "px");
root.style.setProperty("--ld-page-left", pageLeftGutter + "px");
root.style.setProperty("--ld-page-top", top + "px");
root.setAttribute("data-page-layout", "adaptive");
root.style.setProperty("--ld-approach-top", toolbarBottom + 6 + "px");
validatePageFit();
alignSlideIndex();
// Saved/client SVG is kept verbatim rather than rerendered. In Chromium,
// a changed ancestor scale can leave its glyph paint transform stale even
// when getBBox is correct. Invalidate that paint after layout settles,
// using an identity transform and restoring every authored attribute.
all(".ld-fixed-page .ld-diagram svg text").forEach((label) => {
const original = label.getAttribute("transform");
label.setAttribute("transform", (original || "") + " translate(0 0)");
label.getBoundingClientRect();
if (original === null) label.removeAttribute("transform");
else label.setAttribute("transform", original);
});
if (editing) renderSelection();
}
function schedulePageFit() {
if (!pageFitFrame) pageFitFrame = requestAnimationFrame(fitFixedPages);
}
function alignSlideIndex() {
const nav = document.getElementById("ld-form-navigation");
if (!nav || !root.hasAttribute("data-composed-shell")) return;
const viewport = layoutViewport();
if (viewport.width <= 760) {
nav.style.removeProperty("height");
nav.style.removeProperty("--ld-index-top");
return;
}
const pages = visibleFixedPages();
const bodies = pages.flatMap((page) => all(".ld-composed-grid", page));
if (!bodies.length) return;
const top = Math.min(
...bodies.map((node) => node.getBoundingClientRect().top),
);
const bottom = Math.max(
...bodies.map((node) => node.getBoundingClientRect().bottom),
);
// Align the rail to the actual explanation, including its scope footer,
// not to an empty viewport-sized page box. Long indices scroll inside it.
nav.style.setProperty("--ld-index-top", top + "px");
nav.style.height =
Math.max(44, Math.min(bottom, viewport.height - 12) - top) + "px";
}
function installFixedPages() {
paginateReferenceOverview();
referenceEvidenceDetails();
const targets = fixedPageTargets();
if (!targets.length) return;
root.setAttribute("data-fixed-pages", "4:3");
installPageReader();
all(".ld-fixed-page").forEach((page) => {
if (!targets.includes(page)) page.classList.remove("ld-fixed-page");
});
targets.forEach((page) => {
page.classList.add("ld-fixed-page");
if (!page.querySelector("h1,h2")) {
const heading = document.createElement("h1");
heading.className = "ld-page-heading";
heading.setAttribute("data-editable", "");
heading.setAttribute(
"data-placeholder",
"[Title for the subjects covered on this page.]",
);
heading.textContent =
page.getAttribute("aria-label") ||
state.brief?.title ||
"Supporting details";
page.prepend(heading);
}
});
if (!pageFitInstalled) {
pageFitInstalled = true;
onLayoutResize(schedulePageFit);
const bar = document.querySelector(".ld-bar");
if (bar && typeof ResizeObserver !== "undefined")
new ResizeObserver(schedulePageFit).observe(bar);
new MutationObserver((records) => {
if (
records.some(
(record) =>
!record.target.closest ||
!record.target.closest(
"#ld-selection-box,#ld-editor-layer,#ld-page-fit-error,#ld-page-reader",
),
)
)
schedulePageFit();
}).observe(document.body, {
subtree: true,
childList: true,
characterData: true,
});
document.addEventListener("toggle", schedulePageFit, true);
document.fonts && document.fonts.ready.then(schedulePageFit);
}
// Resolve the viewport transform before renderAll creates SVG text.
// Painting text first at the reference scale then changing its ancestor
// scale can leave Chromium's glyph paint cache at the old coordinates.
// Geometry APIs still report correct boxes, masking the error until hover.
if (pageFitFrame) cancelAnimationFrame(pageFitFrame);
fitFixedPages();
}
LD.checkPageFit = (options = {}) => ({
valid: options.allPages ? validateExportPageFit(true) : validatePageFit(),
ratio: "adaptive",
authoringReference: "4:3",
width: parseFloat(root.style.getPropertyValue("--ld-page-width")) || 1200,
height:
parseFloat(root.style.getPropertyValue("--ld-page-height")) || 900,
visiblePages: visibleFixedPages().length,
});
function composedLocation(sectionCount) {
const location = state.review && state.review.location;
return Number.isInteger(location)
? Math.max(0, Math.min(sectionCount - 1, location))
: 0;
}
function composedIndexItems(sections) {
return sections.map((section, index) => {
const heading = section.querySelector("h1,h2,h3");
return {
id: section.getAttribute("data-composition-section"),
label:
section.getAttribute("data-index-label") ||
(heading && heading.textContent.trim()) ||
section.getAttribute("aria-label") ||
"Section " + (index + 1),
};
});
}
function composedIndexGroups() {
const overview = state.overview;
if (!overview?.groups?.length) return [];
const targets = new Map(
(overview.topics || []).map((topic) => [
topic.unitId,
topic.targetSectionId,
]),
);
return overview.groups.map((group) => ({
label: group.label,
sectionIds: group.topicUnitIds
.map((id) => targets.get(id))
.filter(Boolean),
}));
}
function updateComposedForm() {
installSectionLinks();
installFixedPages();
const sections = composedSections();
if (sections.length < 2) return;
const form = composedForm();
const location = composedLocation(sections.length);
sections.forEach((section, index) => {
section.hidden = form !== "one-page" && index !== location;
});
// Hidden slide hosts have zero width. Resolve a newly visible figure
// against its real page width so first navigation and resize agree.
// renderFigure retains authored/edit-preserved and client SVG verbatim.
sections
.filter((section) => !section.hidden)
.forEach((section) => {
all("section.ld-unit[data-unit]", section).forEach((node) => {
const unit = unitState(node.getAttribute("data-unit"));
if (unit?.kind === "figure") renderFigure(node, unit);
});
});
const nav = document.getElementById("ld-form-navigation");
if (!nav) return;
if (
nav.__legalDesignIndexSignature !==
JSON.stringify({
form,
items: composedIndexItems(sections),
groups: composedIndexGroups(),
})
) {
installComposedForm();
return;
}
if (form !== "one-page") {
const status = nav.querySelector("[data-walkthrough-status]");
const previous = nav.querySelector("[data-walkthrough-previous]");
const next = nav.querySelector("[data-walkthrough-next]");
if (status) status.textContent = location + 1 + " / " + sections.length;
if (previous) previous.disabled = location === 0;
if (next) next.disabled = location === sections.length - 1;
}
all("[data-report-section]", nav).forEach((button) => {
button.setAttribute(
"aria-current",
Number(button.dataset.reportSection) === location ? "true" : "false",
);
});
// Navigation, not repaint, reveals the current subject. A reader may
// still close that group or inspect another without it snapping back.
if (nav.__legalDesignGroupLocation !== location) {
all(".ld-index-group", nav).forEach((group) => {
group.open = Boolean(group.querySelector('[aria-current="true"]'));
});
nav.__legalDesignGroupLocation = location;
}
}
let sectionLinksBound = false;
function installSectionLinks() {
installOverviewGroups();
all("[data-section-target]").forEach((node) => {
node.setAttribute("role", "link");
node.setAttribute("tabindex", "0");
});
if (sectionLinksBound) return;
sectionLinksBound = true;
const activate = (event) => {
const target = event.target.closest("[data-section-target]");
if (!target || editing) return;
if (event.type === "keydown" && !["Enter", " "].includes(event.key))
return;
const sections = composedSections();
const index = sections.findIndex(
(section) =>
section.dataset.compositionSection === target.dataset.sectionTarget,
);
if (index < 0) return;
event.preventDefault();
const fromReader = Boolean(target.closest("#ld-page-reader"));
if (activePopup()) closePopup();
setComposedLocation(index);
if (fromReader) {
openPageReader(document.getElementById("ld-read-page"));
return;
}
const heading = sections[index].querySelector("h1,h2,h3");
if (heading) {
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: true });
}
};
document.addEventListener("click", activate);
document.addEventListener("keydown", activate);
}
let overviewGroupsBound = false;
function installOverviewGroups() {
if (overviewGroupsBound) return;
overviewGroupsBound = true;
// Native keyboard activation also emits click. Apply the complete
// one-open transition synchronously: a delayed toggle from a freshly
// cloned reader must not overrule a newer user choice.
document.addEventListener("click", (event) => {
const summary = event.target.closest(".ld-overview-group > summary");
if (!summary) return;
const group = summary.parentElement;
const container = group.closest(".ld-overview-groups");
if (!container) return;
event.preventDefault();
const opening = !group.open;
if (opening)
all(":scope > details.ld-overview-group", container).forEach(
(peer) => {
if (peer !== group) peer.open = false;
},
);
group.open = opening;
});
document.addEventListener(
"toggle",
(event) => {
const group = event.target;
if (!group.matches?.("details.ld-overview-group")) return;
const container = group.closest(".ld-overview-groups");
if (!container) return;
if (!container.closest("#ld-page-reader")) storeEditorDOM();
schedulePageFit();
},
true,
);
}
function setComposedLocation(index, options) {
const sections = composedSections();
if (!sections.length) return;
const next = Math.max(0, Math.min(sections.length - 1, index));
if (!state.review) state.review = {};
state.review.location = next;
state.savedAt = new Date().toISOString();
mirror();
updateComposedForm();
storeEditorDOM();
schedulePageFit();
}
function installComposedForm() {
installLegacySlideIndex();
const sections = composedSections();
const form = composedForm();
if (
sections.length < 2 ||
!["slide-brief", "walkthrough", "report"].includes(form)
) {
if (root.hasAttribute("data-composed-shell")) {
const stale = document.getElementById("ld-form-navigation");
if (stale) stale.remove();
root.removeAttribute("data-has-slide-index");
}
return;
}
const main = document.getElementById("legaldesign-composed-root");
let nav = document.getElementById("ld-form-navigation");
const items = composedIndexItems(sections);
const groups = composedIndexGroups();
const signature = JSON.stringify({ form, items, groups });
// Exported/restored navigation is presentation only. Rebuild it from the
// active approach so a reset template cannot retain another page's index.
if (nav && nav.__legalDesignIndexSignature !== signature) {
nav.remove();
nav = null;
}
if (!nav) {
nav = document.createElement("nav");
nav.id = "ld-form-navigation";
nav.className = "ld-form-nav ld-slide-index";
nav.setAttribute(
"aria-label",
form === "report" ? "Report sections" : "Slide navigation",
);
nav.innerHTML =
'<button class="ld-control ld-index-toggle" type="button" data-index-toggle aria-controls="ld-index-list" aria-expanded="true">Contents</button>' +
'<div id="ld-index-list" data-index-list></div>';
const list = nav.querySelector("[data-index-list]");
const makeEntry = (item, index) => {
const button = document.createElement("button");
button.className = "ld-control";
button.type = "button";
button.setAttribute("data-report-section", String(index));
const number = document.createElement("span");
number.className = "ld-index-number";
number.setAttribute("aria-hidden", "true");
number.textContent = String(index + 1);
const label = document.createElement("span");
label.className = "ld-index-label";
label.textContent = item.label;
button.append(number, label);
return button;
};
const groupedIds = new Set(groups.flatMap((group) => group.sectionIds));
items.forEach((item, index) => {
if (!groupedIds.has(item.id))
list.appendChild(makeEntry(item, index));
});
groups.forEach((group) => {
const details = document.createElement("details");
details.className = "ld-index-group";
const summary = document.createElement("summary");
summary.textContent = group.label;
details.appendChild(summary);
const entries = document.createElement("div");
entries.className = "ld-index-group-items";
group.sectionIds.forEach((id) => {
const index = items.findIndex((item) => item.id === id);
if (index >= 0) entries.appendChild(makeEntry(items[index], index));
});
details.appendChild(entries);
summary.addEventListener("click", (event) => {
event.preventDefault();
const opening = !details.open;
all(".ld-index-group", list).forEach((peer) => {
peer.open = false;
});
details.open = opening;
});
list.appendChild(details);
});
if (form !== "one-page") {
const pager = document.createElement("div");
pager.className = "ld-index-pager";
pager.innerHTML =
'<button class="ld-control" type="button" data-walkthrough-previous>Previous</button>' +
'<span data-walkthrough-status aria-live="polite"></span>' +
'<button class="ld-control" type="button" data-walkthrough-next>Next</button>';
(singleComposition() ? nav : list).appendChild(pager);
}
main.insertBefore(nav, main.firstChild);
}
nav.__legalDesignIndexSignature = signature;
installSlideIndexToggle(nav);
if (!nav.__legalDesignFormBound) {
nav.__legalDesignFormBound = true;
const previous = nav.querySelector("[data-walkthrough-previous]");
const next = nav.querySelector("[data-walkthrough-next]");
if (previous)
previous.addEventListener("click", () => {
setComposedLocation(composedLocation(sections.length) - 1);
});
if (next)
next.addEventListener("click", () => {
setComposedLocation(composedLocation(sections.length) + 1);
});
all("[data-report-section]", nav).forEach((button) => {
button.addEventListener("click", () => {
setComposedLocation(
Number(button.getAttribute("data-report-section")),
{
scroll: form === "report",
},
);
});
});
}
updateComposedForm();
}
function installSlideIndexToggle(nav) {
root.setAttribute("data-has-slide-index", "true");
const toggle = nav.querySelector("[data-index-toggle]");
const list = nav.querySelector("[data-index-list]");
if (!toggle || !list || nav.__legalDesignIndexBound) return;
nav.__legalDesignIndexBound = true;
const phone = window.matchMedia("(max-width: 760px)");
const setExpanded = (expanded) => {
nav.setAttribute("data-index-expanded", expanded ? "true" : "false");
toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
toggle.textContent = expanded ? "Contents" : "☰";
toggle.setAttribute(
"aria-label",
expanded ? "Collapse contents" : "Open contents",
);
list.hidden = !expanded;
};
setExpanded(!phone.matches);
toggle.addEventListener("click", () => {
setExpanded(toggle.getAttribute("aria-expanded") !== "true");
});
nav.addEventListener("click", (event) => {
if (phone.matches && event.target.closest("[data-report-section]"))
setExpanded(false);
});
phone.addEventListener("change", () => setExpanded(!phone.matches));
const bar = document.querySelector(".ld-bar");
if (bar && typeof ResizeObserver !== "undefined") {
const position = () =>
nav.style.setProperty(
"--ld-index-top",
Math.ceil(bar.getBoundingClientRect().height + 16) + "px",
);
position();
const observer = new ResizeObserver(position);
observer.observe(bar);
}
}
function installLegacySlideIndex() {
if (root.hasAttribute("data-composed-shell")) return;
const slides = all("section.sb-slide[data-frame]");
const stage = document.getElementById("slide-stage");
if (slides.length < 2 || !stage) return;
let nav = document.getElementById("ld-legacy-slide-index");
if (!nav) {
nav = document.createElement("nav");
nav.id = "ld-legacy-slide-index";
nav.className = "ld-slide-index";
nav.setAttribute("aria-label", "Slide navigation");
nav.innerHTML =
'<button class="ld-control ld-index-toggle" type="button" data-index-toggle aria-controls="ld-legacy-index-list" aria-expanded="true">Contents</button><div id="ld-legacy-index-list" data-index-list></div>';
const list = nav.querySelector("[data-index-list]");
slides.forEach((slide, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "ld-control";
button.setAttribute("data-report-section", String(index));
list.appendChild(button);
});
const pager = document.querySelector(".sb-navigation");
if (pager) list.appendChild(pager);
stage.before(nav);
}
installSlideIndexToggle(nav);
if (nav.__legalDesignLegacyBound) return;
nav.__legalDesignLegacyBound = true;
const refresh = () => {
all("[data-report-section]", nav).forEach((button, index) => {
const heading = slides[index].querySelector(
".ld-variant:not([hidden]) h1,h2,h3",
);
button.textContent =
(heading && heading.textContent.trim()) || "Slide " + (index + 1);
button.setAttribute(
"aria-current",
slides[index].classList.contains("is-current") ? "true" : "false",
);
});
};
nav.addEventListener("click", (event) => {
const button = event.target.closest("[data-report-section]");
if (!button) return;
const frame = Number(button.getAttribute("data-report-section")) + 1;
if (window.LegalDesignWalkthrough)
window.LegalDesignWalkthrough.showFrame(frame, true);
else {
const original = document.querySelector(
'[data-frame-button="' + frame + '"]',
);
if (original) original.click();
else {
slides.forEach((slide, index) => {
slide.hidden = index !== frame - 1;
slide.classList.toggle("is-current", index === frame - 1);
});
if (!state.review) state.review = {};
state.review.location = frame - 1;
mirror();
schedulePageFit();
}
}
refresh();
});
const observer = new MutationObserver(refresh);
observer.observe(stage, {
attributes: true,
attributeFilter: ["class", "hidden"],
childList: true,
characterData: true,
subtree: true,
});
refresh();
}
function syncUnitEdit(section) {
if (!section || !section.isConnected) return;
const id = section.getAttribute("data-unit");
const unit = unitState(id);
if (!unit || unit.kind === "figure") return;
if (!unit.edits) unit.edits = {};
all(".ld-variant[data-variant]", section).forEach((variant) => {
const body = variant.querySelector("[data-variant-body]");
if (body)
unit.edits[variant.getAttribute("data-variant")] = body.innerHTML;
});
}
function syncEvidenceEdit(pop) {
if (!pop || !pop.isConnected) return;
const id = pop.getAttribute("data-evidence-id") || pop.id;
const item = state.evidence && state.evidence[id];
if (!item) return;
all("[data-evidence-field]", pop).forEach((node) => {
const field = node.getAttribute("data-evidence-field");
const value = node.textContent.trim();
if (!value) return;
if (["cite", "locator", "detail"].includes(field)) item[field] = value;
else if (field === "popup.title" && item.popup)
item.popup.title = value;
else if (field === "popup.lede" && item.popup) item.popup.lede = value;
else {
const match = /^popup\.sections\.(\d+)\.(heading|body)$/.exec(field);
const section =
match && item.popup && Array.isArray(item.popup.sections)
? item.popup.sections[Number(match[1])]
: null;
if (!section) return;
section[match[2]] = value;
}
if (field === "detail") item.excerpt = value;
if (field === "locator")
item.provenance = (item.sourceId || "source") + " · " + value;
});
}
function syncEditorDOMToState(nodes) {
let sections = [];
let popups = [];
if (nodes && nodes.length) {
nodes.forEach((node) => {
const section =
node && node.closest && node.closest("section.ld-unit[data-unit]");
if (section && !sections.includes(section)) sections.push(section);
const popup =
node && node.closest && node.closest(".pop[data-evidence-id]");
if (popup && !popups.includes(popup)) popups.push(popup);
});
} else {
sections = all("section.ld-unit[data-unit]");
popups = all(".pop[data-evidence-id]");
}
sections.forEach(syncUnitEdit);
popups.forEach(syncEvidenceEdit);
}
function commitEditorDOM(nodes) {
(nodes || []).forEach((node) => {
if (!node || !node.closest) return;
const diagram = node.closest(".ld-diagram");
const section = node.closest("section.ld-unit[data-unit]");
if (diagram) diagram.setAttribute("data-editor-preserve-dom", "true");
else if (
section &&
unitState(section.getAttribute("data-unit")) &&
unitState(section.getAttribute("data-unit")).kind === "figure"
)
all(".ld-diagram", section).forEach((host) => {
host.setAttribute("data-editor-preserve-dom", "true");
});
});
syncEditorDOMToState(nodes);
state.savedAt = new Date().toISOString();
mirror();
storeEditorDOM();
renderSelection();
updateEditorButtons();
}
function bindUnits() {
all("section.ld-unit[data-unit]").forEach((section) => {
const id = section.getAttribute("data-unit");
const ab = section.querySelector(".ld-ab");
if (ab && !ab.__legalDesignUnitBound) {
ab.__legalDesignUnitBound = true;
ab.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
setVariant(id, event.key === "ArrowLeft" ? "a" : "b");
});
all("[data-select-variant]", ab).forEach((button) => {
if (button.__legalDesignUnitBound) return;
button.__legalDesignUnitBound = true;
button.addEventListener("click", () => {
setVariant(id, button.getAttribute("data-select-variant"));
});
});
}
const whyButton = section.querySelector(".ld-why-toggle");
const why = section.querySelector(".ld-why");
if (whyButton && why && !whyButton.__legalDesignUnitBound) {
whyButton.__legalDesignUnitBound = true;
whyButton.addEventListener("click", () => {
const open = why.hidden;
why.hidden = !open;
whyButton.setAttribute("aria-expanded", open ? "true" : "false");
});
}
all("[data-editable]", section).forEach((node) => {
if (node.__legalDesignUnitBound) return;
node.__legalDesignUnitBound = true;
node.addEventListener("blur", () => {
if (!node.isConnected || !node.hasAttribute("contenteditable"))
return;
node.removeAttribute("contenteditable");
node.removeAttribute("spellcheck");
textUndoArmed = false;
commitEditorDOM([node]);
});
});
});
}
function renderDecisions() {
all('[data-kind="decision"][data-unit]').forEach((unit) => {
const id = unit.getAttribute("data-unit");
const value =
state.review && state.review.decisions
? state.review.decisions[id]
: null;
const choice =
value && typeof value === "object" && !Array.isArray(value)
? value.choice
: value;
const choices =
value && typeof value === "object" && !Array.isArray(value)
? value.choices
: value;
all("[data-decision-option]", unit).forEach((button) => {
const selected = unit.hasAttribute("data-multi")
? Array.isArray(choices) && choices.includes(button.value)
: choice === button.value;
button.setAttribute("aria-pressed", selected ? "true" : "false");
});
all("[data-decision-custom],[data-decision-note]", unit).forEach(
(input) => {
const key = input.hasAttribute("data-decision-custom")
? "custom"
: "note";
const next =
value && typeof value === "object" && !Array.isArray(value)
? value[key] || ""
: "";
if (document.activeElement !== input) input.value = next;
},
);
unit.toggleAttribute(
"data-answered",
Boolean(
unit.hasAttribute("data-multi")
? (Array.isArray(choices) && choices.length) ||
(value && (value.custom || value.note))
: choice || (value && (value.custom || value.note)),
),
);
});
}
function bindDecisions() {
all('[data-kind="decision"][data-unit]').forEach((unit) => {
const id = unit.getAttribute("data-unit");
all("[data-decision-option]", unit).forEach((button) => {
if (button.__legalDesignDecisionBound) return;
button.__legalDesignDecisionBound = true;
button.addEventListener("click", () => {
change(() => {
if (!state.review.decisions) state.review.decisions = {};
if (unit.hasAttribute("data-multi")) {
const current = state.review.decisions[id];
const values = Array.isArray(current)
? current
: current && Array.isArray(current.choices)
? current.choices
: [];
const at = values.indexOf(button.value);
if (at >= 0) values.splice(at, 1);
else values.push(button.value);
if (
current &&
typeof current === "object" &&
!Array.isArray(current)
)
current.choices = values;
else state.review.decisions[id] = values;
} else {
const current = state.review.decisions[id];
if (
current &&
typeof current === "object" &&
!Array.isArray(current)
)
current.choice = button.value;
else state.review.decisions[id] = button.value;
}
});
});
});
all("[data-decision-custom],[data-decision-note]", unit).forEach(
(input) => {
if (input.__legalDesignDecisionBound) return;
input.__legalDesignDecisionBound = true;
input.addEventListener("change", () => {
change(() => {
if (!state.review.decisions) state.review.decisions = {};
let current = state.review.decisions[id];
if (
!current ||
typeof current !== "object" ||
Array.isArray(current)
) {
current = unit.hasAttribute("data-multi")
? {
choices: Array.isArray(current)
? current.slice()
: current
? [current]
: [],
}
: { choice: current || null };
}
current[
input.hasAttribute("data-decision-custom") ? "custom" : "note"
] = input.value;
state.review.decisions[id] = current;
});
});
},
);
});
}
function popupFor(id) {
const pop = document.getElementById(id);
return pop && pop.matches("#popup-scrim .pop") ? pop : null;
}
function activePopup() {
const scrim = document.getElementById("popup-scrim");
if (!scrim || scrim.hidden) return null;
return (
all(".pop", scrim).find((pop) => !pop.hidden && visible(pop)) || null
);
}
function popupFocusables(pop) {
return all(
'a[href],button,input:not([type="hidden"]),select,textarea,summary,[contenteditable="true"],[tabindex]',
pop,
).filter(
(node) =>
node.tabIndex >= 0 &&
!node.matches(':disabled,[aria-disabled="true"]') &&
!node.closest('[hidden],[inert],[aria-hidden="true"]') &&
visible(node),
);
}
function focusPopupFallback(pop) {
if (!pop.hasAttribute("tabindex")) {
pop.setAttribute("tabindex", "-1");
popupFallbackNodes.add(pop);
}
pop.focus();
}
function clearPopupFallback(pop) {
if (!popupFallbackNodes.has(pop)) return;
pop.removeAttribute("tabindex");
popupFallbackNodes.delete(pop);
}
function trapPopupTab(event, pop) {
const focusables = popupFocusables(pop);
if (!focusables.length) {
event.preventDefault();
focusPopupFallback(pop);
return;
}
const active = document.activeElement;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (
!focusables.includes(active) ||
(event.shiftKey && active === first) ||
(!event.shiftKey && active === last)
) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
}
}
function openPopup(id, source) {
const scrim = document.getElementById("popup-scrim");
const pop = popupFor(id);
if (!scrim || !pop) return;
const prior = activePopup();
if (prior && prior !== pop && source && prior.contains(source)) {
popupTrail.push({
pop: prior,
source,
scrollTop: prior.scrollTop,
returnFocus,
returnFocusKey,
});
} else if (!prior) {
popupTrail.length = 0;
}
rememberReturnFocus(source);
all(".pop", scrim).forEach((item) => {
clearPopupFallback(item);
item.hidden = item !== pop;
});
scrim.hidden = false;
pop.hidden = false;
document.body.style.overflow = "hidden";
const close = pop.querySelector(".ld-popup-close,.pop-close");
const focusables = popupFocusables(pop);
const initial =
close && focusables.includes(close) ? close : focusables[0];
if (initial) initial.focus();
else focusPopupFallback(pop);
updatePopupScrollHint(pop);
}
function closePopup() {
const scrim = document.getElementById("popup-scrim");
if (!scrim || scrim.hidden) return;
const prior = popupTrail.pop();
if (prior?.pop.isConnected) {
all(".pop", scrim).forEach((item) => {
item.hidden = item !== prior.pop;
clearPopupFallback(item);
});
prior.pop.scrollTop = prior.scrollTop;
returnFocus = prior.returnFocus;
returnFocusKey = prior.returnFocusKey;
if (prior.source.isConnected)
prior.source.focus({ preventScroll: true });
else focusPopupFallback(prior.pop);
updatePopupScrollHint(prior.pop);
return;
}
scrim.hidden = true;
all(".pop", scrim).forEach((item) => {
item.hidden = true;
clearPopupFallback(item);
});
document.body.style.overflow = "";
restoreReturnFocus();
}
function bindPopupDOM() {
all("[data-evidence],[data-detail]").forEach((node) => {
if (!node.matches("a,button,input,select,textarea,[tabindex]")) {
node.tabIndex = 0;
node.setAttribute("role", "button");
}
});
all("#popup-scrim .pop").forEach((pop) => {
if (pop.querySelector(".ld-popup-authoring"))
popupAuthoringControls(pop);
if (pop.__legalDesignPopupBound) return;
pop.__legalDesignPopupBound = true;
pop.addEventListener("scroll", () => {
updatePopupScrollHint(pop);
});
all("[data-evidence-field][data-editable]", pop).forEach((node) => {
if (node.__legalDesignEvidenceBound) return;
node.__legalDesignEvidenceBound = true;
node.addEventListener("blur", () => {
if (!node.isConnected || !node.hasAttribute("contenteditable"))
return;
node.removeAttribute("contenteditable");
node.removeAttribute("spellcheck");
textUndoArmed = false;
commitEditorDOM([node]);
});
});
});
all("#popup-scrim a[href]").forEach((link) => {
try {
const url = new URL(link.getAttribute("href"));
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new Error("scheme");
} catch (_) {
link.setAttribute("href", "#");
}
});
}
function bindPopups() {
bindPopupDOM();
document.addEventListener("click", (event) => {
if (suppressEditorClick) {
suppressEditorClick = false;
event.preventDefault();
return;
}
const trigger = event.target.closest("[data-evidence],[data-detail]");
if (editing && trigger && !trigger.closest(".pop")) return;
const interactive = event.target.closest(
"button,input,select,textarea,.ld-unit-tools",
);
if (trigger && interactive && interactive !== trigger) return;
const editableTarget =
editing && event.target.closest("[data-editable]");
if (trigger && editableTarget && trigger.contains(editableTarget))
return;
if (trigger) {
event.preventDefault();
openPopup(
trigger.getAttribute("data-evidence") ||
trigger.getAttribute("data-detail"),
trigger,
);
return;
}
if (
event.target.closest(".ld-popup-close,.pop-close") ||
event.target.id === "popup-scrim"
) {
event.preventDefault();
closePopup();
}
});
document.addEventListener("keydown", (event) => {
const pop = activePopup();
if (pop && event.key === "Tab") {
trapPopupTab(event, pop);
return;
}
const detail =
event.target.closest &&
event.target.closest("[data-evidence],[data-detail]");
const editableTarget =
editing &&
event.target.closest &&
event.target.closest("[data-editable]");
if (detail && editableTarget && detail.contains(editableTarget)) return;
if (detail && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
openPopup(
detail.getAttribute("data-evidence") ||
detail.getAttribute("data-detail"),
detail,
);
}
if (pop && event.key === "Escape") {
event.preventDefault();
closePopup();
}
});
}
function updatePopupScrollHint(pop) {
if (!pop) return;
pop.toggleAttribute(
"data-scroll-more",
pop.scrollTop + pop.clientHeight < pop.scrollHeight - 1,
);
}
function installAuthoringControls() {
const tools = document.querySelector(".ld-editor-tools");
if (!tools) return;
for (const [id, label] of [
["editor-add-text", "Text box"],
["editor-popup", "Add popup"],
["editor-remove-popup", "Remove popup"],
]) {
if (document.getElementById(id)) continue;
const button = document.createElement("button");
button.id = id;
button.type = "button";
button.className = "ld-tool";
button.textContent = label;
button.title = label;
tools.appendChild(button);
}
for (const [kind, tokens] of [
["fill", FILL_TOKENS],
["text", TEXT_TOKENS],
]) {
const select = document.getElementById(`editor-${kind}-token`);
if (!select) continue;
document.getElementById(`editor-${kind}-palette`)?.remove();
select.hidden = true;
const button = document.createElement("button");
button.id = `editor-${kind}-palette`;
button.type = "button";
button.className = "ld-tool";
button.textContent = kind === "fill" ? "Fill ▾" : "Text ▾";
button.setAttribute(
"aria-label",
kind === "fill" ? "Fill color" : "Text color",
);
button.setAttribute("aria-haspopup", "dialog");
button.setAttribute("aria-expanded", "false");
select.after(button);
button.addEventListener("click", () => {
const existing = document.getElementById("ld-color-palette");
const same = existing && existing.dataset.kind === kind;
if (existing) existing.remove();
all('[id$="-palette"].ld-tool').forEach((node) => {
node.setAttribute("aria-expanded", "false");
});
if (same) return;
const panel = document.createElement("div");
panel.id = "ld-color-palette";
panel.dataset.kind = kind;
panel.setAttribute("data-editor-only", "");
panel.setAttribute("role", "dialog");
panel.setAttribute(
"aria-label",
kind === "fill" ? "Choose fill color" : "Choose text color",
);
const names = {
card: "Card",
"card-strong": "Raised card",
tint: "Soft tint",
bg: "Page",
none: "No fill",
ink: "Primary text",
muted: "Secondary text",
faint: "Quiet text",
red: "Accent",
"red-strong": "Strong accent",
};
const heading = document.createElement("strong");
heading.textContent = kind === "fill" ? "Fill color" : "Text color";
panel.appendChild(heading);
const grid = document.createElement("div");
grid.className = "ld-swatch-grid";
const painted = selection.map((node) =>
node.getAttribute(`data-${kind}-token`),
);
const currentToken =
painted.length && painted.every((value) => value === painted[0])
? painted[0]
: null;
tokens.forEach((token) => {
const swatch = document.createElement("button");
swatch.type = "button";
swatch.className = "ld-color-swatch";
swatch.dataset.colorToken = token;
swatch.setAttribute("aria-label", names[token]);
swatch.setAttribute("aria-pressed", String(currentToken === token));
swatch.title = names[token];
swatch.style.background =
token === "none" ? "transparent" : `var(--${token})`;
if (token === "none") swatch.textContent = "∅";
swatch.addEventListener("click", () => {
applyToken(`data-${kind}-token`, token);
panel.remove();
button.setAttribute("aria-expanded", "false");
button.focus();
});
grid.appendChild(swatch);
});
panel.appendChild(grid);
const hint = document.createElement("small");
hint.textContent = "House palette · adapts to light and dark";
panel.appendChild(hint);
document.body.appendChild(panel);
const box = button.getBoundingClientRect();
panel.style.top =
Math.min(innerHeight - panel.offsetHeight - 8, box.bottom + 8) +
"px";
panel.style.left =
Math.max(
8,
Math.min(innerWidth - panel.offsetWidth - 8, box.left),
) + "px";
button.setAttribute("aria-expanded", "true");
grid.querySelector("button").focus();
panel.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.stopPropagation();
panel.remove();
button.setAttribute("aria-expanded", "false");
button.focus();
}
});
});
}
}
function newPopupId() {
let n = 1;
while (
state.evidence["detail-added-" + n] ||
document.getElementById("detail-added-" + n)
)
n++;
return "detail-added-" + n;
}
function cloneAttachedPopups(copy) {
const targets = all("[data-detail],[data-evidence]", copy);
if (copy.matches("[data-detail],[data-evidence]")) targets.unshift(copy);
const mapping = new Map();
targets.forEach((target) => {
const attr = target.hasAttribute("data-detail")
? "data-detail"
: "data-evidence";
const original = target.getAttribute(attr);
const pop = popupFor(original);
if (!pop) return;
if (!mapping.has(original)) {
const id = newPopupId();
const duplicate = pop.cloneNode(true);
duplicate.id = id;
duplicate.setAttribute("data-evidence-id", id);
duplicate.hidden = true;
all("[id]", duplicate).forEach((node) => {
node.removeAttribute("id");
});
duplicate.removeAttribute("aria-labelledby");
duplicate.setAttribute(
"aria-label",
state.evidence[original]?.popup?.title || "Detail",
);
if (state.evidence[original])
state.evidence[id] = clone(state.evidence[original]);
document.getElementById("popup-scrim").appendChild(duplicate);
mapping.set(original, id);
}
target.setAttribute(attr, mapping.get(original));
target.setAttribute("aria-controls", mapping.get(original));
});
}
function addTextBox() {
editorCheckpoint();
const selected = topSelection()[0];
const scope =
activePopup() ||
(selected && selected.closest("section.ld-unit")) ||
all("section.ld-unit").find(visible) ||
document.querySelector("main");
if (!scope) return;
if (getComputedStyle(scope).position === "static")
scope.style.position = "relative";
const node = document.createElement("p");
node.className = "ld-added-text";
node.setAttribute("data-editable", "");
node.setAttribute("data-editor-object", "text");
node.setAttribute("data-placeholder", "[Text explaining this point.]");
node.textContent = "Your text";
Object.assign(node.style, {
position: "absolute",
left: "24px",
top: "64px",
width: "240px",
margin: "0",
zIndex: "4",
});
scope.appendChild(node);
stampEditorOrigins(node);
commitEditorDOM([node]);
select([node]);
editText(node);
}
function addPopupSection(pop) {
const id = pop.getAttribute("data-evidence-id");
const item = state.evidence[id];
if (!item?.popup) return;
const index = item.popup.sections.length;
item.popup.sections.push({
heading: "Section heading",
body: "Explain this point.",
});
const section = document.createElement("section");
section.className = "ld-popup-section";
for (const [tag, field, text] of [
["h3", "heading", "Section heading"],
["p", "body", "Explain this point."],
]) {
const node = document.createElement(tag);
node.textContent = text;
node.setAttribute("data-editable", "");
node.setAttribute(
"data-evidence-field",
`popup.sections.${index}.${field}`,
);
node.setAttribute(
"data-placeholder",
field === "heading"
? "[Section heading.]"
: "[Explanation of this point.]",
);
section.appendChild(node);
}
const controls = pop.querySelector(".ld-popup-authoring");
pop.insertBefore(section, controls || null);
bindPopupDOM();
stampEditorOrigins(section);
commitEditorDOM([section]);
}
function popupAuthoringControls(pop) {
const existing = pop.querySelector(".ld-popup-authoring");
if (existing?.__bound) return;
existing?.remove();
const controls = document.createElement("div");
controls.className = "ld-popup-authoring";
controls.setAttribute("data-editor-only", "");
controls.__bound = true;
const add = document.createElement("button");
add.type = "button";
add.className = "ld-tool";
add.textContent = "Add section";
add.addEventListener("click", () => {
editorCheckpoint();
addPopupSection(pop);
});
controls.appendChild(add);
pop.appendChild(controls);
}
function editSelectedPopup() {
const target = topSelection().length === 1 && topSelection()[0];
if (!target) return;
let id =
target.getAttribute("data-detail") ||
target.getAttribute("data-evidence");
if (!id || !popupFor(id)) {
editorCheckpoint();
id = newPopupId();
const title =
(target.textContent || "Detail").trim().slice(0, 120) || "Detail";
state.evidence[id] = {
cite: "",
locator: "",
excerpt: "",
link: null,
status: "authored",
popup: {
type: "detail",
title,
lede: "Add the detail this element should explain.",
sections: [],
},
};
target.setAttribute("data-detail", id);
target.setAttribute("aria-controls", id);
target.setAttribute("aria-haspopup", "dialog");
target.setAttribute("role", "button");
target.setAttribute("tabindex", "0");
if (target instanceof SVGElement) target.classList.add("ld-detail");
const pop = document.createElement("article");
pop.id = id;
pop.className = "pop";
pop.hidden = true;
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-modal", "true");
pop.setAttribute("aria-label", title);
pop.setAttribute("data-evidence-id", id);
const close = document.createElement("button");
close.type = "button";
close.className = "ld-popup-close";
close.textContent = "×";
close.setAttribute("aria-label", "Close");
pop.appendChild(close);
for (const [tag, field, value] of [
["h2", "title", title],
["p", "lede", state.evidence[id].popup.lede],
]) {
const node = document.createElement(tag);
node.textContent = value;
node.setAttribute("data-editable", "");
node.setAttribute("data-evidence-field", "popup." + field);
if (field === "lede") node.className = "ld-popup-lede";
pop.appendChild(node);
}
document.getElementById("popup-scrim").appendChild(pop);
addPopupSection(pop);
bindPopupDOM();
commitEditorDOM([target, pop]);
}
const pop = popupFor(id);
popupAuthoringControls(pop);
stampEditorOrigins(pop);
select([]);
openPopup(id, target);
}
function removeSelectedPopup() {
const target = topSelection().length === 1 && topSelection()[0];
if (!target) return;
editorCheckpoint();
[
"data-detail",
"data-evidence",
"aria-controls",
"aria-haspopup",
].forEach((attr) => {
target.removeAttribute(attr);
});
target.classList.remove("ld-detail");
if (
target.getAttribute("role") === "button" &&
!target.matches("button")
) {
target.removeAttribute("role");
target.removeAttribute("tabindex");
}
// Keep the detached content recoverable through Undo and other references.
commitEditorDOM([target]);
}
function editorTargets() {
return all(EDITOR_OBJECTS).filter(visible);
}
function editorObjectTargets(scope) {
return all(EDITOR_OBJECTS, scope || document).filter(
(node) =>
visible(node) &&
!node.closest("[data-editor-only],.ld-slide-index") &&
(!activePopup() || activePopup().contains(node)),
);
}
function stampEditorOrigins(scope) {
all(".ld-diagram", scope || document).forEach((host) => {
all('svg [data-diagram-object],svg g[role="button"]', host).forEach(
(node, index) => {
if (!node.hasAttribute("data-editor-node-key"))
node.setAttribute("data-editor-node-key", "node-" + (index + 1));
},
);
});
annotateDiagramLabels(scope || document);
const selector = EDITOR_OBJECTS;
const nodes = all(selector, scope || document);
if (scope && scope.matches && scope.matches(selector))
nodes.unshift(scope);
nodes.forEach((node) => {
if (node.closest("[data-editor-only]")) return;
node.setAttribute("data-editor-unit", "1");
if (!node.hasAttribute("data-editor-origin-style-stamped")) {
node.setAttribute("data-editor-origin-style-stamped", "true");
node.setAttribute(
"data-editor-origin-style-present",
node.hasAttribute("style") ? "true" : "false",
);
node.setAttribute(
"data-editor-origin-style",
node.getAttribute("style") || "",
);
["fill-token", "text-token"].forEach((key) => {
const source = "data-" + key;
node.setAttribute(
"data-editor-origin-" + key + "-present",
node.hasAttribute(source) ? "true" : "false",
);
node.setAttribute(
"data-editor-origin-" + key,
node.getAttribute(source) || "",
);
});
}
});
}
function topSelection() {
return selection.filter(
(node) =>
!selection.some((other) => other !== node && other.contains(node)),
);
}
function boxFor(nodes) {
const boxes = nodes
.filter(visible)
.map((node) => node.getBoundingClientRect());
if (!boxes.length) return null;
const left = Math.min.apply(
null,
boxes.map((box) => box.left),
);
const top = Math.min.apply(
null,
boxes.map((box) => box.top),
);
const right = Math.max.apply(
null,
boxes.map((box) => box.right),
);
const bottom = Math.max.apply(
null,
boxes.map((box) => box.bottom),
);
return {
left: left,
top: top,
right: right,
bottom: bottom,
width: right - left,
height: bottom - top,
};
}
function makeEditorLayer() {
if (document.getElementById("ld-editor-layer")) return;
const layer = document.createElement("div");
layer.id = "ld-editor-layer";
layer.setAttribute("data-editor-only", "");
const box = document.createElement("div");
box.id = "ld-selection-box";
["nw", "n", "ne", "e", "se", "s", "sw", "w"].forEach((name) => {
const handle = document.createElement("span");
handle.className = "ld-handle";
handle.setAttribute("data-handle", name);
box.appendChild(handle);
});
const marquee = document.createElement("div");
marquee.id = "ld-marquee";
layer.appendChild(box);
layer.appendChild(marquee);
document.body.appendChild(layer);
}
function renderSelection() {
all(".ld-selected").forEach((node) => {
node.classList.remove("ld-selected");
});
selection.forEach((node) => {
node.classList.add("ld-selected");
});
// Context controls normally sit above their object. A wrapped phone
// toolbar can occupy that space, so move only the controls below it.
// The authored object and page geometry remain untouched.
const barBottom = Math.max(
0,
...all(".ld-bar,#ld-page-approach,.ld-save-banner")
.filter((node) => !node.hidden && node.getClientRects().length)
.map((node) => node.getBoundingClientRect().bottom),
);
all(".ld-fixed-page .ld-unit-tools").forEach((tools) => {
tools.style.removeProperty("--ld-context-offset");
if (!editing || !tools.getClientRects().length) return;
const offset = barBottom + 4 - tools.getBoundingClientRect().top;
if (offset > 0)
tools.style.setProperty("--ld-context-offset", offset + "px");
});
const control = document.getElementById("ld-selection-box");
if (!control) return;
const bounds = boxFor(selection);
updateEditorButtons();
control.toggleAttribute(
"data-diagram-label-selection",
selection.length === 1 &&
selection[0].hasAttribute("data-diagram-label"),
);
if (!bounds || !editing) {
control.style.display = "none";
return;
}
control.style.display = "block";
control.style.left = bounds.left + "px";
control.style.top = bounds.top + "px";
control.style.width = bounds.width + "px";
control.style.height = bounds.height + "px";
const close = activePopup()?.querySelector(".ld-popup-close,.pop-close");
const closeBox = close && close.getBoundingClientRect();
all(".ld-handle", control).forEach((handle) => {
const box = handle.getBoundingClientRect();
const overlapsClose =
closeBox &&
box.left < closeBox.right + 4 &&
box.right > closeBox.left - 4 &&
box.top < closeBox.bottom + 4 &&
box.bottom > closeBox.top - 4;
handle.style.visibility = overlapsClose ? "hidden" : "";
});
updateEditorButtons();
}
function select(nodes, additive) {
if (!additive) selection = [];
nodes.forEach((node) => {
if (node && node.isConnected && !selection.includes(node))
selection.push(node);
});
renderSelection();
}
function editorCheckpoint() {
checkpoint();
}
function translate(node, x, y) {
x = Math.round(x * 10) / 10;
y = Math.round(y * 10) / 10;
node.setAttribute("data-editor-x", String(x));
node.setAttribute("data-editor-y", String(y));
let base = node.getAttribute("data-editor-base-transform");
if (base == null) {
base = node.style.transform || "";
node.setAttribute("data-editor-base-transform", base);
}
const sx = Number(node.getAttribute("data-editor-scale-x") || 1);
const sy = Number(node.getAttribute("data-editor-scale-y") || 1);
node.style.transform =
`translate(${x}px,${y}px) ${base} scale(${sx},${sy})`.trim();
}
function localDelta(node, dx, dy) {
// Pointer coordinates are CSS pixels; SVG transforms use parent units.
const parent = node instanceof SVGElement && node.parentElement;
const matrix = parent && parent.getScreenCTM && parent.getScreenCTM();
if (!matrix) {
const scale = pageScaleFor(node);
return { x: dx / scale, y: dy / scale };
}
const inverse = matrix.inverse();
return {
x: inverse.a * dx + inverse.c * dy,
y: inverse.b * dx + inverse.d * dy,
};
}
function deleteSelection() {
if (!selection.length) return;
editorCheckpoint();
const changed = topSelection()
.map((node) => node.closest("section.ld-unit[data-unit]"))
.filter(Boolean);
topSelection().forEach((node) => {
node.remove();
});
selection = [];
commitEditorDOM(changed);
}
function nextCopyUnitId(source) {
const base = (source || "unit") + "-copy";
let serial = 1;
while (state.units && state.units[base + "-" + serial]) serial += 1;
return base + "-" + serial;
}
function clearCloneMetadata(copy) {
all("[id]", copy)
.concat(copy.id ? [copy] : [])
.forEach((item) => {
item.removeAttribute("id");
});
const editorNames = [
"data-editor-unit",
"data-editor-origin-style",
"data-editor-origin-style-present",
"data-editor-origin-style-stamped",
"data-editor-origin-fill-token",
"data-editor-origin-fill-token-present",
"data-editor-origin-text-token",
"data-editor-origin-text-token-present",
"data-editor-x",
"data-editor-y",
"data-editor-base-transform",
"contenteditable",
"spellcheck",
];
all("*", copy)
.concat([copy])
.forEach((item) => {
editorNames.forEach((name) => {
item.removeAttribute(name);
});
item.classList.remove("ld-selected", "ld-selection-mark");
});
}
function duplicateSelection() {
if (!selection.length) return;
editorCheckpoint();
const copies = [];
topSelection().forEach((node) => {
const copy = node.cloneNode(true);
clearCloneMetadata(copy);
cloneAttachedPopups(copy);
let sourceSections = [];
if (node.matches && node.matches("section.ld-unit[data-unit]"))
sourceSections.push(node);
sourceSections = sourceSections.concat(
all("section.ld-unit[data-unit]", node),
);
let copySections = [];
if (copy.matches && copy.matches("section.ld-unit[data-unit]"))
copySections.push(copy);
copySections = copySections.concat(
all("section.ld-unit[data-unit]", copy),
);
sourceSections.forEach((sourceSection, index) => {
const duplicateSection = copySections[index];
if (!duplicateSection) return;
const sourceId = sourceSection.getAttribute("data-unit");
const copyId = nextCopyUnitId(sourceId);
duplicateSection.setAttribute("data-unit", copyId);
if (state.units && state.units[sourceId])
state.units[copyId] = clone(state.units[sourceId]);
});
const parent = node.parentElement;
const box = node.getBoundingClientRect();
const parentBox = parent.getBoundingClientRect();
const position = getComputedStyle(node).position;
if (node instanceof SVGElement) {
const delta = localDelta(node, 20, 20);
copy.setAttribute(
"data-editor-base-transform",
node.getAttribute("data-editor-base-transform") || "",
);
translate(
copy,
Number(node.getAttribute("data-editor-x") || 0) + delta.x,
Number(node.getAttribute("data-editor-y") || 0) + delta.y,
);
} else if (position !== "absolute" && position !== "fixed") {
if (getComputedStyle(parent).position === "static")
parent.style.position = "relative";
copy.style.position = "absolute";
copy.style.left =
box.left - parentBox.left + parent.scrollLeft + 16 + "px";
copy.style.top =
box.top - parentBox.top + parent.scrollTop + 16 + "px";
copy.style.width = box.width + "px";
copy.style.margin = "0";
copy.style.zIndex = "4";
} else {
translate(
copy,
Number(node.getAttribute("data-editor-x") || 0) + 16,
Number(node.getAttribute("data-editor-y") || 0) + 16,
);
}
node.insertAdjacentElement("afterend", copy);
stampEditorOrigins(copy);
copies.push(copy);
});
bindUnits();
bindDecisions();
bindPopupDOM();
commitEditorDOM(copies);
select(copies);
}
function eachSelection(fn) {
if (!selection.length) return;
editorCheckpoint();
selection.forEach(fn);
commitEditorDOM(selection);
}
function tokenValue(attribute, value) {
const allowed =
attribute === "data-fill-token"
? FILL_TOKENS
: attribute === "data-text-token"
? TEXT_TOKENS
: [];
return allowed.includes(value) ? value : "";
}
function applyToken(attribute, value) {
value = tokenValue(attribute, value);
if (!value) return;
eachSelection((node) => {
node.setAttribute(attribute, value);
});
}
function toggleStyle(property, on, off) {
eachSelection((node) => {
node.style[property] = getComputedStyle(node)[property].includes(on)
? off
: on;
});
}
function changeFont(delta) {
eachSelection((node) => {
node.style.fontSize =
Math.max(
11,
Math.min(
180,
(parseFloat(getComputedStyle(node).fontSize) || 14) + delta,
),
) + "px";
});
}
function resetSelection() {
eachSelection((node) => {
if (node.getAttribute("data-editor-origin-style-present") === "true")
node.setAttribute(
"style",
node.getAttribute("data-editor-origin-style") || "",
);
else node.removeAttribute("style");
["fill-token", "text-token"].forEach((key) => {
if (
node.getAttribute("data-editor-origin-" + key + "-present") ===
"true"
)
node.setAttribute(
"data-" + key,
node.getAttribute("data-editor-origin-" + key) || "",
);
else node.removeAttribute("data-" + key);
});
[
"data-editor-x",
"data-editor-y",
"data-editor-base-transform",
"data-editor-scale-x",
"data-editor-scale-y",
].forEach((name) => {
node.removeAttribute(name);
});
});
}
function canEditText(node) {
return (
node &&
node.hasAttribute("data-editable") &&
(!(node instanceof SVGElement) ||
node.hasAttribute("data-diagram-label")) &&
!node.matches("img,input,select,button")
);
}
function setDiagramLabelText(node, value) {
const lines = all("tspan", node);
if (!lines.length) {
node.textContent = value;
return;
}
const words = value.trim().split(/\s+/).filter(Boolean);
const wanted = Math.max(1, Math.min(lines.length, words.length || 1));
const chunks = [];
for (let index = 0; index < wanted; index += 1) {
const start = Math.floor((index * words.length) / wanted);
const end = Math.floor(((index + 1) * words.length) / wanted);
chunks.push(words.slice(start, end).join(" "));
}
lines.forEach((line, index) => {
if (index < chunks.length) line.textContent = chunks[index];
else line.remove();
});
}
function syncDiagramLabelParam(node, value) {
const pointer = node.getAttribute("data-param-path");
const section = node.closest("section.ld-unit[data-unit]");
const variant = node.closest(".ld-variant[data-variant]");
if (!pointer || !section || !variant) return false;
const unit = unitState(section.getAttribute("data-unit"));
const spec =
unit &&
unit.variants &&
unit.variants[variant.getAttribute("data-variant")];
return Boolean(spec && pointerValue(spec.params, pointer, value));
}
function editDiagramLabel(node) {
select([node]);
editorCheckpoint();
const old = document.getElementById("ld-svg-text-editor");
if (old) old.remove();
const box = node.getBoundingClientRect();
const style = getComputedStyle(node);
const input = document.createElement("input");
input.id = "ld-svg-text-editor";
input.type = "text";
input.value = node.textContent.trim();
input.setAttribute("aria-label", "Edit diagram label");
Object.assign(input.style, {
position: "fixed",
zIndex: "1000",
left: Math.max(8, box.left - 6) + "px",
top: Math.max(8, box.top - 5) + "px",
width: Math.max(160, box.width + 28) + "px",
minHeight: Math.max(34, box.height + 10) + "px",
border: "2px solid var(--interaction)",
borderRadius: "4px",
background: "var(--card)",
color: "var(--ink)",
padding: "5px 8px",
fontFamily: style.fontFamily,
fontSize: Math.max(13, parseFloat(style.fontSize) || 13) + "px",
});
document.body.appendChild(input);
let finished = false;
const finish = (commit) => {
if (finished) return;
finished = true;
if (commit && input.value.trim()) {
const value = input.value.trim();
syncDiagramLabelParam(node, value);
setDiagramLabelText(node, value);
commitEditorDOM([node]);
}
input.remove();
renderSelection();
};
input.addEventListener("blur", () => finish(true));
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
finish(true);
} else if (event.key === "Escape") {
event.preventDefault();
finish(false);
}
});
input.focus();
input.select();
}
function editText(node) {
if (!canEditText(node)) return;
if (node.hasAttribute("data-diagram-label")) {
editDiagramLabel(node);
return;
}
all('[contenteditable="true"]').forEach((active) => {
if (active !== node) active.blur();
});
select([node]);
editorCheckpoint();
textUndoArmed = true;
node.setAttribute("contenteditable", "true");
node.setAttribute("spellcheck", "false");
node.focus();
const range = document.createRange();
range.selectNodeContents(node);
const current = getSelection();
current.removeAllRanges();
current.addRange(range);
}
function updateEditorButtons() {
[
"editor-duplicate",
"editor-delete",
"editor-bold",
"editor-italic",
"editor-underline",
"editor-font-up",
"editor-font-down",
"editor-reset",
"editor-fill-token",
"editor-text-token",
"editor-fill-palette",
"editor-text-palette",
"editor-popup",
"editor-remove-popup",
].forEach((id) => {
const node = document.getElementById(id);
if (node) node.disabled = !selection.length;
});
const popup = document.getElementById("editor-popup");
const remove = document.getElementById("editor-remove-popup");
const target = topSelection().length === 1 ? topSelection()[0] : null;
const id =
target &&
(target.getAttribute("data-detail") ||
target.getAttribute("data-evidence"));
if (popup) {
popup.disabled = !target;
popup.textContent = id ? "Edit popup" : "Add popup";
}
if (remove) remove.disabled = !id;
const u = document.getElementById("editor-undo");
const r = document.getElementById("editor-redo");
if (u) u.disabled = !undo.length;
if (r) r.disabled = !redo.length;
}
function setMode(on) {
if (!on)
all('[contenteditable="true"]').forEach((node) => {
node.blur();
});
editing = Boolean(on);
root.toggleAttribute("data-mode", editing);
if (editing) root.setAttribute("data-mode", "edit");
const mode = document.getElementById("mode-toggle");
if (mode) {
mode.textContent = editing ? "Done" : "Edit";
mode.setAttribute("aria-pressed", editing ? "true" : "false");
}
if (editing) {
all("section.ld-unit[data-unit]").forEach((unit) => {
if (!unit.hasAttribute("tabindex")) {
unit.tabIndex = 0;
unit.setAttribute("data-editor-added-tabindex", "");
}
});
stampEditorOrigins();
makeEditorLayer();
} else {
selection = [];
root.removeAttribute("data-editor-menu");
all("[contenteditable]").forEach((node) => {
node.removeAttribute("contenteditable");
node.removeAttribute("spellcheck");
});
all("[data-editor-added-tabindex]").forEach((unit) => {
unit.removeAttribute("data-editor-added-tabindex");
unit.removeAttribute("tabindex");
});
textUndoArmed = false;
}
annotateDiagramLabels(document);
renderSelection();
updateEditorButtons();
}
function bindEditor() {
document.addEventListener(
"pointerdown",
() => root.setAttribute("data-input-modality", "pointer"),
true,
);
document.addEventListener(
"keydown",
(event) => {
if (event.key === "Tab" || event.key.startsWith("Arrow"))
root.setAttribute("data-input-modality", "keyboard");
},
true,
);
const mode = document.getElementById("mode-toggle");
if (mode)
mode.addEventListener("click", () => {
setMode(!editing);
});
function bind(id, fn) {
const node = document.getElementById(id);
if (node) node.addEventListener("click", fn);
}
bind("editor-undo", doUndo);
bind("editor-redo", doRedo);
bind("editor-duplicate", duplicateSelection);
bind("editor-delete", deleteSelection);
bind("editor-bold", () => {
toggleStyle("fontWeight", "700", "400");
});
bind("editor-italic", () => {
toggleStyle("fontStyle", "italic", "normal");
});
bind("editor-underline", () => {
toggleStyle("textDecorationLine", "underline", "none");
});
bind("editor-font-up", () => {
changeFont(1);
});
bind("editor-font-down", () => {
changeFont(-1);
});
bind("editor-reset", resetSelection);
bind("editor-add-text", addTextBox);
bind("editor-popup", editSelectedPopup);
bind("editor-remove-popup", removeSelectedPopup);
bind("editor-overflow-toggle", () => {
const open = root.getAttribute("data-editor-menu") === "open";
root.toggleAttribute("data-editor-menu", !open);
if (!open) root.setAttribute("data-editor-menu", "open");
const button = document.getElementById("editor-overflow-toggle");
if (button)
button.setAttribute("aria-expanded", !open ? "true" : "false");
});
const fill = document.getElementById("editor-fill-token");
if (fill)
fill.addEventListener("change", () => {
applyToken("data-fill-token", fill.value);
fill.value = "";
});
const text = document.getElementById("editor-text-token");
if (text)
text.addEventListener("change", () => {
applyToken("data-text-token", text.value);
text.value = "";
});
document.addEventListener(
"pointerdown",
(event) => {
if (
!editing ||
event.button !== 0 ||
event.target.closest(
'.ld-bar,.ld-unit-tools,.ld-slide-index,[data-editor-only]:not(#ld-editor-layer):not(#ld-selection-box),input,textarea,select,button:not([data-detail]):not([data-evidence]),[contenteditable="true"]',
)
)
return;
const handle = event.target.closest(".ld-handle");
if (handle) {
event.preventDefault();
const bounds = boxFor(selection);
if (!bounds) return;
editorCheckpoint();
gesture = {
type: "resize",
handle: handle.getAttribute("data-handle"),
x: event.clientX,
y: event.clientY,
bounds: bounds,
starts: topSelection().map((node) => {
const box = node.getBoundingClientRect();
if (!node.hasAttribute("data-editor-base-transform"))
node.setAttribute(
"data-editor-base-transform",
node.style.transform || "",
);
return {
node: node,
box: box,
x: Number(node.getAttribute("data-editor-x") || 0),
y: Number(node.getAttribute("data-editor-y") || 0),
sx: Number(node.getAttribute("data-editor-scale-x") || 1),
sy: Number(node.getAttribute("data-editor-scale-y") || 1),
};
}),
};
return;
}
const diagramLabel = event.target.closest(
".ld-diagram [data-diagram-label][data-editable]",
);
let target = event.target.closest(EDITOR_OBJECTS);
if (diagramLabel && event.detail < 2) {
target =
diagramLabel.closest('[data-diagram-object],g[role="button"]') ||
diagramLabel;
}
const card = event.target.closest(
'.ld-card,.sb-card,section.ld-unit[data-kind="card"]',
);
if (card && event.detail < 2) target = card;
// Empty space in a figure starts an enclosure selection, not a drag
// of its whole editable body. Existing group selection survives a grab.
if (
event.target.closest(".ld-diagram") &&
!event.target.closest(
'[data-diagram-object],g[role="button"],[data-diagram-label]',
)
)
target = null;
if (target) {
event.preventDefault();
if (event.detail >= 2 && diagramLabel) {
event.stopImmediatePropagation();
gesture = null;
editText(diagramLabel);
return;
}
const selectedOwner = selection.find(
(node) => node === target || node.contains(target),
);
if (!selectedOwner || event.shiftKey)
select([target], event.shiftKey);
gesture = {
type: "pending",
x: event.clientX,
y: event.clientY,
starts: topSelection().map((node) => ({
node: node,
x: Number(node.getAttribute("data-editor-x") || 0),
y: Number(node.getAttribute("data-editor-y") || 0),
})),
};
return;
}
const marquee = document.getElementById("ld-marquee");
if (marquee) {
if (!event.shiftKey) select([]);
gesture = {
type: "marquee",
x: event.clientX,
y: event.clientY,
base: selection.slice(),
};
marquee.style.display = "block";
marquee.style.left = event.clientX + "px";
marquee.style.top = event.clientY + "px";
}
},
true,
);
document.addEventListener(
"pointermove",
(event) => {
if (!gesture) return;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
if (gesture.type === "pending" && Math.hypot(dx, dy) > 3) {
editorCheckpoint();
gesture.type = "move";
}
if (gesture.type === "move") {
event.preventDefault();
gesture.starts.forEach((start) => {
const delta = localDelta(start.node, dx, dy);
translate(start.node, start.x + delta.x, start.y + delta.y);
});
renderSelection();
}
if (gesture.type === "resize") {
event.preventDefault();
const horizontal = gesture.handle.includes("e")
? 1
: gesture.handle.includes("w")
? -1
: 0;
const vertical = gesture.handle.includes("s")
? 1
: gesture.handle.includes("n")
? -1
: 0;
const nextWidth = Math.max(
12,
gesture.bounds.width + dx * horizontal,
);
const nextHeight = Math.max(
12,
gesture.bounds.height + dy * vertical,
);
const scaleX = nextWidth / gesture.bounds.width;
const scaleY = nextHeight / gesture.bounds.height;
gesture.starts.forEach((start) => {
const node = start.node;
const left = start.box.left - gesture.bounds.left;
const top = start.box.top - gesture.bounds.top;
const width = Math.max(8, start.box.width * scaleX);
const height = Math.max(8, start.box.height * scaleY);
if (!(node instanceof SVGElement)) {
const pageScale = pageScaleFor(node);
if (getComputedStyle(node).display === "inline")
node.style.display = "inline-block";
if (horizontal) {
node.style.width = width / pageScale + "px";
node.style.maxWidth = "none";
}
if (vertical) {
if (
/^(P|H1|H2|H3|H4|SPAN|B|I|A|DEL|INS|MARK|FIGCAPTION|TD|TH)$/.test(
node.tagName,
)
) {
node.style.minHeight = height / pageScale + "px";
node.style.height = "auto";
} else node.style.height = height / pageScale + "px";
}
node.style.boxSizing = "border-box";
}
const nextX =
start.x + left * (scaleX - 1) + (horizontal < 0 ? dx : 0);
const nextY =
start.y + top * (scaleY - 1) + (vertical < 0 ? dy : 0);
if (node instanceof SVGElement) {
const delta = localDelta(
node,
nextX - start.x,
nextY - start.y,
);
node.style.transformBox = "fill-box";
node.style.transformOrigin = "top left";
node.setAttribute(
"data-editor-scale-x",
String(start.sx * scaleX),
);
node.setAttribute(
"data-editor-scale-y",
String(start.sy * scaleY),
);
translate(node, start.x + delta.x, start.y + delta.y);
} else {
const delta = localDelta(
node,
nextX - start.x,
nextY - start.y,
);
translate(node, start.x + delta.x, start.y + delta.y);
}
});
renderSelection();
}
if (gesture.type === "marquee") {
const left = Math.min(gesture.x, event.clientX);
const top = Math.min(gesture.y, event.clientY);
const right = Math.max(gesture.x, event.clientX);
const bottom = Math.max(gesture.y, event.clientY);
const marquee = document.getElementById("ld-marquee");
marquee.style.left = left + "px";
marquee.style.top = top + "px";
marquee.style.width = right - left + "px";
marquee.style.height = bottom - top + "px";
const hits = editorObjectTargets().filter((node) => {
const box = node.getBoundingClientRect();
return (
box.left >= left &&
box.right <= right &&
box.top >= top &&
box.bottom <= bottom
);
});
const outerHits = hits.filter(
(node) =>
!hits.some(
(parent) => parent !== node && parent.contains(node),
),
);
selection = gesture.base.concat(
outerHits.filter((node) => !gesture.base.includes(node)),
);
renderSelection();
}
},
true,
);
document.addEventListener(
"pointerup",
() => {
const marquee = document.getElementById("ld-marquee");
if (marquee) marquee.style.display = "none";
if (
gesture &&
(gesture.type === "move" || gesture.type === "resize")
) {
suppressEditorClick = true;
commitEditorDOM(gesture.starts.map((start) => start.node));
setTimeout(() => {
suppressEditorClick = false;
}, 0);
}
gesture = null;
},
true,
);
document.addEventListener(
"dblclick",
(event) => {
if (!editing) return;
const target = event.target.closest("[data-editable]");
if (target) {
event.preventDefault();
event.stopImmediatePropagation();
editText(target);
}
},
true,
);
window.addEventListener("keydown", (event) => {
if (!editing) return;
const focusedUnit =
event.target.matches &&
event.target.matches("section.ld-unit[data-unit]")
? event.target
: null;
if (focusedUnit && (event.key === "Enter" || event.key === " ")) {
const first = editorTargets().find((node) =>
focusedUnit.contains(node),
);
if (first) {
event.preventDefault();
event.stopImmediatePropagation();
if (
event.key === "Enter" &&
selection.length === 1 &&
selection[0] === first &&
canEditText(first)
)
editText(first);
else select([first]);
}
return;
}
const typing =
event.target.closest &&
event.target.closest(
'[contenteditable="true"],input,textarea,select',
);
const key = event.key.toLowerCase();
const mod = event.metaKey || event.ctrlKey;
if (mod && key === "z") {
event.preventDefault();
event.stopImmediatePropagation();
event.shiftKey ? doRedo() : doUndo();
} else if (mod && key === "d" && !typing) {
event.preventDefault();
event.stopImmediatePropagation();
duplicateSelection();
} else if (
(event.key === "Delete" || event.key === "Backspace") &&
!typing
) {
event.preventDefault();
event.stopImmediatePropagation();
deleteSelection();
} else if (event.key === "Escape") {
event.preventDefault();
event.stopImmediatePropagation();
if (typing && typing.hasAttribute("contenteditable")) typing.blur();
select([]);
} else if (
event.key.startsWith("Arrow") &&
!typing &&
selection.length
) {
event.preventDefault();
event.stopImmediatePropagation();
let dx =
event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : 0;
let dy =
event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
if (event.shiftKey) {
dx *= 10;
dy *= 10;
}
editorCheckpoint();
topSelection().forEach((node) => {
translate(
node,
Number(node.getAttribute("data-editor-x") || 0) + dx,
Number(node.getAttribute("data-editor-y") || 0) + dy,
);
});
commitEditorDOM(selection);
}
});
document.addEventListener(
"beforeinput",
(event) => {
if (
editing &&
event.target.closest &&
event.target.closest('[contenteditable="true"]') &&
!textUndoArmed
) {
editorCheckpoint();
textUndoArmed = true;
}
},
true,
);
document.addEventListener(
"input",
(event) => {
if (
editing &&
event.target.closest &&
event.target.closest('[contenteditable="true"]')
)
commitEditorDOM([event.target]);
},
true,
);
document.addEventListener(
"focusout",
(event) => {
const node = event.target;
if (
!editing ||
!node.matches?.('[data-editable][contenteditable="true"]')
)
return;
node.removeAttribute("contenteditable");
node.removeAttribute("spellcheck");
textUndoArmed = false;
commitEditorDOM([node]);
},
true,
);
document.addEventListener("scroll", renderSelection, true);
onLayoutResize(() => {
// Choose responsive diagram geometry against the newly fitted page,
// not its previous width. The fit listener schedules a later frame;
// rendering first would leave wide/narrow mode one resize behind.
if (pageFitFrame) cancelAnimationFrame(pageFitFrame);
fitFixedPages();
renderAll();
renderSelection();
});
}
function clearProtected(cloneRoot) {
all("[data-export-protect]", cloneRoot).forEach((node) => {
(node.getAttribute("data-export-protect") || "")
.split(/[\s,]+/)
.filter(Boolean)
.forEach((name) => {
node.style.removeProperty(name);
node.removeAttribute(name);
});
});
}
function serializeClone(cloneRoot) {
return (
"<!doctype html>\n" +
cloneRoot.outerHTML.replaceAll(
' xmlns="http://www.w3.org/2000/svg"',
"",
)
);
}
function setCloneState(cloneRoot, nextState) {
const block = cloneRoot.querySelector("#legaldesign-state");
if (block) block.textContent = safeJSON(nextState);
}
function scrubTransientEditing(cloneRoot) {
all("[contenteditable]", cloneRoot).forEach((node) => {
node.removeAttribute("contenteditable");
node.removeAttribute("spellcheck");
});
all(".ld-selected,.ld-selection-mark", cloneRoot).forEach((node) => {
node.classList.remove("ld-selected", "ld-selection-mark");
});
all(
"#ld-editor-layer,#ld-svg-text-editor,#ld-selection-popover,#ld-phone-sheet,#ld-restore-banner,.ld-save-banner,.ld-download-link,.ld-bottom-save,#ld-page-reader,#ld-read-page,#ld-page-fit-error",
cloneRoot,
).forEach((node) => {
node.remove();
});
all("[data-editor-added-tabindex]", cloneRoot).forEach((unit) => {
unit.removeAttribute("data-editor-added-tabindex");
unit.removeAttribute("tabindex");
});
cloneRoot.removeAttribute("data-mode");
cloneRoot.removeAttribute("data-editor-menu");
const mode = cloneRoot.querySelector("#mode-toggle");
if (mode) {
mode.textContent = "Edit";
mode.setAttribute("aria-pressed", "false");
}
const overflow = cloneRoot.querySelector("#editor-overflow-toggle");
if (overflow) overflow.setAttribute("aria-expanded", "false");
const scrim = cloneRoot.querySelector("#popup-scrim");
if (scrim) {
scrim.hidden = true;
all(".pop", scrim).forEach((pop) => {
pop.hidden = true;
});
}
const body = cloneRoot.querySelector("body");
if (body && body.style.overflow === "hidden")
body.style.removeProperty("overflow");
}
function scrubEditing(cloneRoot) {
scrubTransientEditing(cloneRoot);
clearProtected(cloneRoot);
}
function reducedClientState(cloneRoot, clientBlueprintId) {
const brief = state.brief || {};
const next = {
schema: state.schema,
artifactId: clientBlueprintId,
savedAt: new Date().toISOString(),
brief: {
title: "[Descriptive title for the visible artifact.]",
reader: "[Reader described by the visible artifact.]",
action: "[Action described by the visible artifact.]",
purpose: brief.purpose || "understand",
message: "[Message stated in the visible artifact.]",
spine: brief.spine || "sequence",
situation: brief.situation || "laptop",
form: brief.form || "one-page",
sources: [],
assumptions: [],
gaps: [],
},
units: {},
evidence: {},
review: {
...(singleComposition() ? {} : { approach: currentApproach() }),
decisions: {},
theme: (state.review && state.review.theme) || "light",
location:
state.review && state.review.location != null
? state.review.location
: null,
},
};
if (state.sourceSchemaVersion != null)
next.sourceSchemaVersion = state.sourceSchemaVersion;
if (state.approaches) {
const approach = currentApproach();
if (state.approaches[approach])
next.approaches = {
[approach]: clone(state.approaches[approach]),
};
}
if (state.composition) {
next.composition = {
strategy: state.composition.strategy,
sections: (state.composition.sections || []).map((section) => ({
id: section.id,
unitIds: [...section.unitIds],
layout: clone(section.layout),
...(section.issueLayout
? { issueLayout: clone(section.issueLayout) }
: {}),
})),
};
}
if (state.overview) next.overview = clone(state.overview);
if (state.style)
next.style = { source: state.style.source || "loxoto", ref: null };
const presentIds = [];
all("section.ld-unit[data-unit]", cloneRoot).forEach((section) => {
const id = section.getAttribute("data-unit");
const sourceUnit = state.units && state.units[id];
if (!sourceUnit) return;
presentIds.push(id);
const selected = sourceUnit.selected === "b" ? "b" : "a";
const variant = section.querySelector(
'.ld-variant[data-variant="' + selected + '"]',
);
const body = variant && variant.querySelector("[data-variant-body]");
const finalHTML = body ? body.innerHTML : "";
const axis =
(sourceUnit.variants &&
sourceUnit.variants[selected] &&
sourceUnit.variants[selected].axis) ||
"single";
const canonicalVariant = { axis: axis, why: "" };
if (sourceUnit.kind !== "figure") canonicalVariant.html = finalHTML;
const unit = {
kind: sourceUnit.kind,
selected: selected,
variants: { a: clone(canonicalVariant) },
edits: { a: selected === "a" ? finalHTML : null },
placeholder:
sourceUnit.kind === "card"
? PLACEHOLDERS.cardLine
: sourceUnit.kind === "figure"
? PLACEHOLDERS.figureLabel
: sourceUnit.kind === "decision"
? "[The decision the reader must make.]"
: PLACEHOLDERS.title,
};
if (selected === "b") {
unit.variants.b = clone(canonicalVariant);
unit.edits.b = sourceUnit.kind === "figure" ? null : finalHTML;
}
next.units[id] = unit;
});
Object.keys((state.review && state.review.decisions) || {}).forEach(
(id) => {
if (presentIds.includes(id))
next.review.decisions[id] = clone(state.review.decisions[id]);
},
);
return next;
}
function placeholderFor(component, key, path, value) {
if (value === "") return value;
if (TEMPLATE_STRUCTURAL_PARAM_KEYS.has(key)) return value;
if (key === "sub" || /Sub$/.test(key))
return "[One fact about it, under twenty words.]";
const phrases = {
flow: {
title: "[Stage name.]",
edges: "[What passes to the next stage.]",
},
timeline: { when: "[Date.]", title: "[What happened.]" },
hierarchy: {
root: "[The authority at the top.]",
title: path.includes("leaves") ? "[Leaf.]" : "[Branch.]",
edge: "[What connects it.]",
},
zones: {
zoneA: "[Zone label.]",
zoneB: "[Zone label.]",
title: "[Item in this zone.]",
crossing: "[What crosses between the zones.]",
},
beforeAfter: {
beforeLabel: "[State before.]",
afterLabel: "[State after.]",
edge: "[What changes it.]",
},
hub: {
centre: "[Central idea.]",
title: "[Spoke.]",
edge: "[What connects it.]",
},
compareTwo: {
a: path.includes("rows") ? "[Value.]" : "[Option name.]",
b: path.includes("rows") ? "[Value.]" : "[Option name.]",
label: "[Criterion.]",
},
funnel: {
title: "[Tier.]",
edges: "[What passes to the next tier.]",
outcome: "[Outcome.]",
},
loop: {
centre: "[Central idea.]",
title: "[Step.]",
edge: "[What leads to the next step.]",
},
matrix: {
xAxis: "[Axis label.]",
yAxis: "[Axis label.]",
title: "[Quadrant.]",
},
figurePath: {
marks: "[Milestone.]",
goal: "[Goal.]",
caption: "[What the path shows.]",
},
stairSteps: {
title: "[Step.]",
caption: "[What the steps show.]",
},
pyramid: {
title: "[Level.]",
caption: "[What the levels show.]",
},
gears: {
labels: "[Moving part.]",
caption: "[How the parts work together.]",
},
};
return (phrases[component] && phrases[component][key]) || "[Label.]";
}
function validTemplatePlaceholder(value) {
if (
typeof value !== "string" ||
!/^\[[^[\]\r\n]{3,200}\]$/.test(value.trim())
)
return false;
return new Set([
"[0 checked · 0 need attention]",
"[01]",
"[AREA · STATUS]",
"[Agreed in this draft: the settled sections, listed.]",
"[Amount, security, valuation · investor.]",
"[Area name, e.g. Formation]",
"[Area name.]",
"[Area · document and page.]",
"[Assumption the reader should know.]",
"[Axis label.]",
"[Branch.]",
"[Candidate visual treatment.]",
"[Central idea.]",
"[Citation or record label.]",
"[Clip of the source page with the cited passage highlighted.]",
"[Column heading.]",
"[Company name, or the number of findings that matter.]",
"[Company · transaction · report date.]",
"[Criterion.]",
"[Date.]",
"[Date. What it means.]",
"[Decision or action.]",
"[Document name.]",
"[Document, section, and page.]",
"[Each date with what happened or came due on it.]",
"[Expected record: the document, approval, or filing that would resolve the point.]",
"[Fact label, e.g. Incorporated]",
"[Finding not included in this sample.]",
"[First amount.]",
"[First position.]",
"[Goal.]",
"[How choices are saved.]",
"[How position, direction, grouping, scale, or contrast expresses the relationship.]",
"[How the parts work together.]",
"[How to read this report.]",
"[How to use the page, in one line.]",
"[How to use the slide.]",
"[Item in this zone.]",
"[Known gap or unresolved point.]",
"[Label.]",
"[Leaf.]",
"[Level.]",
"[List item.]",
"[Matter or deliverable title.]",
"[Section heading.]",
"[Finding title.]",
"[Scope and limits.]",
"[Required conditions.]",
"[Options and next action.]",
"[Review summary.]",
"[Findings and consequences.]",
"[Follow-up plan.]",
"[Actions before proceeding.]",
"[Review coverage.]",
"[Method workflow.]",
"[Method structure.]",
"[Matter · document · draft · date received.]",
"[Matter · the question this page answers.]",
"[Milestone.]",
"[Moving part.]",
"[NN]",
"[Not included in this template.]",
"[One card or row per finding that needs attention.]",
"[One fact about it, under twenty words.]",
"[One line that qualifies the title.]",
"[Open issue · section and name.]",
"[Open item name.]",
"[Open the document in the data room.]",
"[Open the source.]",
"[Option A.]",
"[Option B.]",
"[Option C.]",
"[Outcome.]",
"[PROPOSED INVESTMENT]",
"[Page label.]",
"[Provenance line.]",
"[QUESTION · JURISDICTION OR MATTER]",
"[QUESTION · MATTER]",
"[REPORT AREA]",
"[SECTION N OF TOTAL · COUNT DOCUMENTS CHECKED]",
"[STATUS]",
"[Scale end.]",
"[Scale label and gap.]",
"[Scale start.]",
"[Second amount.]",
"[Second position.]",
"[Section and page.]",
"[Short answer.]",
"[Source and locator.]",
"[Source of the figure's facts.]",
"[Sources and the date they were read.]",
"[Spoke.]",
"[Stage name.]",
"[State after.]",
"[State before.]",
"[Step.]",
"[Table value.]",
"[Term or category.]",
"[The area, or its exceptions, in one sentence.]",
"[The authority at the top.]",
"[The choice the reader makes, as a question.]",
"[The claim in one sentence, ending with a period.]",
"[The clause, in the document’s words.]",
"[The closing condition, cure, covenant, representation, or follow-up.]",
"[The consequence of option A.]",
"[The consequence of option B.]",
"[The consequence of option C.]",
"[The counts.]",
"[The decision the reader must make.]",
"[The finding in five words.]",
"[The finding in one sentence.]",
"[The open item in five words.]",
"[The practical consequence for this transaction.]",
"[The question this card answers?]",
"[The reader and what they already know.]",
"[The source reviewed, the controlling fact, and the exact gap, with dates and numbers.]",
"[The value or fact this block communicates.]",
"[Timing or provenance.]",
"[Two sentences that tell the reader what they are looking at.]",
"[Value from the data room.]",
"[Value.]",
"[Verbatim excerpt of the controlling passage, in the document's words.]",
"[What changes it.]",
"[What connects it.]",
"[What crosses between the zones.]",
"[What happened.]",
"[What happens on this date.]",
"[What is open, in one line.]",
"[What it means.]",
"[What leads to the next step.]",
"[What passes to the next stage.]",
"[What passes to the next tier.]",
"[What the figure shows, as a claim.]",
"[What the levels show.]",
"[What the path shows.]",
"[What the steps show.]",
"[What the table shows.]",
"[What the value means.]",
"[What this report is and who it is for.]",
"[What this section enables the reader to understand or do.]",
"[What this source establishes, in one sentence.]",
"[What was found, with the date.]",
"[Why this composition best communicates the core relationship.]",
"[Why this treatment fits the reader and purpose.]",
"[Zone label.]",
"[§ Section]",
"[§N]",
]).has(value.trim());
}
function preferredTemplatePlaceholder(value, fallback) {
return validTemplatePlaceholder(value) ? value.trim() : fallback;
}
function placeholderNumber(key, path) {
const indexes = (path || []).filter((part) => typeof part === "number");
const ordinal = (indexes.length ? indexes[indexes.length - 1] : 0) + 1;
const fixed = {
nowAt: 0,
offset: 0,
location: 0,
min: 0,
minValue: 0,
scaleMin: 0,
low: 20,
q1: 25,
before: 30,
a: 30,
start: 40,
median: 50,
mark: 50,
markAt: 50,
after: 60,
b: 60,
q3: 75,
high: 80,
max: 100,
maxValue: 100,
scaleMax: 100,
delta: 10,
};
if (key === "value") return ordinal;
if (Object.hasOwn(fixed, key)) return fixed[key];
if (key === "x") return ordinal;
if (key === "y") return ordinal * 10;
return ordinal * 10;
}
function placeholderParams(value, key, evidenceMap, component, path) {
path = path || [];
if (Array.isArray(value))
return value.map((item, index) =>
placeholderParams(
item,
key,
evidenceMap,
component,
path.concat(index),
),
);
if (value && typeof value === "object") {
const out = {};
Object.keys(value).forEach((name) => {
const placeholder = placeholderParams(
value[name],
name,
evidenceMap,
component,
path.concat(name),
);
if (placeholder !== undefined) out[name] = placeholder;
});
return out;
}
if (key === "detail" || /Detail$/.test(key))
return evidenceMap[value] || undefined;
if (TEMPLATE_STRUCTURAL_PARAM_KEYS.has(key)) return value;
if (typeof value === "number") return placeholderNumber(key, path);
return typeof value === "string"
? placeholderFor(component, key, path, value)
: value;
}
function addTemplateId(order, seen, value) {
if (typeof value !== "string" || !value || Object.hasOwn(seen, value))
return;
seen[value] = true;
order.push(value);
}
function idMap(order, prefix) {
const map = Object.create(null);
order.forEach((id, index) => {
map[id] = prefix + "-" + (index + 1);
});
return map;
}
function templateMappings() {
const orders = {
section: [],
unit: [],
claim: [],
evidence: [],
source: [],
};
const seen = {
section: Object.create(null),
unit: Object.create(null),
claim: Object.create(null),
evidence: Object.create(null),
source: Object.create(null),
};
const compositions = state.approaches
? ["a", "b"].map(
(key) => (state.approaches[key] || {}).composition || {},
)
: [state.composition || {}];
compositions.forEach((composition) => {
(composition.sections || []).forEach((section) => {
addTemplateId(orders.section, seen.section, section.id);
(section.unitIds || []).forEach((id) => {
addTemplateId(orders.unit, seen.unit, id);
});
((section.layout || {}).placements || []).forEach((placement) => {
addTemplateId(orders.unit, seen.unit, placement.unitId);
});
});
});
Object.keys(state.units || {}).forEach((id) => {
addTemplateId(orders.unit, seen.unit, id);
});
(state.claims || []).forEach((claim) => {
addTemplateId(orders.claim, seen.claim, claim.id);
(claim.sourceIds || []).forEach((id) => {
addTemplateId(orders.source, seen.source, id);
});
(claim.evidenceIds || []).forEach((id) => {
addTemplateId(orders.evidence, seen.evidence, id);
});
});
Object.keys(state.units || {}).forEach((id) => {
const unit = state.units[id] || {};
(unit.claimRefs || []).forEach((claimId) => {
addTemplateId(orders.claim, seen.claim, claimId);
});
(unit.evidence || []).forEach((evidenceId) => {
addTemplateId(orders.evidence, seen.evidence, evidenceId);
});
});
Object.keys(state.evidence || {}).forEach((id) => {
addTemplateId(orders.evidence, seen.evidence, id);
const item = state.evidence[id] || {};
addTemplateId(orders.source, seen.source, item.sourceId);
(item.claimRefs || []).forEach((claimId) => {
addTemplateId(orders.claim, seen.claim, claimId);
});
});
((state.brief || {}).sources || []).forEach((source) => {
addTemplateId(orders.source, seen.source, source.id);
});
const maps = {
section: idMap(orders.section, "section"),
unit: idMap(orders.unit, "unit"),
claim: idMap(orders.claim, "claim"),
evidence: idMap(orders.evidence, "evidence"),
source: idMap(orders.source, "source"),
options: Object.create(null),
orders: orders,
};
Object.keys(state.units || {}).forEach((unitId) => {
const optionMap = Object.create(null);
((state.units[unitId] || {}).options || []).forEach((option, index) => {
if (option && option.key != null)
optionMap[String(option.key)] = "option-" + (index + 1);
});
maps.options[unitId] = optionMap;
});
return maps;
}
function mappedIds(values, map) {
return (values || []).map((id) => map[id]);
}
function templateUnitPlaceholder(unit) {
let fallback = PLACEHOLDERS.title;
if (unit.kind === "card") fallback = PLACEHOLDERS.cardLine;
if (unit.kind === "figure") fallback = PLACEHOLDERS.figureLabel;
if (unit.kind === "decision")
fallback = "[The decision the reader must make.]";
return preferredTemplatePlaceholder(unit.placeholder, fallback);
}
function templateHeadingPlaceholder(unit = {}, node = null) {
// Only this finite vocabulary can survive as heading intent. A user's
// arbitrary bracketed matter text is not a trustworthy template label.
const headings = new Set([
"[Matter or deliverable title.]",
"[Section heading.]",
"[Finding title.]",
"[Scope and limits.]",
"[Short answer.]",
"[Decision or action.]",
"[Required conditions.]",
"[Options and next action.]",
"[Review summary.]",
"[Findings and consequences.]",
"[Follow-up plan.]",
"[Actions before proceeding.]",
"[Review coverage.]",
"[Method workflow.]",
"[Method structure.]",
]);
for (const candidate of [
unit.placeholder_title,
node?.getAttribute("data-placeholder-title"),
node?.getAttribute("data-placeholder"),
unit.placeholder,
]) {
if (typeof candidate === "string" && headings.has(candidate.trim()))
return candidate.trim();
}
const roleHeadings = {
title: "[Matter or deliverable title.]",
matter: "[Matter or deliverable title.]",
answer: "[Short answer.]",
finding: "[Finding title.]",
scope: "[Scope and limits.]",
action: "[Decision or action.]",
expected: "[Required conditions.]",
};
return Object.hasOwn(roleHeadings, unit.role)
? roleHeadings[unit.role]
: "[Section heading.]";
}
function templateLegacyCollection(value, name, maps) {
const allowed = LEGACY_COLLECTION_FIELDS[name];
if (!allowed || !Array.isArray(value)) return [];
return value.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return {};
const next = {};
Object.keys(item).forEach((key) => {
if (!allowed.has(key)) return;
next[key] = placeholderParams(item[key], key, maps.evidence, null, [
index,
key,
]);
});
return next;
});
}
function templateVariant(variant, unit, maps) {
const next = {};
let component =
typeof variant.component === "string" &&
(LD.registry || []).includes(variant.component)
? variant.component
: null;
if (component) {
try {
LD.render(component, variant.params, {});
} catch (_) {
component = null;
}
}
Object.keys(variant).forEach((name) => {
if (!VARIANT_FIELDS.has(name)) return;
if (name === "axis") {
next.axis = VARIANT_AXES.includes(variant.axis)
? variant.axis
: unit.single
? "single"
: "form";
return;
}
if (name === "component") {
if (component) next.component = component;
return;
}
if (name === "params") {
if (component) {
next.params = placeholderParams(
variant.params,
"params",
maps.evidence,
component,
[],
);
try {
LD.render(component, next.params, {});
} catch (error) {
throw new TypeError(
`template ${component} parameters became invalid during sanitization: ${error.message}`,
);
}
}
return;
}
if (Object.hasOwn(LEGACY_COLLECTION_FIELDS, name)) {
next[name] = templateLegacyCollection(variant[name], name, maps);
return;
}
if (name === "layout") {
if (LEGACY_LAYOUTS.includes(variant.layout))
next.layout = variant.layout;
return;
}
if (name === "why") {
next[name] = "[Why this treatment fits the reader and purpose.]";
return;
}
if (name === "encoding") {
next[name] =
"[How position, direction, grouping, scale, or contrast expresses the relationship.]";
return;
}
if (name === "html") {
next[name] = templateUnitPlaceholder(unit);
return;
}
if (name === "title") {
next[name] = PLACEHOLDERS.cardTitle;
return;
}
next[name] = placeholderParams(
variant[name],
name,
maps.evidence,
component,
[],
);
});
return next;
}
function templateUnit(unitId, maps) {
const unit = state.units[unitId] || {};
const next = {
kind: UNIT_KINDS.includes(unit.kind) ? unit.kind : "text",
selected: unit.selected === "b" ? "b" : "a",
};
if (UNIT_ROLES.includes(unit.role)) next.role = unit.role;
if (["single", "multiple"].includes(unit.selection_mode))
next.selection_mode = unit.selection_mode;
["allow_custom", "allow_note", "single", "sourcePreview"].forEach(
(name) => {
if (typeof unit[name] === "boolean") next[name] = unit[name];
},
);
if (unit.claim != null)
next.claim =
unit.kind === "decision"
? "[The decision this unit presents.]"
: templateUnitPlaceholder(unit);
if (unit.relationship != null)
next.relationship = RELATIONSHIPS.includes(unit.relationship)
? unit.relationship
: "containment";
if (Array.isArray(unit.candidates))
next.candidates = unit.candidates.map(
() => "[Candidate visual treatment.]",
);
if (Array.isArray(unit.claimRefs))
next.claimRefs = mappedIds(unit.claimRefs, maps.claim);
if (Array.isArray(unit.evidence))
next.evidence = mappedIds(unit.evidence, maps.evidence);
if (typeof unit.detail === "string" && maps.evidence[unit.detail])
next.detail = maps.evidence[unit.detail];
next.variants = {};
Object.keys(unit.variants || {}).forEach((key) => {
next.variants[key] = templateVariant(unit.variants[key], unit, maps);
});
next.edits = {};
Object.keys(
Object.assign({}, unit.variants || {}, unit.edits || {}),
).forEach((key) => {
next.edits[key] = null;
});
next.placeholder = templateUnitPlaceholder(unit);
if (unit.placeholder_title != null)
next.placeholder_title = templateHeadingPlaceholder(unit);
if (unit.placeholder_sub != null)
next.placeholder_sub = "[One fact about it, under twenty words.]";
if (unit.source_note != null) next.source_note = "[Source and locator.]";
if (unit.tag != null) next.tag = "[Open issue · section and name.]";
if (unit.question != null)
next.question = "[The choice the reader makes, as a question.]";
if (Array.isArray(unit.options)) {
const optionMap = maps.options[unitId] || {};
next.options = unit.options.map((option, index) => {
const key = optionMap[String(option.key)] || "option-" + (index + 1);
return {
key: key,
label: "[Option " + (index + 1) + ".]",
consequence: "[The consequence of option " + (index + 1) + ".]",
};
});
}
return next;
}
function templateEvidence(item, maps) {
const next = {
cite: "[Citation or record label.]",
locator: "[Section and page.]",
excerpt:
"[Verbatim excerpt of the controlling clause, in the document's words.]",
link: item.link == null ? null : "#",
status: "supplied-unverified",
};
if (item.sourceId != null) next.sourceId = maps.source[item.sourceId];
if (Array.isArray(item.claimRefs))
next.claimRefs = mappedIds(item.claimRefs, maps.claim);
if (item.detail != null)
next.detail = "[What this source establishes, in one sentence.]";
if (item.popup) {
next.popup = {
type: ["source", "explainer", "detail"].includes(item.popup.type)
? item.popup.type
: "detail",
title: "[The question or insight this popup explains.]",
sections: (item.popup.sections || []).map(() => ({
heading: "[Specific subpoint.]",
body: "[The deeper detail the reader needs here.]",
})),
};
if (item.popup.lede != null)
next.popup.lede = "[One-sentence orientation to the deeper detail.]";
}
if (item.original) {
next.original = {
availability:
item.original.availability === "linked" ? "linked" : "unavailable",
label: "[Original source label.]",
};
if (next.original.availability === "linked") next.original.href = "#";
}
if (item.image) {
next.image = {
status:
"[Clip of the source page with the cited passage highlighted.]",
};
if (Object.hasOwn(item.image, "data")) next.image.data = null;
}
if (item.provenance != null)
next.provenance = "[Source, version, and retrieval note.]";
return next;
}
function isPackagedTemplateId(requestedId) {
return /^legaldesign-template-[a-z0-9][a-z0-9-]{0,79}$/.test(
requestedId || "",
);
}
function templateExportId(requestedId) {
if (isPackagedTemplateId(requestedId)) return requestedId;
let suffix = "";
if (window.crypto && typeof window.crypto.randomUUID === "function")
suffix = window.crypto.randomUUID();
else
suffix =
Date.now().toString(36) +
"-" +
Math.random().toString(36).slice(2, 14);
return "legaldesign-template-" + suffix.toLowerCase();
}
function templateState(maps, templateId) {
const brief = state.brief || {};
const nextBrief = {
title: "[Descriptive title for the artifact.]",
reader: "[The reader and what they already know.]",
action: "[What the reader should decide or do.]",
purpose: brief.purpose,
message: "[The answer the page should make clear.]",
spine: brief.spine,
situation: brief.situation,
form: brief.form,
sources: (brief.sources || []).map((source) => {
const next = {
id: maps.source[source.id],
label: "[Source label.]",
status: "supplied-unverified",
};
if (source.note != null) next.note = "[Source status note.]";
return next;
}),
assumptions: (brief.assumptions || []).map(
() => "[Assumption the reader should know.]",
),
gaps: (brief.gaps || []).map(() => "[Known gap or unresolved point.]"),
};
if (brief.run != null) nextBrief.run = {};
const next = {
schema: state.schema,
artifactId: templateId,
savedAt: new Date().toISOString(),
brief: nextBrief,
units: {},
evidence: {},
review: {
...(singleComposition() ? {} : { approach: "a" }),
decisions: {},
theme: (state.review && state.review.theme) || "light",
location: null,
},
history: [],
};
if (state.sourceSchemaVersion != null)
next.sourceSchemaVersion = state.sourceSchemaVersion;
if (state.style != null) next.style = { source: "template", ref: null };
if (state.approaches) {
next.approaches = {};
["a", "b"].forEach((key) => {
const approach = state.approaches[key] || {};
const composition = approach.composition || {};
next.approaches[key] = {
label: key === "a" ? "Page A" : "Page B",
rationale:
"[Why this complete page approach fits the reader and purpose.]",
composition: {
strategy: composition.strategy,
shape: "[The section sequence and reading path.]",
rationale:
"[Why this composition best communicates the core relationship.]",
sections: (composition.sections || []).map((section) => ({
id: maps.section[section.id],
purpose:
"[What this section enables the reader to understand or do.]",
unitIds: mappedIds(section.unitIds, maps.unit),
layout: {
columns: (section.layout || {}).columns || 12,
placements: ((section.layout || {}).placements || []).map(
(placement) => ({
unitId: maps.unit[placement.unitId],
row: placement.row,
column: placement.column,
span: placement.span,
...(placement.rowSpan
? { rowSpan: placement.rowSpan }
: {}),
}),
),
},
})),
templateRef:
composition.strategy === "template-import"
? "[Template or playbook reference.]"
: null,
},
};
});
}
if (state.composition) {
next.composition = {
strategy: state.composition.strategy,
shape: "[The section sequence and reading path.]",
rationale:
"[Why this composition best communicates the core relationship.]",
sections: (state.composition.sections || []).map((section) => ({
id: maps.section[section.id],
purpose:
"[What this section enables the reader to understand or do.]",
unitIds: mappedIds(section.unitIds, maps.unit),
...(section.issueLayout
? {
issueLayout: {
findingUnitId: maps.unit[section.issueLayout.findingUnitId],
implicationUnitId:
maps.unit[section.issueLayout.implicationUnitId],
actionUnitId: maps.unit[section.issueLayout.actionUnitId],
supportUnitIds: mappedIds(
section.issueLayout.supportUnitIds,
maps.unit,
),
},
}
: {}),
layout: {
columns: (section.layout || {}).columns || 12,
placements: ((section.layout || {}).placements || []).map(
(placement) => ({
unitId: maps.unit[placement.unitId],
row: placement.row,
column: placement.column,
span: placement.span,
...(placement.rowSpan ? { rowSpan: placement.rowSpan } : {}),
}),
),
},
})),
templateRef:
state.composition.strategy === "template-import"
? "[Template or playbook reference.]"
: null,
};
}
if (state.overview) {
next.overview = {
sectionId: maps.section[state.overview.sectionId],
contextUnitIds: mappedIds(state.overview.contextUnitIds, maps.unit),
questionUnitId: maps.unit[state.overview.questionUnitId],
answerUnitId: maps.unit[state.overview.answerUnitId],
topics: (state.overview.topics || []).map((topic) => ({
unitId: maps.unit[topic.unitId],
targetSectionId: maps.section[topic.targetSectionId],
})),
...(state.overview.groups
? {
groups: state.overview.groups.map((group, index) => ({
label: `Topic group ${index + 1}`,
topicUnitIds: mappedIds(group.topicUnitIds, maps.unit),
})),
}
: {}),
};
}
if (Array.isArray(state.claims)) {
next.claims = state.claims.map((claim) => {
const nextClaim = {
id: maps.claim[claim.id],
text:
claim.kind === "context"
? "[Context the reader needs.]"
: "[Material claim the artifact communicates.]",
kind: claim.kind,
sourceIds: mappedIds(claim.sourceIds, maps.source),
placement: claim.placement,
evidenceIds: mappedIds(claim.evidenceIds, maps.evidence),
};
if (claim.omissionReason != null)
nextClaim.omissionReason =
"[Why this claim belongs in detail or is omitted.]";
return nextClaim;
});
}
maps.orders.unit.forEach((id) => {
if (state.units && state.units[id])
next.units[maps.unit[id]] = templateUnit(id, maps);
});
maps.orders.evidence.forEach((id) => {
if (state.evidence && state.evidence[id])
next.evidence[maps.evidence[id]] = templateEvidence(
state.evidence[id],
maps,
);
});
return next;
}
function sanitizeTemplateDOM(cloneRoot, nextState, maps) {
const unitMap = Object.assign(Object.create(null), maps.unit);
const sectionMap = Object.assign(Object.create(null), maps.section);
const referenceMap = Object.assign(Object.create(null), maps.evidence);
const classMap = Object.create(null);
const mappedReferenceValues = Object.keys(referenceMap).reduce(
(values, key) => {
values[referenceMap[key]] = true;
return values;
},
Object.create(null),
);
let nextUnitIndex = Object.keys(unitMap).length;
let nextSectionIndex = Object.keys(sectionMap).length;
let nextDetailIndex = 0;
function mappedDOMId(map, oldId, prefix, nextIndex) {
if (Object.hasOwn(map, oldId)) return map[oldId];
const value = prefix + "-" + nextIndex();
map[oldId] = value;
return value;
}
function mappedReference(oldId) {
if (Object.hasOwn(referenceMap, oldId)) return referenceMap[oldId];
if (mappedReferenceValues[oldId]) return oldId;
return mappedDOMId(referenceMap, oldId, "detail", () => {
nextDetailIndex += 1;
const id = nextDetailIndex;
mappedReferenceValues["detail-" + id] = true;
return id;
});
}
function rewriteAttributeSelectorMap(css, attribute, map) {
const staged = [];
let prefix = "__ld_attribute_stage__";
while (css.includes(prefix)) prefix = "_" + prefix;
Object.keys(map).forEach((oldValue, index) => {
const temporary = prefix + attribute + "-" + index;
css = rewriteAttributeSelector(css, attribute, oldValue, temporary);
staged.push([temporary, map[oldValue]]);
});
staged.forEach((entry) => {
css = rewriteAttributeSelector(css, attribute, entry[0], entry[1]);
});
return css;
}
function rewriteClassSelectorMap(css, map) {
const staged = [];
let prefix = "__ld_class_stage__";
while (css.includes(prefix)) prefix = "_" + prefix;
Object.keys(map).forEach((oldValue, index) => {
const temporary = prefix + index;
const escaped = oldValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
css = css.replace(
new RegExp("\\." + escaped + "(?![A-Za-z0-9_-])", "g"),
"." + temporary,
);
staged.push([temporary, map[oldValue]]);
});
staged.forEach((entry) => {
// Stage 1 must not also replace stage 10, 11, ... . A substring
// replacement silently detaches CSS from larger template class maps.
const escaped = entry[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
css = css.replace(
new RegExp("\\." + escaped + "(?![A-Za-z0-9_-])", "g"),
"." + entry[1],
);
});
return css;
}
function remapIdReferences(oldId, newId) {
if (!oldId || oldId === newId) return;
all(
"[for],[aria-controls],[aria-labelledby],[aria-describedby]",
cloneRoot,
).forEach((node) => {
[
"for",
"aria-controls",
"aria-labelledby",
"aria-describedby",
].forEach((name) => {
if (!node.hasAttribute(name)) return;
const value = node
.getAttribute(name)
.split(/\s+/)
.map((id) => (id === oldId ? newId : id));
node.setAttribute(name, value.join(" "));
});
});
all("[href]", cloneRoot).forEach((node) => {
if (node.getAttribute("href") === "#" + oldId)
node.setAttribute("href", "#" + newId);
});
}
function rewriteAttributeSelector(css, attribute, oldValue, newValue) {
[
`[${attribute}="${oldValue}"]`,
`[${attribute}='${oldValue}']`,
`[${attribute}=${oldValue}]`,
].forEach((selector, index) => {
const quote = index === 0 ? '"' : index === 1 ? "'" : "";
const replacement = `[${attribute}=${quote}${newValue}${quote}]`;
css = css.split(selector).join(replacement);
});
return css;
}
function nodePlaceholder(node, fallback) {
return preferredTemplatePlaceholder(
node.getAttribute("data-placeholder"),
fallback,
);
}
function semanticPlaceholder(node, fallback) {
const name = node.localName;
let semanticFallback = fallback;
if (/^h[1-4]$/.test(name)) {
const id = node.closest("[data-unit]")?.getAttribute("data-unit");
return templateHeadingPlaceholder(
nextState.units[id] || state.units[id],
node,
);
}
if (name === "li") semanticFallback = "[List item.]";
if (name === "th") semanticFallback = "[Column heading.]";
if (name === "td") semanticFallback = "[Table value.]";
if (name === "caption") semanticFallback = "[What the table shows.]";
if (name === "dt") semanticFallback = "[Term or category.]";
if (name === "dd") semanticFallback = "[What it means.]";
return nodePlaceholder(node, semanticFallback);
}
function scrubEditableStructure(body, heading, fallback) {
const editables = all("[data-editable]", body);
if (body.matches("[data-editable]")) editables.unshift(body);
const scrubbed = new Set();
editables.forEach((editable) => {
if (editable === heading || scrubbed.has(editable)) return;
if (!editable.children.length) {
const placeholder = semanticPlaceholder(editable, fallback);
editable.setAttribute("data-placeholder", placeholder);
editable.textContent = placeholder;
scrubbed.add(editable);
return;
}
let semantic = all("h1,h2,h3,h4,p,li,td,th,caption,dt,dd", editable);
if (editable.matches("h1,h2,h3,h4,p,li,td,th,caption,dt,dd"))
semantic.unshift(editable);
semantic = semantic.filter((node) => {
if (node === editable) return true;
const ancestor = node.parentElement.closest(
"h1,h2,h3,h4,p,li,td,th,caption,dt,dd",
);
return !ancestor || !editable.contains(ancestor);
});
if (!semantic.length)
semantic = all("span,small,strong,em", editable).filter(
(node) => !node.querySelector("span,small,strong,em"),
);
if (!semantic.length) {
const placeholder = nodePlaceholder(editable, fallback);
editable.setAttribute("data-placeholder", placeholder);
editable.textContent = placeholder;
scrubbed.add(editable);
return;
}
semantic.forEach((node) => {
if (node === heading || scrubbed.has(node)) return;
const placeholder = semanticPlaceholder(node, fallback);
node.setAttribute("data-placeholder", placeholder);
node.textContent = placeholder;
scrubbed.add(node);
});
});
const remaining = all("h1,h2,h3,h4,p,li,td,th,caption,dt,dd", body);
if (body.matches("h1,h2,h3,h4,p,li,td,th,caption,dt,dd"))
remaining.unshift(body);
remaining
.filter((node) => {
if (node === heading || scrubbed.has(node)) return false;
if (node === body) return true;
const ancestor = node.parentElement.closest(
"h1,h2,h3,h4,p,li,td,th,caption,dt,dd",
);
return !ancestor || !body.contains(ancestor);
})
.forEach((node) => {
const placeholder = semanticPlaceholder(node, fallback);
node.setAttribute("data-placeholder", placeholder);
node.textContent = placeholder;
scrubbed.add(node);
});
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
textNodes.forEach((textNode) => {
if (!textNode.textContent.trim()) return;
const parent = textNode.parentElement;
if (
!parent ||
parent.closest("script,style,.ld-diagram") ||
parent.closest("h1,h2,h3,h4,p,li,td,th,caption,dt,dd") ||
validTemplatePlaceholder(textNode.textContent.trim())
)
return;
textNode.textContent = nodePlaceholder(parent, fallback);
});
}
function editedFigureRecords(host) {
if (!host || !host.hasAttribute("data-editor-preserve-dom"))
return null;
const records = all(
'svg [data-diagram-object],svg g[role="button"]',
host,
).map((node, index) => ({
key:
node.getAttribute("data-editor-node-key") || "node-" + (index + 1),
transform: node.style.transform || "",
transformBox: node.style.transformBox || "",
transformOrigin: node.style.transformOrigin || "",
detail: node.getAttribute("data-detail") || null,
x: node.getAttribute("data-editor-x") || "0",
y: node.getAttribute("data-editor-y") || "0",
sx: node.getAttribute("data-editor-scale-x") || "1",
sy: node.getAttribute("data-editor-scale-y") || "1",
fill: FILL_TOKENS.includes(node.getAttribute("data-fill-token"))
? node.getAttribute("data-fill-token")
: null,
text: TEXT_TOKENS.includes(node.getAttribute("data-text-token"))
? node.getAttribute("data-text-token")
: null,
labels: ownedFigureLabels(node).map(figureLabelPresentation),
}));
records.looseLabels = all("svg text", host)
.filter(
(label) => !label.closest('[data-diagram-object],g[role="button"]'),
)
.map(figureLabelPresentation);
return records;
}
function ownedFigureLabels(node) {
return all("text", node).filter(
(label) =>
label.closest('[data-diagram-object],g[role="button"]') === node,
);
}
function figureLabelPresentation(label) {
const styles = {};
for (const property of [
"transform",
"transform-box",
"transform-origin",
"font-size",
"font-weight",
"font-style",
"text-decoration-line",
]) {
const value = label.style.getPropertyValue(property);
if (value && safeTemplateStyleValue(property, value))
styles[property] = value;
}
const geometry = {};
for (const name of ["x", "y", "scale-x", "scale-y"]) {
const value = label.getAttribute("data-editor-" + name);
if (value !== null && Number.isFinite(Number(value)))
geometry[name] = String(Number(value));
}
return {
styles,
geometry,
fill: FILL_TOKENS.includes(label.getAttribute("data-fill-token"))
? label.getAttribute("data-fill-token")
: null,
text: TEXT_TOKENS.includes(label.getAttribute("data-text-token"))
? label.getAttribute("data-text-token")
: null,
};
}
function restoreFigureLabelPresentation(labels, records) {
(records || []).forEach((record, index) => {
const label = labels[index];
if (!label) return;
Object.entries(record.styles).forEach(([property, value]) => {
label.style.setProperty(property, value);
});
Object.entries(record.geometry).forEach(([name, value]) => {
label.setAttribute("data-editor-" + name, value);
});
if (record.styles.transform)
label.setAttribute("data-editor-base-transform", "");
if (record.fill) label.setAttribute("data-fill-token", record.fill);
if (record.text) label.setAttribute("data-text-token", record.text);
});
}
function applyEditedFigureRecords(host, records) {
if (!records) return;
const clean = all(
'svg [data-diagram-object],svg g[role="button"]',
host,
);
const grouped = Object.create(null);
records.forEach((record) => {
if (!grouped[record.key]) grouped[record.key] = [];
grouped[record.key].push(record);
});
clean.forEach((node, index) => {
const key = "node-" + (index + 1);
const matches = grouped[key] || [];
if (!matches.length) {
node.remove();
return;
}
let cursor = node;
matches.forEach((record, copyIndex) => {
const target = copyIndex ? node.cloneNode(true) : node;
if (copyIndex) cursor.insertAdjacentElement("afterend", target);
target.setAttribute("data-editor-node-key", key);
target.style.transform = record.transform;
target.style.transformBox = record.transformBox;
target.style.transformOrigin = record.transformOrigin;
target.setAttribute("data-editor-x", record.x);
target.setAttribute("data-editor-y", record.y);
target.setAttribute("data-editor-scale-x", record.sx);
target.setAttribute("data-editor-scale-y", record.sy);
target.setAttribute("data-editor-base-transform", "");
if (record.detail) {
target.setAttribute("data-detail", record.detail);
target.setAttribute("aria-controls", record.detail);
target.setAttribute("role", "button");
target.setAttribute("tabindex", "0");
target.classList.add("ld-detail");
} else {
[
"data-detail",
"aria-controls",
"aria-haspopup",
"role",
"tabindex",
].forEach((attr) => {
target.removeAttribute(attr);
});
target.classList.remove("ld-detail");
}
if (record.fill)
target.setAttribute("data-fill-token", record.fill);
else target.removeAttribute("data-fill-token");
if (record.text)
target.setAttribute("data-text-token", record.text);
else target.removeAttribute("data-text-token");
restoreFigureLabelPresentation(
ownedFigureLabels(target),
record.labels,
);
cursor = target;
});
});
restoreFigureLabelPresentation(
all("svg text", host).filter(
(label) => !label.closest('[data-diagram-object],g[role="button"]'),
),
records.looseLabels,
);
host.setAttribute("data-editor-preserve-dom", "true");
}
const documentTitle = cloneRoot.matches(
"[data-document-title-placeholder]",
)
? cloneRoot
: cloneRoot.querySelector("[data-document-title-placeholder]");
if (documentTitle)
documentTitle.setAttribute(
"data-document-title-placeholder",
"[Matter] · LegalDesign template",
);
const title = cloneRoot.querySelector("title");
if (title)
title.textContent = documentTitle
? documentTitle.getAttribute("data-document-title-placeholder")
: "[Matter] · LegalDesign template";
const matter = cloneRoot.querySelector(".ld-matter");
if (matter) {
matter.setAttribute(
"data-placeholder",
"[Matter or deliverable title.]",
);
matter.textContent = "[Matter or deliverable title.]";
}
all("[data-placeholder]", cloneRoot).forEach((node) => {
const fallback = node.hasAttribute("data-card-title")
? PLACEHOLDERS.cardTitle
: node.closest(".ld-diagram")
? PLACEHOLDERS.figureLabel
: PLACEHOLDERS.title;
const unitId = node.closest("[data-unit]")?.getAttribute("data-unit");
const placeholder = /^h[1-4]$/.test(node.localName)
? templateHeadingPlaceholder(state.units[unitId], node)
: nodePlaceholder(node, fallback);
node.setAttribute("data-placeholder", placeholder);
node.textContent = placeholder;
});
all("[data-placeholder-title]", cloneRoot).forEach((node) => {
const unitId = node.closest("[data-unit]")?.getAttribute("data-unit");
node.setAttribute(
"data-placeholder-title",
templateHeadingPlaceholder(state.units[unitId], node),
);
});
all("[data-placeholder-sub]", cloneRoot).forEach((node) => {
node.setAttribute(
"data-placeholder-sub",
preferredTemplatePlaceholder(
node.getAttribute("data-placeholder-sub"),
"[One fact about it, under twenty words.]",
),
);
});
all(
"[data-evidence],[data-detail],[data-evidence-ids]",
cloneRoot,
).forEach((node) => {
if (node.hasAttribute("data-evidence"))
node.setAttribute(
"data-evidence",
mappedReference(node.getAttribute("data-evidence")),
);
if (node.hasAttribute("data-detail"))
node.setAttribute(
"data-detail",
mappedReference(node.getAttribute("data-detail")),
);
if (node.hasAttribute("data-evidence-ids"))
node.setAttribute(
"data-evidence-ids",
node
.getAttribute("data-evidence-ids")
.split(/\s+/)
.filter(Boolean)
.map(mappedReference)
.join(" "),
);
});
all("[data-section-target]", cloneRoot).forEach((node) => {
const mapped = sectionMap[node.getAttribute("data-section-target")];
if (mapped) node.setAttribute("data-section-target", mapped);
else node.removeAttribute("data-section-target");
});
all("[data-composition-section]", cloneRoot).forEach((section) => {
const oldId = section.getAttribute("data-composition-section");
if (!Object.hasOwn(sectionMap, oldId)) {
section.remove();
return;
}
const newId = mappedDOMId(sectionMap, oldId, "section", () => {
nextSectionIndex += 1;
return nextSectionIndex;
});
section.setAttribute("data-composition-section", newId);
if (section.hasAttribute("data-index-label"))
section.setAttribute("data-index-label", "[Section topic]");
section.setAttribute(
"aria-label",
"[What this section enables the reader to understand or do.]",
);
});
all("section.ld-unit[data-unit]", cloneRoot).forEach((section) => {
const oldId = section.getAttribute("data-unit");
if (!Object.hasOwn(unitMap, oldId)) {
section.remove();
return;
}
const id = mappedDOMId(unitMap, oldId, "unit", () => {
nextUnitIndex += 1;
return nextUnitIndex;
});
section.setAttribute("data-unit", id);
const unit = nextState.units[id];
if (!unit) return;
section.setAttribute("aria-label", unit.claim || unit.placeholder);
all(".ld-variant", section).forEach((variant) => {
const key = variant.getAttribute("data-variant");
const spec = unit.variants && unit.variants[key];
const existingHosts = all(".ld-diagram", variant);
const bodies = all("[data-variant-body]", variant).filter(
(node) => node.parentElement === variant,
);
let body = bodies.shift();
if (!body) {
body = document.createElement("div");
body.setAttribute("data-variant-body", "");
body.setAttribute("data-editable", "");
variant.appendChild(body);
}
let host =
unit.kind === "figure" ? body.querySelector(".ld-diagram") : null;
if (!host && unit.kind === "figure") host = existingHosts[0] || null;
const figureRecords = editedFigureRecords(host);
if (host && !body.contains(host)) body.appendChild(host);
bodies.forEach((extra) => {
extra.remove();
});
all(".ld-diagram", variant).forEach((candidate) => {
if (candidate !== host) candidate.remove();
});
if (unit.kind === "figure") {
Array.from(body.childNodes).forEach((child) => {
if (child !== host) child.remove();
});
}
if (body && spec && unit.kind !== "figure") {
const inlineDetails = all(
".ld-inline-detail[data-detail]",
body,
).map((link) => ({
detail: link.getAttribute("data-detail"),
parent: link.closest("p,li,td,th,dt,dd"),
}));
const heading = body.querySelector("[data-card-title]");
if (heading)
heading.textContent = templateHeadingPlaceholder(unit, heading);
scrubEditableStructure(
body,
heading,
unit.placeholder ||
(unit.kind === "card"
? PLACEHOLDERS.cardLine
: PLACEHOLDERS.title),
);
// Scrubbing a paragraph replaces its text nodes, including nested
// phrase links. Restore only the existing targets, with neutral
// presentation; never resurrect a user-removed popup or its copy.
inlineDetails.forEach(({ detail, parent }) => {
const existing = parent && body.contains(parent);
const line = existing ? parent : document.createElement("p");
const link = document.createElement("button");
link.type = "button";
link.className = "ld-inline-detail";
link.setAttribute("data-detail", detail);
link.setAttribute("aria-haspopup", "dialog");
link.textContent = existing
? line.textContent
: "[Open the supporting explanation.]";
// Keep the original paragraph slot. Appending a second copy
// adds unplanned rows and can overflow a previously fitted page.
line.replaceChildren(link);
if (!existing) {
line.className = "ld-detail-linkline";
body.appendChild(line);
}
});
}
if (!host && spec && unit.kind === "figure") {
host = document.createElement("div");
host.className = "ld-diagram";
body.appendChild(host);
}
if (host && unit.kind === "figure") {
const oldHostId = host.id;
const nextHostId = "diagram-" + id + "-" + (key || "a");
host.id = nextHostId;
remapIdReferences(oldHostId, nextHostId);
}
if (host && spec && spec.component) {
host.innerHTML = LD.render(spec.component, spec.params, {
ariaLabel: unit.placeholder || PLACEHOLDERS.figureLabel,
});
applyEditedFigureRecords(host, figureRecords);
annotateDiagramLabels(host, spec.params);
} else if (host && spec && unit.kind === "figure") {
host.removeAttribute("data-editor-preserve-dom");
host.innerHTML =
'<svg viewBox="0 0 800 420" role="img" aria-label="[Illustration or diagram supplied for this field.]"><rect width="799" height="419" x=".5" y=".5" fill="var(--card)" stroke="var(--line-strong)"/><text x="400" y="210" text-anchor="middle" fill="currentColor" font-family="Helvetica,Arial,sans-serif" font-size="20">[Illustration or diagram supplied for this field.]</text></svg>';
}
});
const why = section.querySelector(".ld-why");
const selected = unit.variants && unit.variants[unit.selected];
if (why && selected && selected.why) why.textContent = selected.why;
all("[data-editable]", section).forEach((node) => {
if (!node.closest(".ld-variant"))
node.textContent =
node.getAttribute("data-placeholder") ||
(node.hasAttribute("data-card-title")
? unit.placeholder_title || PLACEHOLDERS.cardTitle
: unit.placeholder || PLACEHOLDERS.title);
});
if (unit.kind === "decision") {
section.removeAttribute("data-answered");
const tag = section.querySelector(".tag");
if (tag && unit.tag) tag.textContent = unit.tag;
const question = section.querySelector(".ld-decision h3,h2");
if (question && unit.question) question.textContent = unit.question;
all("[data-decision-option]", section).forEach((button, index) => {
const option = (unit.options || [])[index];
if (!option) return;
button.setAttribute("value", option.key);
button.setAttribute("aria-pressed", "false");
const key = button.querySelector("strong");
const legacyLabel = button.querySelector("b");
const label = button.querySelector("span");
const consequence = button.querySelector("small");
if (key) key.textContent = "Option " + (index + 1);
if (legacyLabel) legacyLabel.textContent = option.label;
if (label)
label.textContent = consequence
? option.label
: option.consequence;
if (consequence) consequence.textContent = option.consequence;
if (!key && !legacyLabel && !label && !consequence)
button.textContent = option.label;
});
}
all(".ld-evidence-trigger", section).forEach((trigger) => {
trigger.textContent = "Source: [Citation or record label.]";
});
const walker = document.createTreeWalker(section, NodeFilter.SHOW_TEXT);
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
textNodes.forEach((textNode) => {
const value = textNode.textContent.trim();
if (!value || validTemplatePlaceholder(value)) return;
const parent = textNode.parentElement;
if (
!parent ||
parent.closest(
"script,style,.ld-unit-tools,.ld-diagram,.ld-why,[data-decision-option],.ld-evidence-trigger",
) ||
parent.closest("[data-placeholder]")
)
return;
textNode.textContent = unit.placeholder || PLACEHOLDERS.title;
});
});
all("[data-variant-body] [class]", cloneRoot).forEach((node) => {
if (node.closest(".ld-diagram")) return;
const next = node
.getAttribute("class")
.split(/\s+/)
.filter(Boolean)
.map((name) => {
if (
name.startsWith("ld-") ||
["cite", "tag", "action"].includes(name)
)
return name;
if (!Object.hasOwn(classMap, name))
classMap[name] =
"template-class-" + (Object.keys(classMap).length + 1);
return classMap[name];
});
if (next.length) node.setAttribute("class", next.join(" "));
else node.removeAttribute("class");
});
all("section.ld-unit [data-variant-body] img", cloneRoot).forEach(
(image) => {
const replacement = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
const sourcePreview = image.closest(".ld-source-preview");
const originalImage = sourcePreview
? all(".ld-source-preview img").find(
(node) =>
node.getAttribute("src") === image.getAttribute("src"),
)
: null;
const width = originalImage?.naturalWidth || 800;
const height = originalImage?.naturalHeight || 420;
replacement.setAttribute("viewBox", `0 0 ${width} ${height}`);
replacement.setAttribute("role", "img");
replacement.setAttribute(
"aria-label",
"[Illustration or source image supplied for this field.]",
);
replacement.innerHTML = `<rect width="${width - 1}" height="${height - 1}" x=".5" y=".5" fill="var(--card)" stroke="var(--line-strong)"/><text x="${width / 2}" y="${height / 2}" dominant-baseline="middle" text-anchor="middle" fill="currentColor" font-family="Helvetica,Arial,sans-serif" font-size="${Math.min(20, height / 2)}">${sourcePreview ? "[Source clip.]" : "[Illustration or source image supplied for this field.]"}</text>`;
image.replaceWith(replacement);
},
);
const safeInternalPages = new Set(
all("[id][data-frame]", cloneRoot)
.map((node) => node.id)
.filter((id) => /^p\d+$/.test(id)),
);
all("[data-page]", cloneRoot).forEach((node) => {
if (!safeInternalPages.has(node.getAttribute("data-page")))
node.removeAttribute("data-page");
});
const allowedMatterData = new Set([
"data-card-title",
"data-decision-custom",
"data-decision-note",
"data-decision-option",
"data-detail",
"data-editable",
"data-diagram-label",
"data-evidence-field",
"data-evidence-id",
"data-editor-node-key",
"data-editor-object",
"data-diagram-object",
"data-editor-preserve-dom",
"data-evidence",
"data-fill-token",
"data-placeholder",
"data-placeholder-sub",
"data-placeholder-title",
"data-page",
"data-text-token",
"data-variant-body",
]);
all(
"section.ld-unit [data-variant-body],section.ld-unit [data-variant-body] *",
cloneRoot,
).forEach((node) => {
if (node.closest(".ld-diagram")) return;
Array.from(node.attributes).forEach((attribute) => {
const name = attribute.name.toLowerCase();
if (name === "id") {
node.removeAttribute(name);
return;
}
if (name === "title" || name === "aria-description") {
node.setAttribute(name, "[Accessible description.]");
return;
}
if (name === "alt") {
node.setAttribute(name, "[Accessible description.]");
return;
}
if (name === "src" || name === "srcset") {
node.removeAttribute(name);
return;
}
if (name.startsWith("data-") && !allowedMatterData.has(name))
node.removeAttribute(name);
});
});
const allowedUnitData = new Set([
"data-answered",
"data-detail",
"data-editor-preserve-dom",
"data-editor-x",
"data-editor-y",
"data-evidence",
"data-fill-token",
"data-kind",
"data-role",
"data-section-target",
"data-single",
"data-text-token",
"data-unit",
]);
all("section.ld-unit[data-unit]", cloneRoot).forEach((section) => {
Array.from(section.attributes).forEach((attribute) => {
const name = attribute.name.toLowerCase();
if (name === "data-role" && !UNIT_ROLES.includes(attribute.value)) {
section.removeAttribute(name);
return;
}
if (name === "id") {
section.removeAttribute(name);
return;
}
if (name === "title" || name === "aria-description") {
section.setAttribute(name, "[Accessible description.]");
return;
}
if (name.startsWith("data-") && !allowedUnitData.has(name))
section.removeAttribute(name);
});
});
all("section.ld-unit .ld-variant", cloneRoot).forEach((variant) => {
Array.from(variant.attributes).forEach((attribute) => {
const name = attribute.name.toLowerCase();
if (name === "id") {
variant.removeAttribute(name);
return;
}
if (name === "title" || name === "aria-description") {
variant.setAttribute(name, "[Accessible description.]");
return;
}
if (name.startsWith("data-") && name !== "data-variant")
variant.removeAttribute(name);
});
});
all("main [title],#popup-scrim [title]", cloneRoot).forEach((node) => {
node.setAttribute("title", "[Accessible description.]");
});
const main = cloneRoot.querySelector("main");
if (main) {
const walker = document.createTreeWalker(main, NodeFilter.SHOW_TEXT);
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
textNodes.forEach((textNode) => {
const value = textNode.textContent.trim();
if (!value || validTemplatePlaceholder(value)) return;
const parent = textNode.parentElement;
if (
!parent ||
parent.closest(
"script,style,section.ld-unit,#ld-form-navigation,#ld-page-approach,.ld-diagram",
) ||
parent.closest("[data-placeholder]")
)
return;
textNode.textContent = "[The value or fact this block communicates.]";
});
}
// Reconstruct navigation chrome after generic matter-text scrubbing.
// Do not preserve an edited label or count as a purported safe value.
all(".ld-index-group", cloneRoot).forEach((group, index) => {
group.open = false;
const summary = group.querySelector(":scope > summary");
if (summary) summary.textContent = `Topic group ${index + 1}`;
});
all(".ld-overview-groups", cloneRoot).forEach((container) => {
all(":scope > .ld-overview-group", container).forEach(
(group, index) => {
group.open = index === 0;
group.setAttribute("data-overview-group", String(index));
const label = group.querySelector(".ld-overview-group-label");
if (label) {
label.textContent = `Topic group ${index + 1}`;
label.setAttribute("data-overview-group-label", String(index));
}
const count = group.querySelector(".ld-overview-group-count");
const total =
nextState.overview?.groups?.[index]?.topicUnitIds.length || 0;
if (count)
count.textContent = `${total} ${total === 1 ? "topic" : "topics"}`;
},
);
});
// The issue anatomy teaches the same three jobs after matter removal.
// Fixed semantic labels, not arbitrary author headings, survive export.
all(".ld-issue-analysis", cloneRoot).forEach((column) => {
const labels = [
"[Statement or finding.]",
"[Why it matters.]",
"[Recommendation or next check.]",
];
all(":scope > section.ld-unit", column).forEach((unit, index) => {
const heading = unit.querySelector("h2,h3");
if (!heading || !labels[index]) return;
heading.textContent = labels[index];
heading.setAttribute("data-placeholder", labels[index]);
});
});
all(".ld-source-preview", cloneRoot).forEach((preview) => {
const heading = preview.querySelector("h2,h3");
const quote = preview.querySelector("blockquote");
const caption = preview.querySelector("figcaption");
for (const [node, label] of [
[heading, "[Source passage.]"],
[quote, "[Exact supporting excerpt, with its qualification.]"],
[caption, "[Source, locator and review status.]"],
]) {
if (!node) continue;
node.textContent = label;
node.setAttribute("data-placeholder", label);
}
});
restoreTrustedTemplateStyles(cloneRoot);
all("style", cloneRoot).forEach((style) => {
let css = style.textContent;
css = rewriteAttributeSelectorMap(css, "data-unit", unitMap);
css = rewriteAttributeSelectorMap(
css,
"data-composition-section",
sectionMap,
);
css = rewriteClassSelectorMap(css, classMap);
style.textContent = css;
});
all("#popup-scrim .pop", cloneRoot).forEach((pop) => {
const oldId = pop.id;
const newId = mappedReference(oldId);
const tag = pop.querySelector(".tag");
const heading = pop.querySelector("h2");
const desc = pop.querySelector(".desc");
remapIdReferences(oldId, newId);
remapIdReferences(oldId + "-title", newId + "-title");
pop.id = newId;
pop.setAttribute("data-evidence-id", newId);
pop.setAttribute("aria-labelledby", newId + "-title");
if (tag) tag.textContent = "[Citation or record label.]";
if (heading) {
heading.id = newId + "-title";
heading.textContent =
"[What this source establishes, in one sentence.]";
}
if (desc)
desc.textContent =
"[Two sentences that tell the reader what they are looking at.]";
all(".doc-text", pop).forEach((node) => {
node.textContent =
"[Verbatim excerpt of the controlling clause, in the document's words.]";
});
all("blockquote", pop).forEach((node) => {
node.textContent =
"[Verbatim excerpt of the controlling clause, in the document's words.]";
});
all(".doc-cl", pop).forEach((node) => {
node.textContent = "[Section and page.]";
});
all(".doc-foot,.ld-popup-source", pop).forEach((node) => {
node.textContent = "[Source, version, date retrieved.]";
});
const evidencePlaceholders = {
cite: "[Citation or record label.]",
locator: "[Section and page.]",
detail:
"[Verbatim excerpt of the controlling clause, in the document's words.]",
"popup.title": "[The question or insight this popup explains.]",
"popup.lede": "[One-sentence orientation to the deeper detail.]",
};
all("[data-evidence-field]", pop).forEach((node) => {
const field = node.getAttribute("data-evidence-field");
const sectionField = /^popup\.sections\.\d+\.(heading|body)$/.exec(
field,
);
const placeholder =
evidencePlaceholders[field] ||
(sectionField && sectionField[1] === "heading"
? "[Specific subpoint.]"
: sectionField
? "[The deeper detail the reader needs here.]"
: null);
if (!placeholder) return;
node.setAttribute("data-placeholder", placeholder);
node.textContent = placeholder;
});
all("img", pop).forEach((image) => {
const svg = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
svg.setAttribute("viewBox", "0 0 800 420");
svg.setAttribute("role", "img");
svg.setAttribute(
"aria-label",
"[Clip of the source page with the cited passage highlighted.]",
);
svg.innerHTML =
'<rect width="799" height="419" x=".5" y=".5" fill="var(--card)" stroke="var(--line-strong)"/><text x="400" y="210" text-anchor="middle" fill="currentColor" font-family="Helvetica,Arial,sans-serif" font-size="20">[Clip of the source page with the cited passage highlighted.]</text>';
image.replaceWith(svg);
});
all("a", pop).forEach((link) => {
link.setAttribute("href", "#");
link.removeAttribute("target");
link.removeAttribute("rel");
link.textContent = "[Open the clause in the source document.]";
});
all(".out,.ld-source-unavailable", pop).forEach((source) => {
if (!source.querySelector("a"))
source.textContent =
"[Original source unavailable in this template.]";
});
});
const safeLabels = [
"Close source popup",
"Theme",
"Edit",
"Export HTML",
"Export template",
"Save",
"Undo",
"Redo",
"Duplicate",
"Delete",
"Bold",
"Italic",
"Underline",
"Increase type",
"Decrease type",
"Reset",
"More",
"Treatment",
"Treatment A",
"Treatment B",
"Fill token",
"Text token",
"Custom position",
"Note",
"Previous slide",
"Next slide",
"Slide navigation",
"Select slide",
"Report sections",
"Contents",
"Collapse contents",
"Expand contents",
"Open contents",
"Previous page",
"Next page",
"Previous section",
"Next section",
];
all("[aria-label]", cloneRoot).forEach((node) => {
const value = node.getAttribute("aria-label");
if (value && !safeLabels.includes(value))
node.setAttribute("aria-label", "[Accessible description.]");
});
all("a[href]", cloneRoot).forEach((link) => {
link.setAttribute("href", "#");
link.removeAttribute("target");
link.removeAttribute("rel");
if (link.closest("#popup-scrim")) return;
const internal =
link.hasAttribute("data-page") ||
link.hasAttribute("data-frame-button") ||
link.hasAttribute("data-evidence") ||
link.hasAttribute("data-detail");
if (internal && link.children.length) return;
if (
link.hasAttribute("data-evidence") ||
link.hasAttribute("data-detail")
)
link.textContent = "[Open the source.]";
else if (internal) link.textContent = "[Open the relevant section.]";
else link.textContent = "[Link.]";
});
all("[data-decision-custom],[data-decision-note]", cloneRoot).forEach(
(input) => {
input.value = "";
input.setAttribute(
"aria-label",
input.hasAttribute("data-decision-custom")
? "Custom position"
: "Note",
);
},
);
all("[data-report-section]", cloneRoot).forEach((button, index) => {
button.textContent = "[Section " + (index + 1) + ".]";
});
all("#ld-page-approach [data-select-approach]", cloneRoot).forEach(
(button) => {
const key =
button.getAttribute("data-select-approach") === "b" ? "B" : "A";
button.innerHTML = "<span>" + key + "</span>Page " + key;
},
);
all("[data-zone-doc]", cloneRoot).forEach((node) => {
node.removeAttribute("data-zone-doc");
});
scrubTemplateMatterAttributes(cloneRoot);
}
function download(text, name) {
const blob = new Blob([text], { type: "text/html;charset=utf-8" });
const link = document.createElement("a");
link.className = "ld-download-link";
const url = URL.createObjectURL(blob);
link.href = url;
link.download = name;
link.hidden = true;
link.setAttribute("data-editor-chrome", "");
document.body.appendChild(link);
try {
link.click();
} catch (error) {
link.remove();
URL.revokeObjectURL(url);
throw error;
}
// The browser consumes a Blob asynchronously. Do not revoke it in the
// same task that requests the download, or assume the request reached disk.
setTimeout(() => {
link.remove();
URL.revokeObjectURL(url);
}, 60000);
}
let fileWriteInProgress = false;
let clientPreviewURL = null;
function clearSaveBanner() {
const old = document.querySelector(".ld-save-banner");
if (old) old.remove();
if (clientPreviewURL) {
URL.revokeObjectURL(clientPreviewURL);
clientPreviewURL = null;
}
}
async function persistHTML(
html,
name,
reuseWorkingHandle = false,
clientPreview = false,
) {
if (fileWriteInProgress) {
showSaveBanner(
"A save is already in progress. Finish that save first.",
8000,
);
return "busy";
}
fileWriteInProgress = true;
clearSaveBanner();
let writable;
try {
if (typeof window.showSaveFilePicker === "function") {
try {
// Invoke the picker before the first await, while the button's
// user activation is still available. Exports never reuse the
// working-copy handle or overwrite it without a fresh choice.
const handle =
reuseWorkingHandle && fileHandle
? fileHandle
: await window.showSaveFilePicker({
suggestedName: name,
types: [
{
description: "HTML file",
accept: { "text/html": [".html"] },
},
],
});
writable = await handle.createWritable();
await writable.write(html);
await writable.close();
writable = null;
if (reuseWorkingHandle) fileHandle = handle;
showSaveBanner(
`Saved ${handle.name || name} to the location you chose.${clientPreview ? " Open a preview of this exact exported copy below." : ""}`,
clientPreview ? 0 : 8000,
clientPreview ? html : null,
);
return "saved";
} catch (error) {
if (writable && typeof writable.abort === "function") {
try {
await writable.abort();
} catch {
/* Preserve the original failure. */
}
writable = null;
}
if (error && error.name === "AbortError") {
showSaveBanner(
"Save canceled. Your open work is unchanged.",
8000,
);
return "canceled";
}
if (reuseWorkingHandle) fileHandle = null;
}
}
download(html, name);
showSaveBanner(
`Download requested: ${name}. Check your browser's download location; this page cannot confirm it reached disk. Your open work is unchanged.${clientPreview ? " The link opens a preview of this exact exported copy, not the saved disk file." : ""}`,
clientPreview ? 0 : 16000,
clientPreview ? html : null,
);
return "download-requested";
} catch {
showSaveBanner(
"The file could not be saved. Your open work is unchanged. Try Save or Export again and choose a writable location.",
16000,
);
return "failed";
} finally {
fileWriteInProgress = false;
}
}
function stripAuthoringData(cloneRoot) {
// data-variant-body is a presentation wrapper: shared copy spacing and
// popup-text affordances depend on it even in a read-only client export.
const names = [
"data-card-title",
"data-document-title-placeholder",
"data-editable",
"data-diagram-label",
"data-evidence-field",
"data-evidence-id",
"data-editor-base-transform",
"data-editor-scale-x",
"data-editor-scale-y",
"data-editor-node-key",
"data-editor-origin-fill-token",
"data-editor-origin-fill-token-present",
"data-editor-origin-style",
"data-editor-origin-style-present",
"data-editor-origin-style-stamped",
"data-editor-origin-text-token",
"data-editor-origin-text-token-present",
"data-editor-only",
"data-editor-preserve-dom",
"data-editor-unit",
"data-editor-x",
"data-editor-y",
"data-export-chrome",
"data-export-protect",
"data-placeholder",
"data-param-path",
"data-single",
"data-zone-doc",
];
all("*", cloneRoot)
.concat([cloneRoot])
.forEach((node) => {
names.forEach((name) => {
node.removeAttribute(name);
});
});
}
function ensureClientResponseControl(scope) {
if (
scope.getAttribute("data-exported") !== "client" ||
scope.querySelector("#ld-response-save")
)
return;
const controls = scope.querySelector(".ld-controls");
if (!controls) return;
const button = document.createElement("button");
button.className = "ld-control";
button.id = "ld-response-save";
button.type = "button";
button.textContent = "Save responses";
controls.appendChild(button);
}
LD.exportHTML = (shouldDownload) => {
all('[contenteditable="true"]').forEach((node) => {
node.blur();
});
// The previous transfer's notice is editor chrome, not part of the next
// deliverable. Release its reserved space before checking page geometry.
clearSaveBanner();
fitFixedPages();
if (!validateExportPageFit(false))
throw new Error(
"Page exceeds the available window. Move detail to a popup or another page before exporting.",
);
const cloneRoot = document.documentElement.cloneNode(true);
scrubEditing(cloneRoot);
cloneRoot.setAttribute("data-exported", "client");
const clientBlueprintId =
"legaldesign-client-" + stableLocationHash(state.artifactId);
cloneRoot.setAttribute("data-client-blueprint-id", clientBlueprintId);
all("#export-html,#export-template", cloneRoot).forEach((node) => {
node.remove();
});
all("[data-placeholder],[data-placeholder-title]", cloneRoot).forEach(
(node) => {
node.removeAttribute("data-placeholder");
node.removeAttribute("data-placeholder-title");
},
);
if (state.approaches) {
const selectedApproach = currentApproach();
all("[data-approach]", cloneRoot).forEach((page) => {
if (page.getAttribute("data-approach") !== selectedApproach)
page.remove();
else page.hidden = false;
});
const approachControl = cloneRoot.querySelector("#ld-page-approach");
if (approachControl) approachControl.remove();
}
all("section.ld-unit[data-unit]", cloneRoot).forEach((section) => {
const sourceUnit = unitState(section.getAttribute("data-unit"));
if (!sourceUnit) {
section.remove();
return;
}
const selected = sourceUnit.selected;
all(".ld-variant", section).forEach((variant) => {
if (variant.getAttribute("data-variant") !== selected)
variant.remove();
else variant.hidden = false;
});
all(
".ld-ab,.ld-why,.ld-unit-tools,.ld-review,.ld-sheet-open",
section,
).forEach((node) => {
node.remove();
});
});
all("[data-editor-only]", cloneRoot).forEach((node) => {
node.remove();
});
const referenced = new Set(
all("[data-evidence],[data-detail],[data-evidence-ids]", cloneRoot)
.filter((node) => !node.closest("#popup-scrim"))
.flatMap((node) => {
const grouped = node.getAttribute("data-evidence-ids");
if (grouped) return grouped.split(/\s+/).filter(Boolean);
return [
node.getAttribute("data-evidence") ||
node.getAttribute("data-detail"),
].filter(Boolean);
}),
);
// A purposeful detail popup can link onward to an original source popup.
// Keep that whole reachable chain, not only the first surface hop.
for (const id of referenced) {
const pop = all("#popup-scrim .pop[id]", cloneRoot).find(
(node) => node.id === id,
);
if (!pop) continue;
all("[data-detail],[data-evidence],[data-evidence-ids]", pop).forEach(
(node) => {
const ids = node.getAttribute("data-evidence-ids");
(ids
? ids.split(/\s+/)
: [
node.getAttribute("data-detail") ||
node.getAttribute("data-evidence"),
]
)
.filter(Boolean)
.forEach((target) => {
referenced.add(target);
});
},
);
}
all("#popup-scrim .pop[id]", cloneRoot).forEach((pop) => {
if (!referenced.has(pop.id)) pop.remove();
});
const clientState = reducedClientState(cloneRoot, clientBlueprintId);
stripAuthoringData(cloneRoot);
ensureClientResponseControl(cloneRoot);
setCloneState(cloneRoot, clientState);
const html = serializeClone(cloneRoot);
if (shouldDownload !== false)
void persistHTML(html, state.artifactId + ".html", false, true);
return html;
};
LD.exportTemplate = (shouldDownload, requestedId) => {
all('[contenteditable="true"]').forEach((node) => {
node.blur();
});
clearSaveBanner();
fitFixedPages();
if (!validateExportPageFit(true))
throw new Error(
"Page exceeds the available window. Move detail to a popup or another page before exporting.",
);
const templateId = templateExportId(requestedId);
const cloneRoot = document.documentElement.cloneNode(true);
cloneRoot.dataset.templateId = templateId;
if (isPackagedTemplateId(requestedId)) {
cloneRoot.dataset.templateKind = "packaged";
} else {
cloneRoot.dataset.templateKind = "exported";
}
cloneRoot.dataset.templateBlueprintId = templateId;
scrubEditing(cloneRoot);
cloneRoot.setAttribute("data-exported", "template");
const maps = templateMappings();
const nextState = templateState(maps, templateId);
sanitizeTemplateDOM(cloneRoot, nextState, maps);
setCloneState(cloneRoot, nextState);
const html = serializeClone(cloneRoot);
if (shouldDownload !== false)
void persistHTML(html, templateId + ".html");
return html;
};
async function saveFile() {
all('[contenteditable="true"]').forEach((node) => {
node.blur();
});
state.savedAt = new Date().toISOString();
mirror();
storeEditorDOM();
const cloneRoot = document.documentElement.cloneNode(true);
scrubTransientEditing(cloneRoot);
setCloneState(cloneRoot, state);
const html = serializeClone(cloneRoot);
const saveName =
root.getAttribute("data-exported") === "client"
? state.artifactId + "-responses.html"
: state.artifactId + ".html";
const result = await persistHTML(html, saveName, true);
if (result === "saved") clearRestore();
return result === "canceled" || result === "failed" || result === "busy"
? null
: html;
}
function clearRestore() {
const banner = document.getElementById("ld-restore-banner");
if (banner) banner.remove();
}
function showSaveBanner(
message,
duration = 2400,
clientPreviewHTML = null,
) {
clearSaveBanner();
const banner = document.createElement("div");
banner.className = "ld-save-banner";
banner.setAttribute("role", "status");
banner.setAttribute("aria-live", "polite");
banner.setAttribute("data-editor-chrome", "");
banner.textContent = message;
if (clientPreviewHTML !== null) {
clientPreviewURL = URL.createObjectURL(
new Blob([clientPreviewHTML], { type: "text/html;charset=utf-8" }),
);
const link = document.createElement("a");
link.className = "ld-export-preview";
link.href = clientPreviewURL;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = "Open exported HTML";
banner.appendChild(link);
}
const bar = document.querySelector(".ld-bar");
(bar ? bar.parentNode : document.body).insertBefore(
banner,
bar ? bar.nextSibling : document.body.firstChild,
);
if (duration > 0)
setTimeout(() => {
if (banner.isConnected) clearSaveBanner();
}, duration);
}
function bindChrome() {
const theme = document.getElementById("theme-toggle");
if (theme)
theme.addEventListener("click", () => {
scheduleReady(true);
applyTheme(
root.getAttribute("data-theme") === "dark" ? "light" : "dark",
true,
);
});
const save = document.getElementById("ld-save");
if (save) save.addEventListener("click", saveFile);
const responseSave = document.getElementById("ld-response-save");
if (responseSave) responseSave.addEventListener("click", saveFile);
const html = document.getElementById("export-html");
if (html)
html.addEventListener("click", () => {
LD.exportHTML(true);
});
const template = document.getElementById("export-template");
if (template)
template.addEventListener("click", () => {
LD.exportTemplate(true);
});
}
LD.state = () => clone(state);
LD.setState = (next) => {
if (!next || next.schema !== "legaldesign.state.v1")
throw new TypeError("state must use legaldesign.state.v1");
checkpoint();
state = clone(next);
mirror();
renderAll({ sync: false });
storeEditorDOM();
};
LD.save = saveFile;
LD.setMode = setMode;
LD.setApproach = setApproach;
LD.openPopup = openPopup;
LD.closePopup = closePopup;
LD.bindDecisions = bindDecisions;
LD.placeholderDerivation = clone(PLACEHOLDERS);
scrubTransientEditing(root);
normalizeChrome();
applyTheme(initialTheme(), false);
removeLegacyReviewChrome();
ensureClientResponseControl(root);
installFixedPages();
renderAll();
bindApproaches();
bindUnits();
bindDecisions();
bindPopups();
installComposedForm();
bindEditor();
bindChrome();
installRestoreBanner();
updateEditorButtons();
scheduleReady(false);
// Phones land in the existing readable view, not an illegible miniature.
// Run once at load; deliberately closing it keeps the canvas available.
if (
singleComposition() &&
window.matchMedia("(max-width: 760px)").matches
) {
requestAnimationFrame(() => {
if (!editing && !activePopup())
openPageReader(document.getElementById("ld-read-page"));
});
}
}
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", boot, { once: true });
else boot();
})();
</script>
<!-- /legaldesign:runtime -->
<style id="diligence-report-layout">
:root { --rail-size: 44px; --slide-gutter: 36px; }
body[data-rail="open"] { --rail-size: 170px; }
body { overflow-x: hidden; }
.ld-bar { position: fixed; right: 0; left: 0; min-height: 48px; }
.ld-report-nav { display: flex; align-items: center; gap: 4px; }
.quiet-control { min-width: 36px; min-height: 36px; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); padding: 5px 8px; font-size: 12px; }
.quiet-control:hover { border-color: var(--red); color: var(--ink); }
.rail { position: fixed; z-index: 30; top: 48px; bottom: 0; left: 0; width: var(--rail-size); overflow-y: auto; border-right: 1px solid var(--line); background: var(--card); padding: 44px 8px 20px; }
.rail-g { border: 0; }
.rail-s { margin: 7px 0 3px; padding: 4px; color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: .09em; list-style: none; }
.rail-s::-webkit-details-marker { display: none; }
.rail-i { display: flex; width: 100%; gap: 7px; border: 0; border-radius: 3px; background: transparent; padding: 5px 4px; color: var(--muted); text-align: left; font-size: 11.5px; }
.rail-i span { min-width: 16px; color: var(--faint); font-size: 11px; text-align: right; }
.rail-i i { font-style: normal; }
.rail-i:hover, .rail-i[aria-current="true"] { background: var(--red-wash); color: var(--ink); }
#rail-handle { position: absolute; top: 7px; right: 6px; display: flex; min-width: 32px; height: 29px; align-items: center; justify-content: center; border: 1px solid var(--line-strong); border-radius: 4px; background: var(--card); padding: 0 8px; color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: .07em; }
body[data-rail="closed"] .rail { width: 44px; padding-inline: 6px; }
body[data-rail="closed"] .rail-g, body[data-rail="closed"] #rail-handle span { display: none; }
body[data-rail="closed"] #rail-handle { right: 6px; width: 32px; padding: 0; }
.stage { width: 100%; max-width: calc(1240px + var(--rail-size) + var(--slide-gutter)); min-height: 100dvh; margin: 0 auto; padding: 64px var(--slide-gutter) 12px calc(var(--rail-size) + var(--slide-gutter)); }
.sb-slide { position: relative; min-height: calc(100dvh - 76px); padding: 10px 0 74px; }
.title { margin: 0; font-size: clamp(25px, 3.25vw, 46px); line-height: 1.04; letter-spacing: -.025em; }
.subtitle { max-width: 72ch; margin: 8px 0 0; color: var(--muted); font-size: 14px; line-height: 1.45; }
.eyebrow { margin: 0 0 7px; color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: .08em; }
.eyebrow span { color: var(--red); }
.foot { display: flex; position: absolute; right: 0; bottom: 10px; left: 0; justify-content: space-between; gap: 18px; border-top: 1px solid var(--line); padding-top: 9px; }
.foot p { margin: 0; color: var(--muted); font-size: 11px; }
.dash-head, .dash-deal { min-width: 0; }
.dash-head { display: grid; grid-template-columns: minmax(180px,.65fr) minmax(0,2fr); gap: 24px; align-items: end; }
.dash-deal > * { min-width: 0; }
.dash-deal { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 9px 0; }
.dash-deal p { margin: 0; color: var(--red); font-size: 11px; font-weight: 700; letter-spacing: .08em; }
.dash-deal b { display: block; margin-top: 5px; color: var(--ink); font-size: 12px; line-height: 1.4; letter-spacing: 0; }
.dash .title { font-size: clamp(34px,4.5vw,56px); }
.dash-facts { margin-top: 14px; border: 1px solid var(--line-strong); border-radius: 4px; background: var(--card); }
.dash-facts :has(> .df) { display: grid; grid-template-columns: repeat(6,minmax(0,1fr)); }
.dash-facts .df { min-width: 0; border-right: 1px solid var(--line); padding: 8px 10px; }
.dash-facts .df:last-child { border-right: 0; }
.dash-facts b { display: block; margin-bottom: 3px; color: var(--faint); font-size: 11px; letter-spacing: .08em; }
.dash-facts span { font-size: 11px; line-height: 1.35; }
.dash-label { margin: 14px 0 7px; color: var(--muted); font-size: 11px; letter-spacing: .1em; }
.issue-cards { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; }
.issue-card { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--red); border-radius: 4px; padding: 10px 11px; background: var(--card); }
.issue-card .issue-tag { margin: 0; color: var(--red); font-size: 11px; letter-spacing: .08em; }
.issue-card h3 { margin: 5px 0 0; font-size: 13px; line-height: 1.25; }
.issue-card p:not(.issue-tag) { margin: 5px 0 8px; color: var(--muted); font-size: 11px; line-height: 1.4; }
.issue-card a { margin-top: auto; font-size: 11px; }
.issue-table { border-top: 1px solid var(--line-strong); }
.issue-row { display: grid; grid-template-columns: .7fr 1.55fr 1.35fr; gap: 12px; border-bottom: 1px solid var(--line); padding: 7px 4px; color: var(--ink); font-size: 11px; line-height: 1.35; }
.issue-row:not(.issue-head) { text-decoration-color: var(--line-strong); }
.issue-head { color: var(--faint); font-size: 11px; letter-spacing: .07em; }
.area-register { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); border-top: 1px solid var(--line-strong); border-left: 1px solid var(--line); }
.area-row { display: grid; grid-template-columns: 24px minmax(0,1fr); gap: 5px; min-height: 60px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 7px 8px; color: var(--ink); text-decoration-color: var(--line-strong); }
.area-row.muted { color: var(--muted); }
.area-n { color: var(--faint); font-size: 11px; }
.area-copy { display: flex; min-width: 0; flex-direction: column; }
.area-copy small { color: var(--faint); font-size: 11px; letter-spacing: .07em; }
.area-copy b { margin-top: 2px; font-size: 11px; line-height: 1.25; }
.area-copy em, .area-copy i { margin-top: 3px; color: var(--muted); font-size: 11px; font-style: normal; }
.area-copy i { color: var(--faint); }
.checklist .title { font-size: clamp(22px,2.5vw,32px); }
.report-register { margin-top: 12px; }
.reg { border-top: 1.5px solid var(--line-strong); }
.reg .rrow, .reg .rrow.iss { display: grid; grid-template-columns: 18px minmax(0,1fr) auto; gap: 0 10px; align-items: baseline; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; padding: 6px 4px 6px 2px; box-shadow: none; }
.reg .mk { width: 8px; height: 8px; align-self: center; background: var(--ink); }
.reg .rrow.iss { border-top: 1px solid var(--red); border-bottom-color: var(--red); }
.reg .rrow.iss .mk { background: var(--red); }
.reg .nm { font-size: 12px; font-weight: 650; line-height: 1.3; }
.reg .desc { display: block; margin-top: 1px; color: var(--muted); font-size: 11px; font-weight: 400; line-height: 1.35; }
.reg .stw { color: var(--faint); font-size: 11px; font-weight: 700; letter-spacing: .07em; }
.reg .rrow.iss .stw, .reg .rrow.iss .nm { color: var(--red); }
.register-action { display: inline-block; margin-top: 3px; font-size: 11px; }
.register-note { display: block; margin-top: 3px; color: var(--faint); font-size: 11px; font-style: normal; }
.reg-legend { display: flex; justify-content: flex-end; gap: 15px; margin-top: 7px; color: var(--muted); font-size: 11px; }
.reg-legend .sw { display: inline-block; width: 8px; height: 8px; margin-right: 5px; background: var(--ink); }
.reg-legend .sw.open { background: var(--red); }
.finding .title { font-size: clamp(23px,2.7vw,37px); }
.finding-grid { display: grid; grid-template-columns: minmax(0,1.28fr) minmax(310px,.9fr); gap: 22px; margin-top: 12px; }
.finding-block { border-top: 1px solid var(--line); padding: 8px 0; }
.finding-block.recommendation { border-top: 2px solid var(--red); }
.finding-block h2 { display: flex; gap: 8px; margin: 0 0 4px; font-size: 12px; }
.finding-block h2 span { color: var(--faint); }
.finding-block.recommendation h2, .finding-block.recommendation h2 span { color: var(--red); }
.finding-block p { margin: 0; font-size: 11.5px; line-height: 1.47; }
.finding-figure { border: 1px solid var(--line-strong); border-radius: 4px; background: var(--card); padding: 42px 10px 9px; }
.finding-figure .ld-diagram svg { width: 100%; max-height: 520px; }
.figure-sentence { padding: 22px 10px; color: var(--muted); font-family: var(--sans); font-size: 16px; line-height: 1.55; }
.figure-sentence p { margin: 0; }
.stats-row { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); border-top: 1px solid var(--line); border-left: 1px solid var(--line); }
.stat { border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 10px; }
.stat strong, .stat span { display: block; }
.stat strong { font-size: 19px; }
.stat span { margin-top: 3px; color: var(--muted); font-size: 11px; line-height: 1.3; }
.source-note { margin: 6px 2px 0; color: var(--faint); font-size: 11px; line-height: 1.35; }
.expected-record { margin-top: 7px; border-top: 1px solid var(--line-strong); padding-top: 8px; }
.expected-record p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.4; }
.evidence-panel { margin-top: 9px; }
.evidence-panel h2 { margin: 0 0 6px; color: var(--muted); font-size: 11px; letter-spacing: .09em; text-transform: uppercase; }
.evidence-card { display: block; width: 100%; border: 0; border-top: 1px solid var(--line); background: transparent; padding: 7px 2px; color: var(--ink); text-align: left; text-decoration: none; }
.evidence-card:hover { border-color: var(--red); }
.evidence-card small, .evidence-card b, .evidence-card span { display: block; }
.evidence-card small { color: var(--red); font-size: 11px; letter-spacing: .05em; }
.evidence-card b { margin-top: 2px; font-size: 11px; }
.evidence-card span { margin-top: 2px; color: var(--muted); font-size: 11px; line-height: 1.35; }
.evidence-frame { display: block; width: 100%; color: var(--ink); background: var(--card); }
.evidence-frame rect { fill: var(--card); stroke: var(--line-strong); }
.evidence-frame path { fill: none; stroke: var(--line-strong); stroke-width: 2; }
.evidence-frame text { fill: var(--muted); font: 13px var(--sans); }
html[data-exported="client"] .ld-bar, html[data-exported="template"] .ld-bar { position: relative; }
html[data-exported="client"] .rail, html[data-exported="template"] .rail { top: 0; }
html[data-exported="client"] .stage, html[data-exported="template"] .stage { padding-top: 16px; }
@media (max-width: 820px) {
.ld-bar { gap: 4px; padding-inline: 6px; }
.ld-report-nav { margin-right: auto; }
.stage { padding-right: 14px; padding-left: calc(var(--rail-size) + 14px); }
.finding-grid { grid-template-columns: 1fr; }
.dash-facts :has(> .df) { grid-template-columns: repeat(3,minmax(0,1fr)); }
.dash-facts .df:nth-child(3n) { border-right: 0; }
}
@media (max-width: 600px) {
body, body[data-rail="open"] { --rail-size: 0px; }
.ld-bar { min-height: 104px; align-content: center; }
.ld-report-nav, .ld-controls { flex: 0 0 100%; justify-content: center; margin: 0; }
.quiet-control { min-width: 44px; min-height: 44px; width: auto; height: 44px; }
.ld-control { min-height: 44px; font-size: 11px; }
.rail { display: none; top: 104px; width: min(320px, 100vw); padding: 14px 8px 20px; box-shadow: 10px 0 30px rgba(0, 0, 0, .22); }
body[data-rail="open"] .rail { display: block; width: min(320px, 100vw); }
body[data-rail="open"] .rail-g { display: block; }
#rail-handle, body[data-rail="closed"] #rail-handle { display: none; }
.stage { padding-top: 118px; padding-right: 10px; padding-left: 10px; }
.sb-slide { padding-bottom: 24px; }
.dash-head { grid-template-columns: 1fr; gap: 10px; }
.dash-facts :has(> .df) { grid-template-columns: 1fr 1fr; }
.dash-facts .df:nth-child(n) { border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.dash-facts .df:nth-child(even) { border-right: 0; }
.issue-cards, .area-register { grid-template-columns: 1fr; }
.issue-table { overflow-x: auto; }
.issue-row { min-width: 520px; }
.title, .dash .title { font-size: 27px; }
.reg .rrow, .reg .rrow.iss { grid-template-columns: 12px minmax(0,1fr); }
.reg .stw { grid-column: 2; margin-top: 3px; }
.foot { position: static; flex-direction: column; gap: 3px; margin-top: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.rail { transition: none; }
}
</style>
</head>
<body data-rail="open">
<header class="ld-bar bar" data-export-chrome>
<p class="ld-matter" data-placeholder="[Company] · legal due diligence · [transaction] · prepared for [recipient].">Foxhill Energy Analytics, Inc. · legal due diligence · Series A · prepared for Harbor Lane Ventures</p>
<div class="ld-report-nav"><button class="quiet-control" id="nav-prev" type="button" aria-label="Previous page">Prev</button><button class="quiet-control" id="nav-next" type="button" aria-label="Next page">Next</button><button class="quiet-control" id="rail-toggle" type="button" aria-pressed="true">Contents</button></div>
<div class="ld-controls"><button class="ld-control" id="mode-toggle" type="button" aria-pressed="false" data-editor-only>Edit</button><button class="ld-control" id="theme-toggle" type="button" aria-label="Theme">Theme</button><button class="ld-control" id="export-html" type="button">Export HTML</button><button class="ld-control" id="export-template" type="button">Export template</button></div>
<div class="ld-editor-tools" data-editor-only><button class="ld-tool" id="ld-save" type="button">Save</button><button class="ld-tool" id="editor-undo" type="button">Undo</button><button class="ld-tool" id="editor-redo" type="button">Redo</button><button class="ld-tool" id="editor-duplicate" type="button">Duplicate</button><button class="ld-tool" id="editor-delete" type="button">Delete</button><button class="ld-tool" id="editor-bold" type="button">Bold</button><button class="ld-tool" id="editor-italic" type="button">Italic</button><button class="ld-tool" id="editor-underline" type="button">Underline</button><span class="ld-overflow-wrap"><button class="ld-tool" id="editor-overflow-toggle" type="button" aria-expanded="false" aria-controls="editor-overflow-menu">»</button><span class="ld-overflow-menu" id="editor-overflow-menu"><button class="ld-tool" id="editor-font-up" type="button">Increase type</button><button class="ld-tool" id="editor-font-down" type="button">Decrease type</button><button class="ld-tool" id="editor-reset" type="button">Reset</button><select class="ld-palette" id="editor-fill-token" aria-label="Fill token"><option value="">Fill</option><option value="card">Card</option><option value="tint">Tint</option><option value="none">None</option></select><select class="ld-palette" id="editor-text-token" aria-label="Text token"><option value="">Text</option><option value="ink">Ink</option><option value="muted">Muted</option><option value="red">Red</option></select></span></span></div>
</header>
<nav class="rail" id="rail" aria-label="Contents"><button id="rail-handle" type="button" aria-expanded="true" aria-label="Collapse contents"><span>Diligence report</span></button><details class="rail-g" open><summary class="rail-s" data-placeholder="[REPORT AREA]">OVERVIEW</summary><button class="rail-i" type="button" data-page="p1" data-frame-button="1" aria-current="true"><span>01</span><i data-placeholder="[Page label.]">Dashboard</i></button></details><details class="rail-g" open><summary class="rail-s" data-placeholder="[REPORT AREA]">FORMATION</summary><button class="rail-i" type="button" data-page="p2" data-frame-button="2" aria-current="false"><span>02</span><i data-placeholder="[Page label.]">Checklist</i></button><button class="rail-i" type="button" data-page="p3" data-frame-button="3" aria-current="false"><span>03</span><i data-placeholder="[Page label.]">Good standing</i></button><button class="rail-i" type="button" data-page="p4" data-frame-button="4" aria-current="false"><span>04</span><i data-placeholder="[Page label.]">Salary approvals</i></button></details><details class="rail-g" open><summary class="rail-s" data-placeholder="[REPORT AREA]">FOUNDER STOCK</summary><button class="rail-i" type="button" data-page="p5" data-frame-button="5" aria-current="false"><span>05</span><i data-placeholder="[Page label.]">Checklist</i></button><button class="rail-i" type="button" data-page="p6" data-frame-button="6" aria-current="false"><span>06</span><i data-placeholder="[Page label.]">Okafor 83(b)</i></button></details></nav>
<main class="stage" data-export-protect="height min-height"><section class="sb-slide on dash" id="p1" data-frame="1" aria-label="Page 1 of 6" data-export-protect="height min-height"><div class="dash-head"><section class="ld-unit dash-deal" data-unit="u-dash-deal" data-kind="text" data-single><div class="ld-variant" data-variant="a"><div data-variant-body><p><span data-editable data-placeholder="[PROPOSED INVESTMENT]">PROPOSED INVESTMENT</span><b data-editable data-placeholder="[Amount, security, valuation · investor.]">$12 million Series A Preferred at a $48 million pre-money valuation · Harbor Lane Ventures</b></p></div></div></section><section class="ld-unit report-title" data-unit="u-dash-title" data-kind="text"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div data-variant-body><h1 class="title" data-editable data-placeholder="[Company name, or the number of findings that matter.]">Foxhill Energy Analytics, Inc.</h1><p class="subtitle" data-editable data-placeholder="[What this report is and who it is for.]">Legal due diligence on the data room, prepared for Harbor Lane Ventures.</p></div></div><div class="ld-variant" data-variant="b" hidden><div data-variant-body><h1 class="title" data-editable data-placeholder="[Company name, or the number of findings that matter.]">Three findings decide the Series A terms.</h1><p class="subtitle" data-editable data-placeholder="[What this report is and who it is for.]">Legal due diligence on Foxhill Energy Analytics, Inc., prepared for Harbor Lane Ventures.</p></div></div><aside class="ld-why" hidden>The company name as the title reads as a report cover, which is how the partner files it.</aside></section></div><section class="ld-unit dash-facts" data-unit="u-dash-facts" data-kind="table" data-single><div class="ld-variant" data-variant="a"><div data-variant-body><div class="df"><b data-editable data-placeholder="[Fact label, e.g. Incorporated]">INCORPORATED</b><span data-editable data-placeholder="[Value from the data room.]">Delaware, August 20, 2024</span></div><div class="df"><b data-editable data-placeholder="[Fact label, e.g. Incorporated]">HEADQUARTERS</b><span data-editable data-placeholder="[Value from the data room.]">San Mateo, California</span></div><div class="df"><b data-editable data-placeholder="[Fact label, e.g. Incorporated]">BUSINESS</b><span data-editable data-placeholder="[Value from the data room.]">Energy-analytics software</span></div><div class="df"><b data-editable data-placeholder="[Fact label, e.g. Incorporated]">TEAM</b><span data-editable data-placeholder="[Value from the data room.]">19 people: 14 employees and 5 contractors</span></div><div class="df"><b data-editable data-placeholder="[Fact label, e.g. Incorporated]">PRIOR ROUND</b><span data-editable data-placeholder="[Value from the data room.]">Series Seed, April 2025</span></div><div class="df"><b data-editable data-placeholder="[Fact label, e.g. Incorporated]">DATA ROOM</b><span data-editable data-placeholder="[Value from the data room.]">As provided August 21, 2026</span></div></div></div></section><h2 class="dash-label">KEY ISSUES</h2><section class="ld-unit dash-issues" data-unit="u-dash-issues" data-kind="card"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div data-variant-body><div class="issue-cards"><article class="issue-card"><p class="issue-tag" data-editable data-placeholder="[Area name, e.g. Formation]">FOUNDER STOCK</p><h3 data-editable data-placeholder="[The finding in five words.]">No proof of the CEO's 83(b) election.</h3><p data-editable data-placeholder="[One fact about it, under twenty words.]">Only an unsigned form is in the data room. The 30-day window on Sarah Okafor's 3,900,000 founder shares closed September 25, 2024.</p><a href="?frame=6" data-page="p6" data-placeholder="Go to the finding">Go to the finding</a></article><article class="issue-card"><p class="issue-tag" data-editable data-placeholder="[Area name, e.g. Formation]">FORMATION</p><h3 data-editable data-placeholder="[The finding in five words.]">The good standing certificate is 24 months old.</h3><p data-editable data-placeholder="[One fact about it, under twenty words.]">The only certificate is dated August 23, 2024. Two Delaware annual reports have come due since, with no filing evidence on file.</p><a href="?frame=3" data-page="p3" data-placeholder="Go to the finding">Go to the finding</a></article><article class="issue-card"><p class="issue-tag" data-editable data-placeholder="[Area name, e.g. Formation]">FORMATION</p><h3 data-editable data-placeholder="[The finding in five words.]">The founders' raises have no board approval on file.</h3><p data-editable data-placeholder="[One fact about it, under twenty words.]">$185,000 each from May 1, 2025, approved by stockholders in a consent tied to a different subject.</p><a href="?frame=4" data-page="p4" data-placeholder="Go to the finding">Go to the finding</a></article></div></div></div><div class="ld-variant" data-variant="b" hidden><div data-variant-body><div class="issue-table"><div class="issue-row issue-head"><b data-editable data-placeholder="[Column heading.]">Area</b><b data-editable data-placeholder="[Column heading.]">Finding</b><b data-editable data-placeholder="[Column heading.]">Ask at closing</b></div><a class="issue-row" href="?frame=6" data-page="p6"><span data-editable data-placeholder="[One card or row per finding that needs attention.]">Founder stock</span><span data-editable data-placeholder="[One card or row per finding that needs attention.]">No 83(b) proof for the CEO's 3,900,000 shares</span><span data-editable data-placeholder="[One card or row per finding that needs attention.]">A search, then an indemnity if nothing surfaces</span></a><a class="issue-row" href="?frame=3" data-page="p3"><span data-editable data-placeholder="[One card or row per finding that needs attention.]">Formation</span><span data-editable data-placeholder="[One card or row per finding that needs attention.]">Good standing certificate dated August 23, 2024</span><span data-editable data-placeholder="[One card or row per finding that needs attention.]">A current certificate, dated within ten days of closing</span></a><a class="issue-row" href="?frame=4" data-page="p4"><span data-editable data-placeholder="[One card or row per finding that needs attention.]">Formation</span><span data-editable data-placeholder="[One card or row per finding that needs attention.]">$185,000 founder raises with no board approval on file</span><span data-editable data-placeholder="[One card or row per finding that needs attention.]">A ratifying board consent and a compensation representation</span></a></div></div></div><aside class="ld-why" hidden>Three cards give each finding its own line and its own door to the finding page.</aside></section><h2 class="dash-label">ALL FINDINGS · SIX AREAS</h2><section class="ld-unit dash-areas" data-unit="u-dash-areas" data-kind="table" data-single><div class="ld-variant" data-variant="a"><div data-variant-body><div class="area-register"><a class="area-row" href="?frame=2" data-page="p2"><span class="area-n" data-editable data-placeholder="[01]">01</span><span class="area-copy"><small data-editable data-placeholder="[Area name, e.g. Formation]">FORMATION</small><b data-editable data-placeholder="[Area name.]">Formation and corporate records</b><em data-editable data-placeholder="[0 checked · 0 need attention]">14 checked · 3 need attention</em></span></a><a class="area-row" href="?frame=5" data-page="p5"><span class="area-n" data-editable data-placeholder="[01]">02</span><span class="area-copy"><small data-editable data-placeholder="[Area name, e.g. Formation]">FOUNDER STOCK</small><b data-editable data-placeholder="[Area name.]">Founder stock and 83(b) elections</b><em data-editable data-placeholder="[0 checked · 0 need attention]">8 checked · 2 need attention</em></span></a><div class="area-row muted"><span class="area-n" data-editable data-placeholder="[01]">03</span><span class="area-copy"><small data-editable data-placeholder="[Area name, e.g. Formation]">EQUITY PLAN</small><b data-editable data-placeholder="[Area name.]">Equity plan and option grants</b><em data-editable data-placeholder="[0 checked · 0 need attention]">14 checked · 8 need attention</em><i data-editable data-placeholder="[Not included in this template.]">Not in this sample</i></span></div><div class="area-row muted"><span class="area-n" data-editable data-placeholder="[01]">04</span><span class="area-copy"><small data-editable data-placeholder="[Area name, e.g. Formation]">SECURITIES</small><b data-editable data-placeholder="[Area name.]">Financings and securities filings</b><em data-editable data-placeholder="[0 checked · 0 need attention]">21 checked · 8 need attention</em><i data-editable data-placeholder="[Not included in this template.]">Not in this sample</i></span></div><div class="area-row muted"><span class="area-n" data-editable data-placeholder="[01]">05</span><span class="area-copy"><small data-editable data-placeholder="[Area name, e.g. Formation]">PEOPLE</small><b data-editable data-placeholder="[Area name.]">Employees, contractors and advisors</b><em data-editable data-placeholder="[0 checked · 0 need attention]">15 checked · 8 need attention</em><i data-editable data-placeholder="[Not included in this template.]">Not in this sample</i></span></div><div class="area-row muted"><span class="area-n" data-editable data-placeholder="[01]">06</span><span class="area-copy"><small data-editable data-placeholder="[Area name, e.g. Formation]">TAX AND FILINGS</small><b data-editable data-placeholder="[Area name.]">Tax, financial and state filings</b><em data-editable data-placeholder="[0 checked · 0 need attention]">8 checked · 8 need attention</em><i data-editable data-placeholder="[Not included in this template.]">Not in this sample</i></span></div></div></div></div></section><footer class="foot"><p data-editable data-placeholder="[How to read this report.]">Sample review. Every name, date, and number is fictional.</p><p data-editable data-placeholder="[Company · transaction · report date.]">Foxhill Energy Analytics, Inc. · Series A · report dated August 28, 2026</p></footer></section>
<section class="sb-slide checklist" id="p2" data-frame="2" aria-label="Page 2 of 6" hidden data-export-protect="height min-height"><p class="eyebrow" data-editable data-placeholder="[SECTION N OF TOTAL · COUNT DOCUMENTS CHECKED]">SECTION 1 OF 6 · FOURTEEN DOCUMENTS CHECKED</p><section class="ld-unit report-title" data-unit="u-p2-title" data-kind="text"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div data-variant-body><h1 class="title" data-editable data-placeholder="[The area, or its exceptions, in one sentence.]">Formation and corporate records.</h1><p class="subtitle" data-editable data-placeholder="[The counts.]">Fourteen documents checked: eleven in order, two incomplete, one missing.</p></div></div><div class="ld-variant" data-variant="b" hidden><div data-variant-body><h1 class="title" data-editable data-placeholder="[The area, or its exceptions, in one sentence.]">Two formation records are incomplete and one is missing.</h1><p class="subtitle" data-editable data-placeholder="[The counts.]">Eleven of fourteen are in order.</p></div></div><aside class="ld-why" hidden>The area name as the title keeps the checklist pages parallel.</aside></section><section class="ld-unit report-register" data-unit="u-p2-register" data-kind="table" data-single><div class="ld-variant" data-variant="a"><div data-variant-body><div class="reg"><div class="rrow" data-status="resolved"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Certificate of incorporation</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">The current charter is the restated certificate filed April 14, 2025, located and read in this review.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">RESOLVED</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Bylaws</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Adopted and certified August 22, 2024.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Action of sole incorporator</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Appoints Sarah Okafor and James Whitfield as the initial board.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Initial board consent</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Ratifies the bylaws, elects officers, authorizes the founder stock, sets the fiscal year, authorizes bank accounts.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">EIN confirmation (IRS CP 575)</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Issued August 27, 2024.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow iss" data-status="incomplete" data-open><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Delaware certificate of good standing</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">The only certificate is dated August 23, 2024.</span><a class="register-action" href="?frame=3" data-page="p3">Open the finding</a></span><span class="stw" data-editable data-placeholder="[STATUS]">INCOMPLETE</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Bank accounts</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Two accounts documented.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">California foreign qualification</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Registered September 4, 2024.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Stock issuance records</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Uncertificated shares by board election; issuance notices on file.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Minute book index</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">A running index of every board and stockholder action from August 20, 2024 to July 14, 2026.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Officer and director list</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Both founder directors, Marcus Levin in the preferred seat, and the officers elected August 22, 2024.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow iss" data-status="missing" data-open><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Indemnification agreements</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">None on file for any director or officer. The bylaws' indemnification article is the only cover, and Harbor Lane Ventures will designate a director.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">MISSING</span></div><div class="rrow" data-status="resolved"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Cap table</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">The August 19, 2026 export, read and reconciled in this review.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">RESOLVED</span></div><div class="rrow iss" data-status="incomplete" data-open><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Board and stockholder consents since inception</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">A substantial set is on file. Marcus Levin's board seat is confirmed; the founders' salary increases have no board approval on file.</span><a class="register-action" href="?frame=4" data-page="p4">Open the finding</a></span><span class="stw" data-editable data-placeholder="[STATUS]">INCOMPLETE</span></div></div><div class="reg-legend"><span><i class="sw"></i>In order</span><span><i class="sw open"></i>Needs attention</span></div></div></div></section><footer class="foot"><p data-editable data-placeholder="[How to read this report.]">Status labels are evidence states, not legal conclusions.</p><p data-editable data-placeholder="[Section and page.]">Section 1 of 6</p></footer></section>
<section class="sb-slide finding" id="p3" data-frame="3" aria-label="Page 3 of 6" hidden data-export-protect="height min-height"><p class="eyebrow" data-placeholder="[AREA · STATUS]">FORMATION AND CORPORATE RECORDS · <span>INCOMPLETE</span></p><section class="ld-unit report-title" data-unit="u-p3-title" data-kind="text"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div data-variant-body><h1 class="title" data-editable data-placeholder="[The finding in one sentence.]">The Delaware good standing certificate is nearly two years old.</h1></div></div><div class="ld-variant" data-variant="b" hidden><div data-variant-body><h1 class="title" data-editable data-placeholder="[The finding in one sentence.]">Foxhill cannot give a good standing representation at closing off a 2024 certificate.</h1></div></div><aside class="ld-why" hidden>Names the defect in the document, which is what the checklist row promised.</aside></section><div class="finding-grid"><div><section class="ld-unit finding-copy" data-unit="u-p3-finding" data-kind="text"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div data-variant-body><div class="finding-blocks"><article class="finding-block"><h2><span>1</span>What we found</h2><p data-editable data-placeholder="[The source reviewed, the controlling fact, and the exact gap, with dates and numbers.]">The only certificate in the file is dated August 23, 2024, three days after incorporation. Nothing more recent appears, and no Delaware annual report or franchise tax confirmation appears in the file either.</p></article><article class="finding-block"><h2><span>2</span>Why it matters</h2><p data-editable data-placeholder="[The practical consequence for this transaction.]">Good standing lapses when the annual report and franchise tax stop being filed, and Delaware's March 1 deadline has now passed twice. A 24-month-old certificate says nothing about the status today, and the company cannot give a good standing representation at closing off this document.</p></article><article class="finding-block recommendation"><h2><span>3</span>Recommendation</h2><p data-editable data-placeholder="[The closing condition, cure, covenant, representation, or follow-up.]">Make a current Delaware certificate of good standing, dated within ten days of closing, a closing deliverable. If it does not issue cleanly, an annual report is outstanding: require the overdue reports filed and the tax paid before the certificate is reordered.</p></article></div></div></div><div class="ld-variant" data-variant="b" hidden><div data-variant-body><div class="finding-blocks"><article class="finding-block"><h2><span>1</span>What we found</h2><p data-editable data-placeholder="[The source reviewed, the controlling fact, and the exact gap, with dates and numbers.]">One certificate, dated August 23, 2024. No annual report and no franchise tax confirmation since.</p></article><article class="finding-block"><h2><span>2</span>Why it matters</h2><p data-editable data-placeholder="[The practical consequence for this transaction.]">Two March 1 deadlines have passed. The certificate proves nothing about today, and no closing representation can rest on it.</p></article><article class="finding-block recommendation"><h2><span>3</span>Recommendation</h2><p data-editable data-placeholder="[The closing condition, cure, covenant, representation, or follow-up.]">A current certificate, dated within ten days of closing, as a closing deliverable. If it will not issue, the overdue reports and tax come first.</p></article></div></div></div><aside class="ld-why" hidden>The three-part finding is the grammar every page shares, so a reader can compare findings.</aside></section><section class="ld-unit expected-record" data-unit="u-p3-expected" data-kind="text" data-single><div class="ld-variant" data-variant="a"><div data-variant-body><p data-editable data-placeholder="[Expected record: the document, approval, or filing that would resolve the point.]">Expected: a current certificate of good standing from Delaware, dated within ten days of closing.</p></div></div></section></div><div><section class="ld-unit finding-figure" data-unit="u-p3-figure" data-kind="figure"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div class="ld-diagram" id="diagram-u-p3-figure-a"></div></div><div class="ld-variant" data-variant="b" hidden><div class="figure-sentence" data-editable data-placeholder="[Each date with what happened or came due on it.]"><p>The certificate is dated August 23, 2024. Annual reports came due on March 1, 2025 and March 1, 2026, and the file holds no evidence of either. Today the certificate is 24 months old.</p></div></div><p class="source-note" data-editable data-placeholder="[Source and locator.]">Data room, formation folder, as provided August 21, 2026.</p><aside class="ld-why" hidden>Dates on a scale show two missed deadlines sitting between the certificate and today.</aside></section><aside class="evidence-panel"><h2>Evidence reviewed</h2><a href="#ev-p3-cert" class="evidence-card" data-evidence="ev-p3-cert"><small data-placeholder="[Area · document and page.]">Formation · certificate of good standing</small><b data-placeholder="[What this source establishes, in one sentence.]">The certificate on file</b><span data-placeholder="[Two sentences that tell the reader what they are looking at.]">The Delaware certificate of good standing in the formation folder. It is dated three days after incorporation.</span></a></aside></div></div><footer class="foot"><p data-editable data-placeholder="[How to read this report.]">Marked incomplete.</p><p data-editable data-placeholder="[Section and page.]">Formation · finding 1 of 2</p></footer></section>
<section class="sb-slide finding" id="p4" data-frame="4" aria-label="Page 4 of 6" hidden data-export-protect="height min-height"><p class="eyebrow" data-placeholder="[AREA · STATUS]">FORMATION AND CORPORATE RECORDS · <span>INCOMPLETE</span></p><section class="ld-unit report-title" data-unit="u-p4-title" data-kind="text"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div data-variant-body><h1 class="title" data-editable data-placeholder="[The finding in one sentence.]">The founders' salary increases have stockholder approval but no board approval on file.</h1></div></div><div class="ld-variant" data-variant="b" hidden><div data-variant-body><h1 class="title" data-editable data-placeholder="[The finding in one sentence.]">Two directors set their own pay, and the board never approved it on the record.</h1></div></div><aside class="ld-why" hidden>Names the gap in the record, which is what the checklist row promised.</aside></section><div class="finding-grid"><div><section class="ld-unit finding-copy" data-unit="u-p4-finding" data-kind="text"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div data-variant-body><div class="finding-blocks"><article class="finding-block"><h2><span>1</span>What we found</h2><p data-editable data-placeholder="[The source reviewed, the controlling fact, and the exact gap, with dates and numbers.]">A stockholder written consent signed by the two seed funds approves $185,000 salaries for both founders effective May 1, 2025. The consent takes effect only after board resolutions approving the restated charter, a different subject, and no board consent approving the salary increase itself appears anywhere in the file. The founders' own signature pages are not with the consent either.</p></article><article class="finding-block"><h2><span>2</span>Why it matters</h2><p data-editable data-placeholder="[The practical consequence for this transaction.]">Both founders sit on the board and both benefit from the raise, so this is pay the directors set for themselves. Delaware treats that as a self-interested decision: it holds up when disinterested stockholders approve it on a clean record, and this record ties the approval to the wrong subject. The exposure sits with the company Harbor Lane Ventures is buying into, and it runs from May 1, 2025 forward.</p></article><article class="finding-block recommendation"><h2><span>3</span>Recommendation</h2><p data-editable data-placeholder="[The closing condition, cure, covenant, representation, or follow-up.]">Require the board consent ratifying both salary increases from May 1, 2025, and the completed stockholder consent with the founders' signature pages, as pre-closing deliverables. Add a compensation representation to the Series A purchase agreement covering approval of all officer pay, and put officer compensation on the list of actions needing the preferred director's vote.</p></article></div></div></div><div class="ld-variant" data-variant="b" hidden><div data-variant-body><div class="finding-blocks"><article class="finding-block"><h2><span>1</span>What we found</h2><p data-editable data-placeholder="[The source reviewed, the controlling fact, and the exact gap, with dates and numbers.]">$185,000 each from May 1, 2025, approved by the two seed funds in a consent that takes effect on a different subject. No board consent. No founder signature pages.</p></article><article class="finding-block"><h2><span>2</span>Why it matters</h2><p data-editable data-placeholder="[The practical consequence for this transaction.]">Directors setting their own pay need a clean disinterested approval. This record does not give one, and the exposure runs from May 2025.</p></article><article class="finding-block recommendation"><h2><span>3</span>Recommendation</h2><p data-editable data-placeholder="[The closing condition, cure, covenant, representation, or follow-up.]">A ratifying board consent and the completed stockholder consent before closing. A compensation representation in the purchase agreement. Officer pay on the preferred director's vote list.</p></article></div></div></div><aside class="ld-why" hidden>The three-part finding is the grammar every page shares, so a reader can compare findings.</aside></section><section class="ld-unit expected-record" data-unit="u-p4-expected" data-kind="text" data-single><div class="ld-variant" data-variant="a"><div data-variant-body><p data-editable data-placeholder="[Expected record: the document, approval, or filing that would resolve the point.]">Expected: for a transaction with directors on both sides, approval by the directors who do not benefit from it, or approval by the stockholders, normally with the board acting first.</p></div></div></section></div><div><section class="ld-unit finding-figure" data-unit="u-p4-figure" data-kind="figure"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div class="ld-diagram" id="diagram-u-p4-figure-a"></div></div><div class="ld-variant" data-variant="b" hidden><div class="ld-diagram" id="diagram-u-p4-figure-b"></div></div><p class="source-note" data-editable data-placeholder="[Source and locator.]">Stockholder written consent, effective May 1, 2025; minute book index.</p><aside class="ld-why" hidden>One decision at the centre and four approvals pointing at it, three of them absent.</aside></section><aside class="evidence-panel"><h2>Evidence reviewed</h2><a href="#ev-p4-consent" class="evidence-card" data-evidence="ev-p4-consent"><small data-placeholder="[Area · document and page.]">Formation · stockholder consent</small><b data-placeholder="[What this source establishes, in one sentence.]">The consent that approves the raise</b><span data-placeholder="[Two sentences that tell the reader what they are looking at.]">The stockholder written consent signed by the two seed funds. Its effectiveness clause ties it to the charter resolutions, not to the salaries.</span></a></aside></div></div><footer class="foot"><p data-editable data-placeholder="[How to read this report.]">Marked incomplete.</p><p data-editable data-placeholder="[Section and page.]">Formation · finding 2 of 2</p></footer></section>
<section class="sb-slide checklist" id="p5" data-frame="5" aria-label="Page 5 of 6" hidden data-export-protect="height min-height"><p class="eyebrow" data-editable data-placeholder="[SECTION N OF TOTAL · COUNT DOCUMENTS CHECKED]">SECTION 2 OF 6 · EIGHT DOCUMENTS CHECKED</p><section class="ld-unit report-title" data-unit="u-p5-title" data-kind="text"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div data-variant-body><h1 class="title" data-editable data-placeholder="[The area, or its exceptions, in one sentence.]">Founder stock and 83(b) elections.</h1><p class="subtitle" data-editable data-placeholder="[The counts.]">Eight documents checked: six in order, one missing, one incomplete.</p></div></div><div class="ld-variant" data-variant="b" hidden><div data-variant-body><h1 class="title" data-editable data-placeholder="[The area, or its exceptions, in one sentence.]">One founder's 83(b) proof is missing and the vesting amendments were never signed.</h1><p class="subtitle" data-editable data-placeholder="[The counts.]">Six of eight are in order.</p></div></div><aside class="ld-why" hidden>The area name as the title keeps the checklist pages parallel.</aside></section><section class="ld-unit report-register" data-unit="u-p5-register" data-kind="table" data-single><div class="ld-variant" data-variant="a"><div data-variant-body><div class="reg"><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Okafor restricted stock purchase agreement</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Signed both sides, August 26, 2024. Sells 3,900,000 shares and assigns her IP to the company now, not as a future promise.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Whitfield restricted stock purchase agreement</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Signed both sides, August 26, 2024. Sells 3,820,000 shares with the same present-tense IP assignment.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Notices of stock issuance</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">On file for both founders.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Founder IP assignment</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Each purchase agreement assigns the founder's pre-incorporation work to the company in the present tense.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow" data-status="in-order"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Founder vesting and repurchase schedule</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">One schedule for both founders: 1/72 monthly from August 26, 2024, no cliff.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">IN ORDER</span></div><div class="rrow iss" data-status="missing" data-open><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Okafor 83(b) election</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Only the unsigned form attached to her purchase agreement is on file. The filing window closed September 25, 2024.</span><a class="register-action" href="?frame=6" data-page="p6">Open the finding</a></span><span class="stw" data-editable data-placeholder="[STATUS]">MISSING</span></div><div class="rrow" data-status="resolved"><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Whitfield 83(b) election</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">Signed August 26, mailed August 28, IRS-stamped received September 9, 2024.</span></span><span class="stw" data-editable data-placeholder="[STATUS]">RESOLVED</span></div><div class="rrow iss" data-status="incomplete" data-open><span class="mk" aria-hidden="true"></span><span class="nm"><span data-editable data-placeholder="[Document name.]">Founder vesting amendments</span><span class="desc" data-editable data-placeholder="[What was found, with the date.]">The Series Seed purchase agreement required six-year vesting amendments; no signed amendment is on file for either founder.</span><i class="register-note" data-editable data-placeholder="[Finding not included in this sample.]">Finding not in this sample</i></span><span class="stw" data-editable data-placeholder="[STATUS]">INCOMPLETE</span></div></div><div class="reg-legend"><span><i class="sw"></i>In order</span><span><i class="sw open"></i>Needs attention</span></div></div></div></section><footer class="foot"><p data-editable data-placeholder="[How to read this report.]">In a live review, every document name links to its copy in the data room.</p><p data-editable data-placeholder="[Section and page.]">Section 2 of 6</p></footer></section>
<section class="sb-slide finding" id="p6" data-frame="6" aria-label="Page 6 of 6" hidden data-export-protect="height min-height"><p class="eyebrow" data-placeholder="[AREA · STATUS]">FOUNDER STOCK · <span>MISSING</span></p><section class="ld-unit report-title" data-unit="u-p6-title" data-kind="text"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div data-variant-body><h1 class="title" data-editable data-placeholder="[The finding in one sentence.]">No 83(b) election proof is on file for Sarah Okafor's founder stock.</h1></div></div><div class="ld-variant" data-variant="b" hidden><div data-variant-body><h1 class="title" data-editable data-placeholder="[The finding in one sentence.]">Until an 83(b) election surfaces, every vesting date on the CEO's founder stock is a taxable event.</h1></div></div><aside class="ld-why" hidden>Names the missing document, which is what the checklist row promised.</aside></section><div class="finding-grid"><div><section class="ld-unit finding-copy" data-unit="u-p6-finding" data-kind="text"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div data-variant-body><div class="finding-blocks"><article class="finding-block"><h2><span>1</span>What we found</h2><p data-editable data-placeholder="[The source reviewed, the controlling fact, and the exact gap, with dates and numbers.]">Okafor bought 3,900,000 shares of restricted stock on August 26, 2024, for $390.00 in total. Her purchase agreement, p. 9, recites that she has decided to make an 83(b) election and is submitting an executed form. The only election in the data room is the form at Exhibit B, p. 16: filled in, unsigned, and undated. No IRS-stamped copy, no certified-mail receipt, and no tracking appears anywhere in the file. The window closed Wednesday, September 25, 2024. James Whitfield's package is the contrast: signed, mailed, and IRS-stamped inside the window.</p></article><article class="finding-block"><h2><span>2</span>Why it matters</h2><p data-editable data-placeholder="[The practical consequence for this transaction.]">Filed on time, the election would have taxed Okafor once, on zero: she paid exactly what the shares were worth. Without it, each vesting date is new pay, monthly, on 3,900,000 shares that finish vesting in August 2030. The company has to value its stock and withhold through payroll for every month already passed, and its own deduction depends on the W-2 reporting. The unpaid withholding, penalties, and interest sit inside the company Harbor Lane Ventures is investing in, and the CEO carries a personal tax bill that rises with the share price.</p></article><article class="finding-block recommendation"><h2><span>3</span>Recommendation</h2><p data-editable data-placeholder="[The closing condition, cure, covenant, representation, or follow-up.]">Require the search before signing: Okafor's own records and email, formation counsel's file, a certified-mail receipt, an IRS-stamped copy. If the proof surfaces, it goes into the data room and this point closes. If it does not, seek a specific indemnity for the founder-level and company-level payroll tax exposure, escrowed, and make the payroll correction plan and the valuation record for each vesting date closing deliverables.</p></article></div></div></div><div class="ld-variant" data-variant="b" hidden><div data-variant-body><div class="finding-blocks"><article class="finding-block"><h2><span>1</span>What we found</h2><p data-editable data-placeholder="[The source reviewed, the controlling fact, and the exact gap, with dates and numbers.]">3,900,000 shares bought August 26, 2024 for $390.00. The agreement recites an executed election; the file holds an unsigned, undated form and nothing from the IRS. The window closed September 25, 2024. Whitfield's package is complete.</p></article><article class="finding-block"><h2><span>2</span>Why it matters</h2><p data-editable data-placeholder="[The practical consequence for this transaction.]">Without the election, every monthly vesting date through August 2030 is pay. The company owes withholding for each month already passed, and the CEO owes tax that grows with the share price.</p></article><article class="finding-block recommendation"><h2><span>3</span>Recommendation</h2><p data-editable data-placeholder="[The closing condition, cure, covenant, representation, or follow-up.]">Search first: her records, formation counsel's file, a mail receipt, a stamped copy. If nothing surfaces, an escrowed specific indemnity, a payroll correction plan, and a valuation record for every vesting date.</p></article></div></div></div><aside class="ld-why" hidden>The three-part finding is the grammar every page shares, so a reader can compare findings.</aside></section><section class="ld-unit expected-record" data-unit="u-p6-expected" data-kind="text" data-single><div class="ld-variant" data-variant="a"><div data-variant-body><p data-editable data-placeholder="[Expected record: the document, approval, or filing that would resolve the point.]">Expected: a signed election plus proof it reached the IRS inside the 30-day window, an IRS-stamped copy or a certified-mail receipt. A recollection of mailing is not proof.</p></div></div></section></div><div><section class="ld-unit finding-figure" data-unit="u-p6-figure" data-kind="figure"><div class="ld-unit-tools" data-editor-only><div class="ld-ab" role="group" tabindex="0" aria-label="Treatment"><button type="button" data-select-variant="a" aria-label="Treatment A" aria-pressed="true">A</button><button type="button" data-select-variant="b" aria-label="Treatment B" aria-pressed="false">B</button></div><button class="ld-why-toggle" type="button" aria-expanded="false">Why</button></div><div class="ld-variant" data-variant="a"><div class="ld-diagram" id="diagram-u-p6-figure-a"></div></div><div class="ld-variant" data-variant="b" hidden><div class="stats-row"><div class="stat"><strong data-editable data-placeholder="[Value.]">3,900,000</strong><span data-editable data-placeholder="[What the value means.]">shares bought August 26, 2024</span></div><div class="stat"><strong data-editable data-placeholder="[Value.]">$390</strong><span data-editable data-placeholder="[What the value means.]">paid, equal to their value</span></div><div class="stat"><strong data-editable data-placeholder="[Value.]">Sep 25, 2024</strong><span data-editable data-placeholder="[What the value means.]">the day the 30-day window closed</span></div><div class="stat"><strong data-editable data-placeholder="[Value.]">72</strong><span data-editable data-placeholder="[What the value means.]">monthly vesting dates through August 2030, each taxable without the election</span></div></div></div><p class="source-note" data-editable data-placeholder="[Source and locator.]">Okafor restricted stock purchase agreement and Exhibit B; stock ledger.</p><aside class="ld-why" hidden>Left-to-right stages with named gaps show where the election had to happen and what follows without it.</aside></section><aside class="evidence-panel"><h2>Evidence reviewed</h2><a href="#ev-p6-recital" class="evidence-card" data-evidence="ev-p6-recital"><small data-placeholder="[Area · document and page.]">Founder stock · purchase agreement, p. 9</small><b data-placeholder="[What this source establishes, in one sentence.]">The recital that an election was being submitted</b><span data-placeholder="[Two sentences that tell the reader what they are looking at.]">Section 8 of Okafor's restricted stock purchase agreement. It says an executed election is being submitted with the agreement.</span></a><a href="#ev-p6-form" class="evidence-card" data-evidence="ev-p6-form"><small data-placeholder="[Area · document and page.]">Founder stock · Exhibit B, p. 16</small><b data-placeholder="[What this source establishes, in one sentence.]">The election form as it sits in the file</b><span data-placeholder="[Two sentences that tell the reader what they are looking at.]">The Exhibit B election form attached to the agreement. The taxpayer, date, and signature lines are empty.</span></a></aside></div></div><footer class="foot"><p data-editable data-placeholder="[How to read this report.]">Marked missing.</p><p data-editable data-placeholder="[Section and page.]">Founder stock · finding 1 of 1 in this sample</p></footer></section></main>
<div class="ld-scrim" id="popup-scrim" hidden><div class="pop" id="ev-p3-cert" hidden role="dialog" aria-modal="true" aria-labelledby="ev-p3-cert-title"><button class="ld-popup-close" type="button" aria-label="Close source popup">×</button><p class="tag" data-editable data-placeholder="[Area · document and page.]">Formation · certificate of good standing</p><h2 id="ev-p3-cert-title" data-editable data-placeholder="[What this source establishes, in one sentence.]">The certificate on file</h2><p class="desc" data-editable data-placeholder="[Two sentences that tell the reader what they are looking at.]">The Delaware certificate of good standing in the formation folder. It is dated three days after incorporation.</p><div class="doc"><svg class="evidence-frame" viewBox="0 0 720 250" role="img" aria-label="Clip of the source page, not included in this sample."><rect x=".5" y=".5" width="719" height="249"></rect><path d="M82 64H638M82 96H560M82 128H610M82 160H520"></path><text x="360" y="214" text-anchor="middle" data-placeholder="[Clip of the source page with the cited passage highlighted.]">Clip of the source page, not included in this sample.</text></svg><div class="doc-text" data-editable data-placeholder="[Verbatim excerpt of the controlling passage, in the document's words.]">Certificate of good standing · Foxhill Energy Analytics, Inc. · August 23, 2024</div><p class="doc-cl" data-editable data-placeholder="[Document, section, and page.]">Formation folder, item 6</p></div><p class="out" data-editable data-placeholder="[Open the document in the data room.]">In a live review this line links to the document in the data room.</p></div>
<div class="pop" id="ev-p4-consent" hidden role="dialog" aria-modal="true" aria-labelledby="ev-p4-consent-title"><button class="ld-popup-close" type="button" aria-label="Close source popup">×</button><p class="tag" data-editable data-placeholder="[Area · document and page.]">Formation · stockholder consent</p><h2 id="ev-p4-consent-title" data-editable data-placeholder="[What this source establishes, in one sentence.]">The consent that approves the raise</h2><p class="desc" data-editable data-placeholder="[Two sentences that tell the reader what they are looking at.]">The stockholder written consent signed by the two seed funds. Its effectiveness clause ties it to the charter resolutions, not to the salaries.</p><div class="doc"><svg class="evidence-frame" viewBox="0 0 720 250" role="img" aria-label="Clip of the source page, not included in this sample."><rect x=".5" y=".5" width="719" height="249"></rect><path d="M82 64H638M82 96H560M82 128H610M82 160H520"></path><text x="360" y="214" text-anchor="middle" data-placeholder="[Clip of the source page with the cited passage highlighted.]">Clip of the source page, not included in this sample.</text></svg><div class="doc-text" data-editable data-placeholder="[Verbatim excerpt of the controlling passage, in the document's words.]">This consent shall take effect upon the adoption by the Board of Directors of resolutions approving the Restated Certificate.</div><p class="doc-cl" data-editable data-placeholder="[Document, section, and page.]">Consents folder, May 2025, p. 2</p></div><p class="out" data-editable data-placeholder="[Open the document in the data room.]">In a live review this line links to the document in the data room.</p></div>
<div class="pop" id="ev-p6-recital" hidden role="dialog" aria-modal="true" aria-labelledby="ev-p6-recital-title"><button class="ld-popup-close" type="button" aria-label="Close source popup">×</button><p class="tag" data-editable data-placeholder="[Area · document and page.]">Founder stock · purchase agreement, p. 9</p><h2 id="ev-p6-recital-title" data-editable data-placeholder="[What this source establishes, in one sentence.]">The recital that an election was being submitted</h2><p class="desc" data-editable data-placeholder="[Two sentences that tell the reader what they are looking at.]">Section 8 of Okafor's restricted stock purchase agreement. It says an executed election is being submitted with the agreement.</p><div class="doc"><svg class="evidence-frame" viewBox="0 0 720 250" role="img" aria-label="Clip of the source page, not included in this sample."><rect x=".5" y=".5" width="719" height="249"></rect><path d="M82 64H638M82 96H560M82 128H610M82 160H520"></path><text x="360" y="214" text-anchor="middle" data-placeholder="[Clip of the source page with the cited passage highlighted.]">Clip of the source page, not included in this sample.</text></svg><div class="doc-text" data-editable data-placeholder="[Verbatim excerpt of the controlling passage, in the document's words.]">Purchaser has decided to make an 83(b) Election and is submitting to the Company an executed form entitled "Election Under Section 83(b) of the Internal Revenue Code of 1986," attached hereto as Exhibit B.</div><p class="doc-cl" data-editable data-placeholder="[Document, section, and page.]">Purchase agreement, §8, p. 9</p></div><p class="out" data-editable data-placeholder="[Open the document in the data room.]">In a live review this line links to the document in the data room.</p></div>
<div class="pop" id="ev-p6-form" hidden role="dialog" aria-modal="true" aria-labelledby="ev-p6-form-title"><button class="ld-popup-close" type="button" aria-label="Close source popup">×</button><p class="tag" data-editable data-placeholder="[Area · document and page.]">Founder stock · Exhibit B, p. 16</p><h2 id="ev-p6-form-title" data-editable data-placeholder="[What this source establishes, in one sentence.]">The election form as it sits in the file</h2><p class="desc" data-editable data-placeholder="[Two sentences that tell the reader what they are looking at.]">The Exhibit B election form attached to the agreement. The taxpayer, date, and signature lines are empty.</p><div class="doc"><svg class="evidence-frame" viewBox="0 0 720 250" role="img" aria-label="Clip of the source page, not included in this sample."><rect x=".5" y=".5" width="719" height="249"></rect><path d="M82 64H638M82 96H560M82 128H610M82 160H520"></path><text x="360" y="214" text-anchor="middle" data-placeholder="[Clip of the source page with the cited passage highlighted.]">Clip of the source page, not included in this sample.</text></svg><div class="doc-text" data-editable data-placeholder="[Verbatim excerpt of the controlling passage, in the document's words.]">Election Under Section 83(b) of the Internal Revenue Code of 1986 · Taxpayer signature: ______ · Date: ______</div><p class="doc-cl" data-editable data-placeholder="[Document, section, and page.]">Purchase agreement, Exhibit B, p. 16</p></div><p class="out" data-editable data-placeholder="[Open the document in the data room.]">In a live review this line links to the document in the data room.</p></div></div>
<script id="diligence-report-navigation">
(function () {
function bootReport() {
var pages = Array.prototype.slice.call(document.querySelectorAll('.sb-slide[data-frame]'));
var body = document.body;
var rail = document.getElementById('rail');
var toggle = document.getElementById('rail-toggle');
var handle = document.getElementById('rail-handle');
function setRail(open) {
body.dataset.rail = open ? 'open' : 'closed';
toggle.setAttribute('aria-pressed', String(open));
handle.setAttribute('aria-expanded', String(open));
handle.setAttribute('aria-label', open ? 'Collapse contents' : 'Expand contents');
}
function currentFrame() {
var page = document.querySelector('.sb-slide.on');
return page ? Number(page.dataset.frame) : 1;
}
function go(frame, options) {
var target = pages.find(function (page) { return Number(page.dataset.frame) === frame; });
if (!target) return;
pages.forEach(function (page) { var on = page === target; page.hidden = !on; page.classList.toggle('on', on); });
rail.querySelectorAll('.rail-i').forEach(function (item) { item.setAttribute('aria-current', String(Number(item.dataset.frameButton) === frame)); });
var group = rail.querySelector('.rail-i[aria-current="true"]');
if (group && group.closest('details')) group.closest('details').open = true;
if (options && options.persist && window.LegalDesign && window.LegalDesign.state) {
var next = window.LegalDesign.state();
next.review = next.review || {};
next.review.location = target.id;
window.LegalDesign.setState(next);
}
if (!options || options.history !== false) history.replaceState(null, '', '?frame=' + frame);
scrollTo(0, 0);
}
document.addEventListener('click', function (event) {
var trigger = event.target.closest('[data-page],[data-frame-button]');
if (!trigger) return;
event.preventDefault();
go(Number(trigger.dataset.frameButton || frameForPage(trigger.dataset.page)), { persist: true });
});
function frameForPage(id) { return Number(String(id || 'p1').slice(1)); }
document.getElementById('nav-prev').addEventListener('click', function () { go(Math.max(1, currentFrame() - 1), { persist: true }); });
document.getElementById('nav-next').addEventListener('click', function () { go(Math.min(pages.length, currentFrame() + 1), { persist: true }); });
toggle.addEventListener('click', function () { setRail(body.dataset.rail !== 'open'); });
handle.addEventListener('click', function () { setRail(body.dataset.rail !== 'open'); });
addEventListener('keydown', function (event) {
if (event.target.closest('input,textarea,select,[contenteditable="true"]')) return;
if (event.key === 'ArrowLeft') go(Math.max(1, currentFrame() - 1), { persist: true });
if (event.key === 'ArrowRight') go(Math.min(pages.length, currentFrame() + 1), { persist: true });
});
var query = Number(new URLSearchParams(location.search).get('frame'));
var saved = window.LegalDesign && window.LegalDesign.state ? window.LegalDesign.state().review.location : null;
var initial = query >= 1 && query <= pages.length ? query : frameForPage(saved || 'p1');
setRail(!matchMedia('(max-width: 700px)').matches);
requestAnimationFrame(function () { body.dataset.railReady = 'true'; });
go(initial, { history: false, persist: false });
window.reportGoTo = function (frame) { go(frame, { persist: true }); };
window.buildExport = function () { return window.LegalDesign.exportHTML(false); };
window.buildTemplateExport = function () { return window.LegalDesign.exportTemplate(false); };
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootReport, { once: true });
else bootReport();
})();
</script>
</body>
</html>
SHA-256: 4a635928a2c38f897f5987ab227aec337838f889d00b5184bcf0117453ffc6d8