diff --git a/CITATION.cff b/CITATION.cff index 9a8990e..d9e0d87 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 title: Frontier Decision Engine message: Please cite the software and the specific dataset or case version used. type: software -version: 0.5.15 +version: 0.5.16 authors: - name: Bridge Node 7 license: Apache-2.0 diff --git a/README.md b/README.md index 09ff4bd..de527c1 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,13 @@ ## Start with one input -The first screen asks one question: **What are you considering?** +The public root asks one question: **What are you considering?** -Share the situation, decision, question, or context in your own words. **Natural-language intake currently supports English.** FDE then does one of three things: +Type or paste relevant context in ordinary language and choose **Continue**. For ordinary in-scope material, FDE returns a working **Decision Brief** immediately rather than beginning a compulsory clarification sequence. The brief separates the decision or focus, what matters, what remains unclear, and the next useful move. Sparse or multi-decision context can still produce a partial brief; unsupported information remains explicit instead of being silently invented. -- shows only decision structure that is supportable from the words provided; -- asks exactly one useful clarification question when the decision is still too sparse; or -- states an honest capability boundary and a useful next action when the request is outside the browser-local decision-support scope. +The original source text remains preserved as working context. Candidate decisions, choices, criteria, and conditions may be broader than the formal model. When the person voluntarily opens **Compare options**, they select the bounded set for one deterministic comparison: 2–4 objectives, 2–3 strategies, and 2–4 scenarios. This bounds the computation without silently discarding the source context. -When explicit language identifies a requirement, time gate, dependency, conditional, or blocker that may control the decision, FDE can surface it as a provisional **decision hinge** with source provenance. The person confirms, rejects, or marks the condition uncertain before it can affect bounded comparison inputs. When a consequential decision needs more structure first, **Guided framing** captures the decision, criteria, choices, and uncertainties before handing confirmed inputs into the same Decision Lab. Supportable first-run structure uses four human-facing concepts: **Decision, What matters, Choices, What may change.** Nothing inferred becomes a canonical model input until a person confirms it. +When explicit language identifies a requirement, time gate, dependency, conditional, or blocker that may control the decision, FDE can surface a provisional **decision hinge** with source provenance. It does not affect formal comparison inputs until the person explicitly keeps it or leaves it uncertain during comparison setup. The browser does not call a remote AI provider, retrieve outside facts, invent evidence, probabilities, scores, thresholds, scenario effects, or recommendations. @@ -32,7 +30,7 @@ Accepted context stays in JavaScript page memory only and is discarded on refres ## Decision Lab -FDE is the human-governed decision layer within Bridge Node 7's Frontier Mission Assurance architecture. Materials-to-Mission and other assurance profiles can contribute governed evidence and dependency context; Governed Context carries bounded preparation context into FDE; the accountable human records the consequential decision and reassessment. After confirmation, FDE asks only for the additional explicit inputs required for a deterministic comparison. The formal Decision Lab preserves the existing ranking semantics, published schemas, saved-work protections, and truthful alternate outcomes including ties, no acceptable option, and insufficient information. +FDE is the human-governed decision layer within Bridge Node 7's Frontier Mission Assurance architecture. Materials-to-Mission and other assurance profiles can contribute governed evidence and dependency context; Governed Context carries bounded preparation context into FDE; the accountable human records the consequential decision and reassessment. Formal comparison is optional. A single comparison-setup surface prepares the bounded explicit inputs required by the existing deterministic Decision Lab. The Decision Lab preserves the existing ranking semantics, published schemas, saved-work protections, and truthful alternate outcomes including ties, no acceptable option, and insufficient information. The comparison informs. A person decides. The result surface leads with what held up, why, what could change it, the next required proof, and the next action before exposing detailed calculations. A lightweight Decision Brief can be copied or downloaded before recording; when the working decision later changes, FDE shows a deterministic reassessment delta and preserves prior valid Receipt v2 records in a bounded **Local Decision Receipt Archive** before a new Receipt is recorded. The archive preserves independently verifiable local receipts; it is not a cryptographic continuity chain. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8d740e9..ec7667b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,13 +6,11 @@ Frontier Decision Engine is the human-governed decision layer of Bridge Node 7's The public root is a one-input decision experience for technical, organizational, mission, and strategic decisions. A person can start in ordinary language without learning decision-science vocabulary first. -After Continue, the runtime must resolve to exactly one of three states: +After **Continue**, ordinary in-scope text resolves to a working **Decision Brief**. The brief is a valid stopping point. It presents only supportable structure: the decision or focus, what matters, what remains unclear, and the next useful move. Sparse, ambiguous, or multi-decision input remains useful as a partial brief rather than forcing a question-by-question completion path. -1. supportable decision structure; -2. exactly one useful clarification question; or -3. an honest capability boundary plus a useful next action. +A pre-interpretation scope boundary still runs before brief generation. High-confidence personal safety language receives the established safety boundary; otherwise first-person personal-life decisions are redirected neutrally unless explicit organizational or decision-support context makes the work in scope. Information-only requests receive the established no-retrieval boundary. These are product-scope decisions, not clinical judgments. -The first-run structure uses stable semantic concepts: `decision`, `what_matters`, `options`, `what_may_change`, `status`, and `next_required_input`. A provisional decision hinge may be derived only from explicit requirement, time-gate, dependency, conditional, or blocker language. It preserves the exact source span, begins with no formal influence, and requires human confirmation before it can populate a bounded formal input. Human-facing labels are **Decision, What matters, Choices, What may change**. Only fields actually supported by explicit input are rendered. +The first-run layer preserves the original source text and candidate decisions, choices, criteria, conditions, and provisional decision hinge. Candidate capture can be broader than the formal comparison. The Decision Brief is working context, not a Decision Receipt and not evidence by itself. A Decision Map remains an internal architecture/export concept where it improves technical precision. It is not the pre-input first-run surface. @@ -20,15 +18,17 @@ A Decision Map remains an internal architecture/export concept where it improves FDE follows this interaction rule: -> Continue without bothering the person when the system can proceed safely; otherwise ask for the smallest human contribution that materially advances the decision. +> Produce useful structure before asking the person to operate the formal model. -No ordinary in-scope input is treated as an error-only dead end. Sparse or ambiguous input receives one clarification question. A hinge-derived question may preempt the ordinary schema order only when the answer can deterministically change a requirement, criterion, evidence need, choice, constraint, or modeled future; otherwise the deterministic choices → goals → futures fallback remains in force. A pre-interpretation scope boundary keeps FDE inside its technical, organizational, mission, and strategic product domain. High-confidence personal safety language receives the established safety boundary; otherwise first-person personal-life decisions are redirected neutrally unless explicit organizational or decision-support context makes the work in scope. The neutral redirect is a product-scope decision, not a clinical judgment. +**Refine** returns to the same freeform source context. It does not open another structured questionnaire. Formal depth is voluntary through **Compare options**. + +The optional comparison setup shows the complete bounded formal setup on one surface. The person selects one decision when needed, 2–3 strategies, 2–4 objectives, and 2–4 scenarios. Additional source candidates remain preserved outside the active formal set. A provisional decision hinge may enter a formal criterion or modeled future only after explicit human handling. ## Formal Decision Lab -Once enough explicit structure exists and the person confirms it, the information is handed into the existing deterministic Decision Lab. Guided comparison supports 2–4 objectives, 2–3 strategies, and 2–4 scenarios. The minimum comparison is a true 2 × 2 × 2 model. +After the person deliberately opens a valid bounded comparison, FDE creates the existing guided Decision Case and hands it into the existing deterministic Decision Lab. Guided comparison remains 2–4 objectives, 2–3 strategies, and 2–4 scenarios. The minimum comparison remains a true 2 × 2 × 2 model. -The comparison core is isolated from the first-run layer so UX changes do not silently alter ranking semantics. Missing analytical values are not fabricated. +The comparison core is isolated from the first-run layer so UX changes do not silently alter ranking semantics. Missing analytical values are not fabricated. Saved formal work is not silently overwritten by a new first-run brief. ## Epistemic boundaries diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index d440fbe..402db1c 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -2,13 +2,13 @@ Frontier Decision Engine operationalizes the human decision layer of Frontier Mission Assurance: choices, criteria, evidence state, dependencies, uncertainty, thresholds, conditions, and reassessment remain inspectable. It uses a transparent scenario comparison rather than a hidden prediction model. -1. State the decision or situation in ordinary language. -2. When explicit language supports it, identify and confirm the condition most capable of changing the decision. -3. Define what needs to go well and what counts as good enough. -4. Name realistic choices. -5. Name plausible futures that could change the answer. -6. Enter explicit performance assumptions and future-specific changes. -7. Compare threshold passes, critical failures, and vulnerabilities. +1. State the decision, situation, question, or context in ordinary language. +2. Review the supportable Decision Brief: decision or focus, what matters, what remains unclear, and the next useful move. +3. Refine the same source context only when more framing would materially help. +4. When formal comparison is warranted, select one bounded comparison set: realistic choices, criteria, and plausible futures or conditions. +5. When explicit language supports it, explicitly keep, leave uncertain, or remove a provisional decision hinge before it can affect the formal model. +6. Enter explicit performance assumptions, thresholds, evidence state, and future-specific changes in Decision Lab. +7. Compare threshold passes, critical failures, vulnerabilities, ties, no-acceptable-choice states, and insufficient-information states. 8. Review the result-first decision value: what held up, why, what could change it, and any required next proof. 9. Re-evaluate scope policy on the operative decision/action fields immediately before accountable recording. 10. Record the human-owned decision, rationale, and next action. diff --git a/docs/PILOT_READINESS.md b/docs/PILOT_READINESS.md index 9baf4f5..6f52c70 100644 --- a/docs/PILOT_READINESS.md +++ b/docs/PILOT_READINESS.md @@ -11,7 +11,7 @@ FDE pilots test whether accountable decision-makers find the decision architectu 5. Proof request: required unresolved evidence can stop additional scoring and surface the human-declared next proof. 6. Privacy: public FDE is for public or sanitized information only; browser storage is not encrypted confidential storage. 7. Decision surface: the final view leads with the supportable prepared bottom line and controlling issue before deeper detail, while making clear that the accountable human still decides. -8. Human burden: a participant can complete the workflow without BN7 narrating the product, and the pilot records where avoidable clarification or reconstruction burden remains. +8. Human burden: the first Continue action produces a useful Decision Brief for ordinary in-scope text, the brief is a valid stopping point, and formal comparison is entered only when the participant chooses deeper analysis. 9. Reconstruction: an independent reviewer can understand the receipt from the artifact alone. 10. Provenance: application version, schema version, and receipt digests are preserved. 11. Reuse intent: the pilot asks whether the participant would voluntarily use FDE on another real decision. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index fe09e7d..fae7780 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -2,7 +2,7 @@ Frontier Decision Engine is browser-local by default. The public application has no backend, account system, analytics, telemetry, cookies, remote AI provider, or default upload endpoint. -Guided framing uses tab-scoped session storage to recover in-progress framing after an accidental refresh. Decision Lab uses bounded browser autosave for structured work and, after a re-record, a bounded **Local Decision Receipt Archive** for prior valid Decision Receipt v2 records. Clearing the current draft does not erase that archive. The archive is local convenience storage, not a cryptographic continuity chain, and browser/profile deletion can remove it. Browser-local storage is not encrypted confidential storage. +The public first-run Decision Brief uses bounded tab-scoped session storage to recover in-progress source context after an accidental refresh. Guided framing remains available as a separate route. Decision Lab uses bounded browser autosave for structured work and, after a re-record, a bounded **Local Decision Receipt Archive** for prior valid Decision Receipt v2 records. Clearing the current draft does not erase that archive. The archive is local convenience storage, not a cryptographic continuity chain, and browser/profile deletion can remove it. Browser-local storage is not encrypted confidential storage. Local decision files are processed in the browser. Do not enter information that requires an approved confidential, controlled, or otherwise restricted environment. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 5227eb2..f47bda3 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,16 +1,20 @@ -# v0.5.15 +# v0.5.16 -Application version is 0.5.15. +Application version is 0.5.16. Compatible decision schema: 0.2.10. Semantic decision schema: 0.3.0. Mission Graph Decision Context Packet: 0.3.0. Decision Receipt format: 2. Pilot Proof Envelope: 1. -This corrective UX release makes the supportable prepared bottom line the first substantive result on the final decision surface and in the downloadable Decision Brief. +This release refines the public first-run experience while preserving the deterministic Decision Lab and accountable decision boundaries. -- Required unresolved evidence produces a HOLD prepared bottom line even when another declared posture would otherwise permit advancement. -- STOP, HOLD, REWORK, ADVANCE WITH CONDITIONS, and ADVANCE retain their existing semantic authority and are translated into plain-language prepared output. -- An inactive assurance posture remains comparison-only rather than being promoted into an automated recommendation. -- The visible prepared bottom line shows the controlling issue, the next required proof when one exists, and an explicit reminder that the accountable human still decides. -- Browser UAT now requires the prepared bottom-line surface and its human-authority boundary. -- The Decision Case schemas, Mission Graph context contract, Decision Receipt v2 format, ranking semantics, reassessment behavior, authority roles, and portable interfaces are unchanged. Accountable human judgment remains authoritative. +- The public root keeps one freeform input: **What are you considering?** +- **Continue** now produces a working Decision Brief for ordinary in-scope text rather than beginning a compulsory clarification sequence. +- Sparse and multi-decision context can produce a partial brief while unresolved structure remains explicit. +- Original source context and detected candidate decisions, choices, criteria, and conditions remain preserved before formal comparison. +- Formal comparison is voluntary and uses one bounded setup surface before entering the existing Decision Lab. +- The formal comparison contract remains 2–4 objectives, 2–3 strategies, and 2–4 scenarios. +- Provisional decision hinges remain source-derived and require explicit human handling before they can affect formal comparison inputs. +- Existing saved Decision Lab work is not silently overwritten by a new first-run brief. +- Product-scope, safety, evidence-readiness, authority, Decision Receipt v2, governed-context, reassessment, and ranking semantics remain unchanged. +- The Bridge Node 7 shell is quieter and more consistent with the institutional website while preserving product navigation. -Browser-local storage is not encrypted confidential storage. Public deployment is for public or sanitized decision material only. +Browser-local storage is not encrypted confidential storage. Public deployment is for public or sanitized decision material only. Human judgment remains authoritative. The comparison informs; a person decides. diff --git a/package-lock.json b/package-lock.json index 90b5fa7..b178ce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontier-decision-engine", - "version": "0.5.15", + "version": "0.5.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontier-decision-engine", - "version": "0.5.15", + "version": "0.5.16", "license": "Apache-2.0", "engines": { "node": ">=22" diff --git a/package.json b/package.json index 5f351d1..4fc973a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "frontier-decision-engine", - "version": "0.5.15", + "version": "0.5.16", "private": true, "type": "module", "description": "Browser-local, human-governed decision infrastructure for Frontier Mission Assurance.", diff --git a/project-facts.json b/project-facts.json index 80d4966..6cc6735 100644 --- a/project-facts.json +++ b/project-facts.json @@ -1,5 +1,5 @@ { - "applicationVersion": "0.5.15", + "applicationVersion": "0.5.16", "schemaVersions": { "decision": "0.2.10", "semanticDecision": "0.3.0" diff --git a/scripts/browser_e2e_release.py b/scripts/browser_e2e_release.py index c38c359..2932c35 100644 --- a/scripts/browser_e2e_release.py +++ b/scripts/browser_e2e_release.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Release wrapper for the retained browser E2E suite with the v0.3.3 root-route contract.""" +"""Release wrapper for the retained browser E2E suite and current root-route contract.""" from __future__ import annotations import browser_e2e as suite @@ -32,13 +32,13 @@ def no_js_one_page(browser) -> None: "How it works", "Decision Lab", "Appearance", + "Partner", "What are you considering?", - "Share the situation, decision, question, or context in your own words.", - "Advanced paths", - "Private by design. Your working decision stays in this browser unless you choose to export it.", + "JavaScript is required for the browser-local decision workspace.", "Human authority", - "FDE structures and calculates; it does not approve or authorize the decision.", - "confidential or controlled information", + "FDE structures and calculates; it does not approve or authorize a decision.", + "public or sanitized material only", + "Privacy", ]: assert text in body for removed in ["Bring the whole mess", "Decision Map", "Find the decision"]: diff --git a/scripts/browser_guided_framing_e2e.py b/scripts/browser_guided_framing_e2e.py index 5488fcb..551b18a 100644 --- a/scripts/browser_guided_framing_e2e.py +++ b/scripts/browser_guided_framing_e2e.py @@ -1,274 +1,248 @@ #!/usr/bin/env python3 -"""Focused browser verification for the frictionless FDE first-run surface.""" +"""First-run browser verification for the Decision-Brief-first FDE experience.""" from __future__ import annotations -import functools -import http.server import os -import socketserver -import threading -from pathlib import Path +from urllib.parse import urlparse -from playwright.sync_api import sync_playwright +try: + from playwright.sync_api import sync_playwright +except ImportError as exc: # pragma: no cover + raise SystemExit( + "Browser E2E requires Playwright. Run: node scripts/run-python.mjs -m pip install " + "-r requirements-dev.txt and node scripts/run-python.mjs -m playwright install chromium" + ) from exc -ROOT = Path(__file__).resolve().parents[1] -SITE = ROOT / "site" -SESSION_KEY = "fde.universal.session.v1" -DECISION_KEY = "fde.decision.autosave.v0.2.11" -GUIDED_SESSION_KEY = "fde.guided-framing.session.v1" +from browser_e2e import browser_executable, install_static_route, native_http_available, start_static_server +SESSION_KEY = "fde.universal.session.v2" +OLD_SESSION_KEY = "fde.universal.session.v1" +DECISION_STORAGE_KEY = "fde.decision.v1" -class QuietHandler(http.server.SimpleHTTPRequestHandler): - def log_message(self, *_args): - pass +def launch_browser(playwright): + executable = browser_executable() + kwargs = {"headless": True} + if executable: + kwargs["executable_path"] = executable + return playwright.chromium.launch(**kwargs) -def browser_executable() -> str | None: - return os.environ.get("CHROME_BIN") or None + +def reset(page, base: str) -> None: + page.goto(base, wait_until="networkidle") + page.evaluate("localStorage.clear(); sessionStorage.clear()") + page.goto(base, wait_until="networkidle") + + +def uses_static_route(base: str) -> bool: + parsed = urlparse(base) + return parsed.scheme == "http" and parsed.hostname == "fde.test" and parsed.port is None + + +def assert_first_view(page) -> None: + assert page.locator("#universal-title").inner_text() == "What are you considering?" + field = page.locator("#universal-input") + assert field.get_attribute("placeholder") == "Type or paste anything relevant…" + assert page.get_by_role("button", name="Continue").count() == 1 + assert page.get_by_text("Advanced paths", exact=True).count() == 0 + assert page.get_by_text("Share the situation, decision, question, or context in your own words.", exact=True).count() == 0 + + +def assert_brief(page) -> None: + assert page.locator("#universal-response-title").inner_text() == "Decision brief" + assert page.locator('[data-fde-field="decision"]').count() == 1 + assert page.locator('[data-fde-field="what_matters"]').count() == 1 + assert page.locator('[data-fde-field="uncertainty"]').count() == 1 + assert page.locator('[data-fde-field="next_useful_move"]').count() == 1 + assert page.locator(".universal-question").count() == 0 def run() -> None: - handler = functools.partial(QuietHandler, directory=str(SITE)) - with socketserver.TCPServer(("127.0.0.1", 0), handler) as server: - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - base = f"http://127.0.0.1:{server.server_address[1]}/" - try: - with sync_playwright() as p: - launch = {"headless": True} - executable = browser_executable() - if executable: - launch["executable_path"] = executable - browser = p.chromium.launch(**launch) - try: - context = browser.new_context(viewport={"width": 390, "height": 844}) - remote_requests: list[str] = [] - context.on("request", lambda request: remote_requests.append(request.url) if not request.url.startswith(base) else None) - page = context.new_page() - page.goto(base, wait_until="networkidle") - - assert page.title() == "Frontier Decision Engine" - assert page.locator("h1").inner_text() == "What are you considering?" - assert page.get_by_text("Share the situation, decision, question, or context in your own words.", exact=True).is_visible() - assert page.get_by_role("button", name="Continue").is_visible() - assert page.get_by_text("Advanced paths", exact=True).is_visible() - page.get_by_text("Advanced paths", exact=True).click() - assert page.get_by_role("link", name="Already know the decision and choices? Open Decision Lab →").is_visible() - assert page.get_by_role("link", name="Need more help framing the decision? Use guided framing →").is_visible() - assert page.get_by_text("Private by design. Your working decision stays in this browser unless you choose to export it.", exact=True).is_visible() - assert page.locator("#universal-input").get_attribute("placeholder") == "Decision, choices, criteria, uncertainties, notes, or context…" - assert page.locator(".universal-surface").count() == 0 - assert "Decision Map" not in page.locator("main").inner_text() - assert "Bring the whole mess" not in page.locator("body").inner_text() - assert page.locator("#theme-toggle").inner_text() == "Appearance" - assert "Current:" in (page.locator("#theme-toggle").get_attribute("aria-label") or "") - assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth + 1") - - # Long input stays visible and produces an explicit bounded-processing message. - long_input = "x" * 12001 - page.locator("#universal-input").fill(long_input) - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-limit").is_visible() - assert len(page.locator("#universal-input").input_value()) == len(long_input) - - # A grounded controlling condition is shown before schema completion. - page.locator("#universal-input").fill("Should we qualify Supplier A or Supplier B? November is our integration window.") - page.get_by_role("button", name="Continue").click() - assert page.locator("[data-fde-field='decision_hinge']").is_visible() - assert page.get_by_text("November is our integration window", exact=True).is_visible() - page.get_by_role("button", name="Yes").click() - assert page.locator("#universal-response-title").inner_text() == "Does this condition need to be true for the decision?" - page.get_by_role("button", name="Yes").click() - assert "What else matters" in page.locator("#universal-response-title").inner_text() - page.get_by_role("button", name="Adjust original input").click() - - # Explicit prohibitive hard requirements remain eligible hinge candidates. - page.locator("#universal-input").fill("Should we proceed with this design? The system must not exceed 10 watts.") - page.get_by_role("button", name="Continue").click() - assert page.locator("[data-fde-field='decision_hinge']").is_visible() - assert page.get_by_text("must not exceed 10 watts", exact=True).is_visible() - page.locator("#universal-adjust").click() - - # Sparse input yields one question, not an invalid state or empty structural cards. - page.locator("#universal-input").fill("qualification evidence incomplete") - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-response-title").inner_text() == "Which decision or question should we focus on?" - assert page.locator("[data-fde-field='next_required_input']").count() == 1 - assert page.locator("[data-fde-field='decision']").count() == 0 - assert "Invalid input" not in page.locator("body").inner_text() - - page.get_by_role("button", name="Adjust original input").click() - clear_input = "Should we qualify an alternate source or redesign around the dependency? Schedule risk and resilience matter, but qualification may be late. " - page.locator("#universal-input").fill(clear_input) - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-response-title").inner_text() == "Decision structure" - assert page.get_by_text("Needs confirmation", exact=True).is_visible() - assert page.locator("[data-fde-field='decision']").is_visible() - assert page.locator("[data-fde-field='what_matters']").is_visible() - assert page.locator("[data-fde-field='options']").is_visible() - assert page.locator("[data-fde-field='what_may_change']").is_visible() - assert page.get_by_text("qualify an alternate source", exact=True).is_visible() - assert page.get_by_text("redesign around the dependency", exact=True).is_visible() - assert page.get_by_text("Schedule risk", exact=True).is_visible() - assert page.get_by_text("Resilience", exact=True).is_visible() - assert page.get_by_text("Timing gets worse", exact=True).is_visible() - assert page.locator("script").filter(has_text="alert(1)").count() == 0 - - # Confirmation requests only the next required input. - page.get_by_role("button", name="Yes").click() - assert page.locator("#universal-response-title").inner_text() == "What else could change the choice?" - page.locator("#universal-input").fill("Requirements change") - - # Saved-work protection: never silently replace a Decision Lab draft. - page.evaluate(f"localStorage.setItem('{DECISION_KEY}', JSON.stringify({{sentinel:'keep-me'}}))") - page.get_by_role("button", name="Continue").click() - assert page.evaluate(f"localStorage.getItem('{DECISION_KEY}')") == '{"sentinel":"keep-me"}' - assert page.locator("#universal-response-title").inner_text() == "A saved FDE decision already exists." - assert page.get_by_role("link", name="Open Decision Lab →").is_visible() - - # A completely new decision entered during a follow-up must not inherit - # criteria or options from the abandoned decision. - page.evaluate(f"localStorage.removeItem('{DECISION_KEY}')") - page.evaluate(f"sessionStorage.removeItem('{SESSION_KEY}')") - page.goto(base, wait_until="networkidle") - diligence_input = "Should we qualify a second gallium nitride wafer supplier in Japan or keep our current Chinese supplier? We care about cost, schedule risk, and DFARS compliance." - page.locator("#universal-input").fill(diligence_input) - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-response-title").inner_text() == "Decision structure" - assert page.get_by_text("Cost", exact=True).is_visible() - assert page.get_by_text("Schedule risk", exact=True).is_visible() - assert page.get_by_text("Compliance", exact=True).is_visible() - page.get_by_role("button", name="Yes").click() - assert page.locator("#universal-response-title").inner_text() == "What conditions or uncertainties could change the choice?" - page.locator("#universal-input").fill("Should we retire the legacy test stand?") - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-response-title").inner_text() == "Decision structure" - assert page.get_by_text("Should we retire the legacy test stand", exact=True).is_visible() - current_text = page.locator("main").inner_text() - assert "Cost" not in current_text - assert "Schedule risk" not in current_text - assert "Compliance" not in current_text - assert "Japanese supplier" not in current_text - assert "Chinese supplier" not in current_text - - # Information-request UAT is a separate first-run scenario. Clear only the - # tab-scoped intake session; the persistence behavior itself is tested below. - page.evaluate(f"localStorage.removeItem('{DECISION_KEY}')") - page.evaluate(f"sessionStorage.removeItem('{SESSION_KEY}')") - page.goto(base, wait_until="networkidle") - page.locator("#universal-input").fill("What is the current spot price of gallium?") - page.get_by_role("button", name="Continue").click() - assert "does not retrieve outside facts" in page.locator("#universal-response-title").inner_text().lower() - assert "Gather the fact first" in page.locator("main").inner_text() - - # Self-directed crisis language fails closed before ordinary decision structuring. - page.evaluate(f"localStorage.removeItem('{DECISION_KEY}')") - page.evaluate(f"sessionStorage.removeItem('{SESSION_KEY}')") - page.goto(base, wait_until="networkidle") - page.locator("#universal-input").fill("I want to die.") - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-response-title").inner_text() == "This decision is outside FDE’s comparison scope." - assert "does not compare or optimize self-harm" in page.locator("main").inner_text() - assert "988" in page.locator("main").inner_text() - - # Organizational prevention decisions remain valid decision inputs. - page.get_by_role("button", name="Adjust").click() - page.locator("#universal-input").fill("Should the ministry fund suicide prevention hotlines or school counselors?") - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-response-title").inner_text() == "Decision structure" - - # Oversized choice sets get an explicit bounded-selection prompt. - page.get_by_role("button", name="Adjust").click() - page.locator("#universal-input").fill("Choose between Supplier Alpha, Supplier Bravo, an alternate material, a reserve, or subsystem redesign.") - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-response-title").inner_text() == "I found 5 possible choices. Choose up to 3 to compare." - - # Mission-focused criteria remain choices/criteria, not an ambiguous option list. - page.evaluate(f"sessionStorage.removeItem('{SESSION_KEY}')") - page.goto(base, wait_until="networkidle") - page.locator("#universal-input").fill("Should we impose export controls or negotiate supply agreements with allies? National security, cost, and time matter.") - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-response-title").inner_text() == "Decision structure" - assert page.get_by_text("National security", exact=True).is_visible() - assert page.get_by_text("Cost", exact=True).is_visible() - assert page.get_by_text("Time", exact=True).is_visible() - assert page.get_by_text("impose export controls", exact=True).is_visible() - assert page.get_by_text("negotiate supply agreements with allies", exact=True).is_visible() - - # Explicit criteria outside the built-in vocabulary stay visible for human confirmation. - page.get_by_role("button", name="Adjust").click() - page.locator("#universal-input").fill("Should we qualify Supplier Alpha or Supplier Bravo? Data residency, capex, measurement traceability, and corrosion resistance matter.") - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-response-title").inner_text() == "Decision structure" - for criterion in ("Data residency", "Capex", "Measurement traceability", "Corrosion resistance"): - assert page.get_by_text(criterion, exact=True).is_visible() - - # More criteria than the bounded draft can hold must never be silently truncated. - page.get_by_role("button", name="Adjust").click() - page.locator("#universal-input").fill("Should we qualify Supplier Alpha or Supplier Bravo? Data residency, capex, measurement traceability, corrosion resistance, and repairability matter.") - page.get_by_role("button", name="Continue").click() - assert page.locator("#universal-response-title").inner_text() == "I found 5 possible criteria. Choose up to 4 to keep." - - # Informational comparison language remains informational rather than inventing a choice set. - page.get_by_role("button", name="Adjust original input").click() - page.locator("#universal-input").fill("Explain qualification versus redesign for a new engineer.") - page.get_by_role("button", name="Continue").click() - assert "does not retrieve outside facts" in page.locator("#universal-response-title").inner_text().lower() - - # Ctrl/Cmd + Enter activates Continue. - page.get_by_role("button", name="Adjust").click() - page.locator("#universal-input").fill("Should we qualify the alternate source or hold for evidence? Mission safety and cost exposure matter. Schedule slips and demand changes are possible.") - page.locator("#universal-input").press("Control+Enter") - assert page.locator("#universal-response-title").inner_text() == "Decision structure" - - # Refresh preserves in-progress first-run work. - page.get_by_role("button", name="Adjust").click() - page.locator("#universal-input").fill("Refresh should not erase this qualification context.") - assert page.evaluate(f"Boolean(sessionStorage.getItem('{SESSION_KEY}'))") - page.reload(wait_until="networkidle") - assert page.locator("#universal-input").input_value() == "Refresh should not erase this qualification context." - - # Guided framing enforces the same input boundary. - page.evaluate(f"sessionStorage.removeItem('{GUIDED_SESSION_KEY}')") - page.goto(f"{base}#/framing", wait_until="networkidle") - page.locator("#guided-question").wait_for(state="visible") - page.locator("#guided-intake").fill("Should I cut myself or call someone?") - page.locator("#guided-start").click() - assert "This decision is outside FDE’s comparison scope." in page.locator("#guided-status").inner_text() - assert "988" in page.locator("#guided-status").inner_text() - assert page.locator("#guided-question").inner_text() == "What decision context are you facing?" - - # Decision Lab enforces the same boundary before stage advancement. - page.goto(f"{base}#/decision/new", wait_until="networkidle") - page.locator("#decision-question").fill("I am suicidal and deciding what to do.") - page.locator('[data-decision-stage="0"] [data-stage-next]').click() - validation = page.locator('[data-stage-validation="0"]') - assert "This decision is outside FDE’s comparison scope." in validation.inner_text() - assert "988" in validation.inner_text() - assert page.locator("#decision-question").get_attribute("aria-invalid") == "true" - - assert not remote_requests, f"FDE made unexpected remote requests: {remote_requests}" - context.close() - - # Appearance follows system, then remains operable in dark mode. - theme = browser.new_context(viewport={"width": 1280, "height": 900}, color_scheme="light") - theme_page = theme.new_page() - theme_page.goto(base, wait_until="networkidle") - assert theme_page.locator("html").get_attribute("data-theme") == "light" - assert theme_page.locator("html").get_attribute("data-theme-preference") == "system" - assert theme_page.locator("#theme-toggle").inner_text() == "Appearance" - theme_page.emulate_media(color_scheme="dark") - theme_page.locator('html[data-theme="dark"]').wait_for(state="attached") - assert theme_page.locator("html").get_attribute("data-theme-preference") == "system" - assert theme_page.evaluate("document.documentElement.scrollWidth <= window.innerWidth + 1") - theme.close() - finally: - browser.close() - finally: - server.shutdown() - thread.join(timeout=5) + server, native_base = start_static_server() + try: + with sync_playwright() as playwright: + browser = launch_browser(playwright) + try: + base = native_base + if not native_http_available(browser, base, attempts=2): + base = "http://fde.test/" + + context = browser.new_context(viewport={"width": 1280, "height": 900}, color_scheme="dark") + if uses_static_route(base): + install_static_route(context) + + remote_requests: list[str] = [] + page = context.new_page() + allowed_host = urlparse(base).netloc + + def observe_request(request) -> None: + parsed = urlparse(request.url) + if parsed.scheme in {"http", "https"} and parsed.netloc != allowed_host: + remote_requests.append(request.url) + + page.on("request", observe_request) + + reset(page, base) + assert_first_view(page) + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth + 1") + + # Clear, supportable input returns value immediately instead of starting a questionnaire. + clear_input = ( + "Should we qualify the alternate source or retain the incumbent? " + "Schedule risk, resilience, and cost matter. Delay and shortage are possible." + ) + page.locator("#universal-input").fill(clear_input) + page.get_by_role("button", name="Continue").click() + assert_brief(page) + assert "qualify the alternate source" in page.locator('[data-fde-field="decision"]').inner_text().lower() + assert page.get_by_role("button", name="Compare options").count() == 1 + assert page.get_by_role("button", name="Refine").count() == 1 + assert page.get_by_role("button", name="Start another").count() == 1 + + # Refine returns to the same freeform source context with no new form. + page.get_by_role("button", name="Refine").click() + assert_first_view(page) + assert page.locator("#universal-input").input_value() == clear_input + page.locator("#universal-input").fill(clear_input + " Technical performance also matters.") + page.get_by_role("button", name="Continue").click() + assert_brief(page) + assert "Technical performance" in page.locator('[data-fde-field="what_matters"]').inner_text() + + # Sparse input still yields a partial brief. No compulsory clarification screen appears. + page.get_by_role("button", name="Start another").click() + page.locator("#universal-input").fill("Qualification evidence is incomplete and the mission dependency is unclear.") + page.get_by_role("button", name="Continue").click() + assert_brief(page) + assert "not explicit" in page.locator('[data-fde-field="decision"]').inner_text().lower() + assert "decision itself" in page.locator('[data-fde-field="uncertainty"]').inner_text().lower() + + # Multiple decisions are preserved rather than forcing a choose-one page. + page.get_by_role("button", name="Start another").click() + page.locator("#universal-input").fill( + "Should we qualify a second source? Should we redesign around the dependency? " + "Should we build strategic inventory?" + ) + page.get_by_role("button", name="Continue").click() + assert_brief(page) + decision_text = page.locator('[data-fde-field="decision"]').inner_text() + assert "qualify a second source" in decision_text.lower() + assert "redesign around the dependency" in decision_text.lower() + assert "build strategic inventory" in decision_text.lower() + assert "3 decision questions preserved" in page.locator(".universal-preserved").inner_text().lower() + + # Large candidate sets remain preserved before bounded formal comparison. + page.get_by_role("button", name="Start another").click() + page.locator("#universal-input").fill( + "Choose between Supplier Alpha, Supplier Bravo, an alternate material, a reserve, or subsystem redesign. " + "Cost, schedule, resilience, qualification, and compliance matter. Delay and shortage are possible." + ) + page.get_by_role("button", name="Continue").click() + assert_brief(page) + preserved = page.locator(".universal-preserved").inner_text().lower() + assert "5 possible choices preserved" in preserved + assert "5 criteria preserved" in preserved + + # Formal depth is voluntary and visible on one surface. + page.get_by_role("button", name="Compare options").click() + assert page.locator("#universal-response-title").inner_text() == "Compare options" + assert page.locator('legend').filter(has_text="Options").count() >= 1 + assert page.locator('legend').filter(has_text="What matters").count() >= 1 + assert page.locator('legend').filter(has_text="What may change").count() >= 1 + assert page.get_by_role("button", name="Open Decision Lab").count() == 1 + # Reduce the preselected choices/criteria to formal limits and provide a second scenario if needed. + choice_boxes = page.locator('input[name="formal-choice"]') + for index in range(choice_boxes.count()): + choice_boxes.nth(index).set_checked(index < 3) + goal_boxes = page.locator('input[name="formal-goal"]') + for index in range(goal_boxes.count()): + goal_boxes.nth(index).set_checked(index < 4) + + # Added formal items are validated rather than silently truncated. + page.locator("#universal-add-choices").fill("Emergency reserve") + page.get_by_role("button", name="Open Decision Lab").click() + assert page.locator("#universal-response-title").inner_text() == "Compare options" + assert "no more than 3 options" in page.locator("#universal-compare-validation").inner_text().lower() + page.locator("#universal-add-choices").fill("") + + page.locator("#universal-add-futures").fill("Demand changes") + page.get_by_role("button", name="Open Decision Lab").click() + page.wait_for_url("**#/decision") + page.locator("#decision-work").wait_for(state="visible") + # Decision Map remains a technical Decision Lab concept, not the first-run surface. + assert page.locator("#decision-question").input_value().strip() + assert page.locator(".guided-starting-context").count() == 1 + + # Saved-work protection: a new brief may exist without overwriting formal work. + # Saved-work protection is enforced when a second brief tries to enter Decision Lab. + page.goto(base, wait_until="networkidle") + page.locator("#universal-input").fill( + "Should we qualify another supplier or retain the incumbent? Cost and reliability matter. Delay and shortage are possible." + ) + page.get_by_role("button", name="Continue").click() + assert_brief(page) + page.get_by_role("button", name="Compare options").click() + page.locator("#universal-add-futures").fill("Requirements change") + page.get_by_role("button", name="Open Decision Lab").click() + assert "saved FDE decision already exists" in page.locator("#universal-response-title").inner_text() + assert page.get_by_role("link", name="Open Decision Lab").count() == 1 + + # Information requests retain the existing no-fabrication boundary. + page.evaluate("localStorage.clear(); sessionStorage.clear()") + page.goto(base, wait_until="networkidle") + page.locator("#universal-input").fill("What is the current spot price of gallium?") + page.get_by_role("button", name="Continue").click() + assert "does not retrieve outside facts" in page.locator("#universal-response-title").inner_text().lower() + + # Scope and safety boundaries remain fail-closed before first-run structuring. + page.get_by_role("button", name="Adjust").click() + page.locator("#universal-input").fill("Should I cut myself or call someone?") + page.get_by_role("button", name="Continue").click() + assert "outside FDE’s comparison scope" in page.locator("#universal-response-title").inner_text() + assert "988" in page.locator(".universal-boundary").inner_text() + + # Over-length input is rejected without silent truncation. + page.get_by_role("button", name="Adjust").click() + page.locator("#universal-input").fill("x" * 12001) + page.get_by_role("button", name="Continue").click() + assert page.locator("#universal-input").count() == 1 + assert "longer than FDE can safely structure" in page.locator("#universal-limit").inner_text() + + # sessionStorage recovery retains in-progress source text; old v1 transient state is ignored. + page.locator("#universal-input").fill("Refresh should preserve this source context.") + assert page.evaluate(f"Boolean(sessionStorage.getItem('{SESSION_KEY}'))") + page.evaluate(f"sessionStorage.setItem('{OLD_SESSION_KEY}', JSON.stringify({{'version': 1, 'view': 'question'}}))") + page.reload(wait_until="networkidle") + assert page.locator("#universal-input").input_value() == "Refresh should preserve this source context." + + # Mobile/reflow: accepted first view stays operable without page-level horizontal scrolling. + mobile = browser.new_context(viewport={"width": 375, "height": 812}, color_scheme="dark") + if uses_static_route(base): + install_static_route(mobile) + mobile_page = mobile.new_page() + mobile_page.goto(base, wait_until="networkidle") + assert_first_view(mobile_page) + assert mobile_page.evaluate("document.documentElement.scrollWidth <= window.innerWidth + 1") + assert mobile_page.get_by_role("button", name="Continue").bounding_box()["height"] >= 44 + mobile.close() + + # Appearance remains operable and honors the browser color_scheme. + light = browser.new_context(viewport={"width": 1280, "height": 900}, color_scheme="light") + if uses_static_route(base): + install_static_route(light) + light_page = light.new_page() + light_page.goto(base, wait_until="networkidle") + assert light_page.locator("html").get_attribute("data-theme") == "light" + assert light_page.locator("#theme-toggle").inner_text() == "Appearance" + light.close() + + assert not remote_requests, f"FDE made unexpected remote requests: {remote_requests}" + context.close() + finally: + browser.close() + finally: + server.shutdown() if __name__ == "__main__": run() - print("FIRST-RUN UX E2E PASS") + print("FIRST-RUN UX E2E PASS") \ No newline at end of file diff --git a/scripts/validate_repository.py b/scripts/validate_repository.py index 615f348..a0efd6f 100644 --- a/scripts/validate_repository.py +++ b/scripts/validate_repository.py @@ -140,8 +140,10 @@ def load_json(relative: str): for stylesheet in ["./assets/styles.css", "./assets/bridge-node-7-shell.css", "./assets/beginner-first.css", "./assets/guided-framing.css", "./assets/universal-decision.css"]: if stylesheet not in index: errors.append(f"site index does not load required stylesheet: {stylesheet}") -if "Bridge Node 7 Home" not in index: - errors.append("explicit Bridge Node 7 Home path is missing") +if 'href="https://bridgenode7.com/"' not in index: + errors.append("explicit Bridge Node 7 home path is missing") +if 'href="https://bridgenode7.com/partner/"' not in index: + errors.append("Bridge Node 7 Partner path is missing") for path in (SITE / "src").rglob("*.js"): try: @@ -171,25 +173,34 @@ def load_json(relative: str): required_front_door = [ "What are you considering?", - "Share the situation, decision, question, or context in your own words.", - "Decision, choices, criteria, uncertainties, notes, or context…", + "Type or paste anything relevant…", ">Continue<", - "Advanced paths", - "Already know the decision and choices? Open Decision Lab →", + "Decision brief", + "What's still unclear", + "Next useful move", + "Compare options", + "Open Decision Lab", + "Preserved context", "deriveDecisionHinge", "This decision may turn on", - "Need more help framing the decision? Use guided framing →", - "Private by design. Your working decision stays in this browser unless you choose to export it.", - "supportableSection('Decision', 'decision'", + "Browser-local. Public or sanitized material only.", "supportableSection('What matters', 'what_matters'", - "supportableSection('Choices', 'options'", - "supportableSection('What may change', 'what_may_change'", - "Which decision or question should we focus on?", - "Needs confirmation", + "supportableSection(\"What's still unclear\", 'uncertainty'", + "supportableSection('Next useful move', 'next_useful_move'", + "fde.universal.session.v2", ] for token in required_front_door: if token not in universal_text: errors.append(f"first-run UX missing required contract: {token}") +for obsolete in [ + "Advanced paths", + "Which decision or question should we focus on?", + "Choose up to 3 to compare.", + "Choose up to 4 to keep.", + "Needs confirmation", +]: + if obsolete in universal_text: + errors.append(f"first-run UX still contains obsolete compulsory-flow copy: {obsolete}") for prohibited in ["Bring the whole mess", "Find the decision", "What FDE sees so far", "Invalid input"]: if prohibited in universal_text or prohibited in index: errors.append(f"first-run UX still contains prohibited pre-input copy: {prohibited}") @@ -241,7 +252,7 @@ def load_json(relative: str): sys.exit(1) print("REPOSITORY VALIDATION PASS") -print("- first-run UX provides structure, one clarification, or an explicit capability boundary") +print("- first-run UX returns an immediate Decision Brief or an explicit capability boundary") print("- Guided framing and deterministic Decision Lab preserve human authority and browser-local boundaries") print("- public decision schemas, release identity, accessibility controls, and publication checks are aligned") print("- static site has no external runtime dependencies") diff --git a/site/assets/universal-decision.css b/site/assets/universal-decision.css index 09dfc7b..d7161b5 100644 --- a/site/assets/universal-decision.css +++ b/site/assets/universal-decision.css @@ -1,46 +1,55 @@ :root{--surface-glow:rgba(117,214,255,.08);--surface-border:rgba(117,214,255,.18)} .universal-hero{width:100%;max-width:920px;margin:clamp(2.75rem,7vw,6.5rem) auto 5rem;padding:0 .5rem} .universal-hero .eyebrow{display:block;text-align:center} -.universal-hero h1{margin:.65rem auto .8rem;font-size:clamp(2.7rem,5vw,5rem);line-height:1.02;letter-spacing:-.05em;max-width:900px;text-align:center;text-wrap:balance} -.universal-subtitle{max-width:720px;margin:0 auto 2rem;color:var(--muted);font-size:clamp(1rem,1.4vw,1.18rem);line-height:1.6;text-align:center} +.universal-hero h1{margin:.65rem auto 1.25rem;font-size:clamp(2.7rem,5vw,4.7rem);line-height:1.01;letter-spacing:-.052em;max-width:none;text-align:center;text-wrap:balance} +.universal-front-door h1{white-space:nowrap} .universal-entry{max-width:780px;margin:0 auto} -#universal-input{width:100%;min-height:220px;resize:vertical;border:1px solid var(--line-strong);border-radius:20px;background:var(--surface);font:inherit;font-size:1rem;line-height:1.6;padding:1rem 1.05rem;box-shadow:0 18px 55px rgba(0,0,0,.12)} +#universal-input{width:100%;min-height:240px;resize:vertical;border:1px solid var(--line-strong);border-radius:20px;background:var(--surface);font:inherit;font-size:1rem;line-height:1.6;padding:1rem 1.05rem;box-shadow:0 18px 55px rgba(0,0,0,.12)} #universal-input:focus-visible{outline:3px solid var(--focus,Highlight);outline-offset:3px} -.universal-entry .help,.universal-question .help{margin:.6rem 0 1rem;color:var(--muted)} -.universal-actions{display:flex;gap:.65rem;flex-wrap:wrap;align-items:center} +.universal-actions{display:flex;gap:.65rem;flex-wrap:wrap;align-items:center;margin-top:1rem} .universal-actions .primary{min-height:48px;min-width:132px} -.universal-lab-link,.universal-return,.universal-trust{margin:.9rem 0 0;line-height:1.5} -.universal-lab-link a,.universal-return a{font-weight:800;color:var(--text)} -.universal-trust{color:var(--muted);font-size:.88rem} +.universal-actions .quiet,.universal-actions .button.quiet{background:transparent;color:var(--muted)} +.universal-return,.universal-trust{margin:.85rem 0 0;line-height:1.5} +.universal-return a{font-weight:800;color:var(--text)} +.universal-trust{color:var(--muted);font-size:.82rem} .universal-post-input{max-width:920px;margin-top:clamp(2.25rem,5vw,4.5rem)} -.universal-post-input>h1{font-size:clamp(2.2rem,4vw,3.6rem);margin-bottom:1.8rem} -.universal-structure,.universal-question,.universal-boundary{border:1px solid var(--line-strong);background:var(--surface);border-radius:22px;padding:clamp(1rem,2.5vw,1.6rem);box-shadow:0 20px 65px rgba(0,0,0,.12)} +.universal-structure,.universal-boundary{border:1px solid var(--line-strong);background:var(--surface);border-radius:24px;padding:clamp(1.05rem,3vw,1.8rem);box-shadow:0 20px 65px rgba(0,0,0,.12)} .universal-structure-head{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;padding-bottom:1rem;border-bottom:1px solid var(--line)} -.universal-structure-head h2,.universal-question h2,.universal-boundary h2{margin:0;font-size:clamp(1.5rem,2.8vw,2rem);line-height:1.15} -.universal-badge{display:inline-flex;align-items:center;min-height:30px;padding:.32rem .55rem;border:1px solid var(--line-strong);border-radius:999px;color:var(--muted);font-size:.72rem;font-weight:850;letter-spacing:.05em;text-transform:uppercase;white-space:nowrap} +.universal-structure-head h1,.universal-boundary h1{margin:0;text-align:left;font-size:clamp(1.7rem,3vw,2.35rem);line-height:1.08;letter-spacing:-.035em;white-space:normal} +.universal-structure-head p{margin:.55rem 0 0;color:var(--muted);max-width:68ch} .universal-field{padding:1.05rem 0;border-bottom:1px solid var(--line)} -.universal-field h3,.universal-confirmation h3{margin:0 0 .5rem;font-size:.88rem;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)} +.universal-field h3{margin:0 0 .5rem;font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)} .universal-field p{margin:0;font-size:1.08rem;line-height:1.55} .universal-field ul{margin:.2rem 0 0;padding-left:1.2rem;display:grid;gap:.35rem;line-height:1.5} -.universal-confirmation{padding-top:1.2rem} -.universal-confirmation h3{font-size:1.05rem;letter-spacing:0;text-transform:none;color:var(--text)} -.universal-confirm-row{display:flex;gap:.55rem;flex-wrap:wrap} -.universal-confirm-row button{min-height:44px} -.universal-question textarea{margin-top:1.2rem} -.universal-boundary p{color:var(--muted);line-height:1.6} -@media(max-width:620px){.universal-hero{margin-top:2rem;margin-bottom:3rem;padding:0}.universal-hero h1{font-size:clamp(2.35rem,11vw,3.5rem);line-height:1.04}.universal-subtitle{font-size:1rem;margin-bottom:1.4rem}.universal-actions,.universal-confirm-row{align-items:stretch;flex-direction:column}.universal-actions button,.universal-actions .button,.universal-confirm-row button{width:100%}.universal-structure,.universal-question,.universal-boundary{border-radius:18px}.universal-structure-head{align-items:flex-start;flex-direction:column}.universal-badge{align-self:flex-start}#universal-input{min-height:190px}} -@media(max-width:360px){.universal-hero h1{font-size:2.2rem}.universal-structure,.universal-question,.universal-boundary{padding:.9rem}} -@media(forced-colors:active){#universal-input,.universal-structure,.universal-question,.universal-boundary,.universal-badge{box-shadow:none;border:1px solid CanvasText}#universal-input:focus-visible{outline:3px solid Highlight}} -@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} - -.universal-advanced{margin:.9rem 0 0;color:var(--muted)} -.universal-advanced summary{cursor:pointer;font-weight:800;color:var(--text);width:max-content} -.universal-advanced p{margin:.55rem 0 0} -.universal-advanced a{font-weight:800;color:var(--text)} -.universal-limit{margin:.65rem 0 1rem;padding:.75rem .85rem;border:1px solid var(--line-strong);border-radius:12px;color:var(--text);background:var(--surface)} +.universal-empty{color:var(--muted)} +.universal-brief-actions{margin-top:1.25rem} +.universal-limit{margin:.65rem 0 0;padding:.75rem .85rem;border:1px solid var(--line-strong);border-radius:12px;color:var(--text);background:var(--surface-2)} .universal-hinge{margin:.2rem 0 .1rem;padding:1rem 0 1.1rem;border-bottom:1px solid var(--line)} -.universal-hinge h3{margin:.45rem 0 .5rem;font-size:.88rem;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)} -.universal-hinge p,.universal-hinge-quote{margin:0;font-size:1.08rem;line-height:1.55} -.universal-hinge-meta{display:flex;gap:.45rem;flex-wrap:wrap;color:var(--muted);font-size:.76rem;font-weight:800} +.universal-hinge h3{margin:.45rem 0 .5rem;font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)} +.universal-hinge p,.universal-hinge-quote{margin:0;font-size:1rem;line-height:1.55} +.universal-hinge-meta{display:flex;gap:.45rem;flex-wrap:wrap;color:var(--muted);font-size:.74rem;font-weight:800} .universal-hinge-meta span{display:inline-flex;padding:.22rem .45rem;border:1px solid var(--line);border-radius:999px} -.universal-hinge-question .universal-hinge-quote{margin:1rem 0 1.15rem;padding:1rem;border-left:3px solid var(--line-strong)} +.universal-preserved{margin:1rem 0 0;padding:.9rem 1rem;border:1px solid var(--line);border-radius:16px;background:rgba(255,255,255,.025)} +.universal-preserved strong{display:block;margin-bottom:.25rem} +.universal-preserved p{margin:0;color:var(--muted);font-size:.9rem;line-height:1.5} +.universal-trace{margin-top:1rem;border-top:1px solid var(--line);padding-top:1rem} +.universal-trace summary{cursor:pointer;font-weight:800;color:var(--muted);width:max-content;max-width:100%} +.universal-trace p{margin:.75rem 0;color:var(--muted);font-size:.9rem} +.universal-trace pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:.7rem 0 0;padding:.9rem;border:1px solid var(--line);border-radius:14px;background:var(--surface-2);color:var(--muted);font:inherit;font-size:.88rem;line-height:1.55;max-height:18rem;overflow:auto} +.universal-boundary p{color:var(--muted);line-height:1.6;margin:1rem 0 0} +.universal-compare-grid{display:grid;gap:.85rem;margin-top:1rem} +.universal-compare-field{min-width:0;border:1px solid var(--line);border-radius:18px;padding:1rem;background:rgba(255,255,255,.025)} +label.universal-compare-field{display:grid;gap:.55rem;font-weight:800} +.universal-compare-field legend{padding:0 .2rem;font-weight:850;color:var(--text)} +.universal-compare-field legend small{margin-left:.35rem;color:var(--muted);font-weight:600} +.universal-check-list,.universal-radio-list{display:grid;gap:.55rem;margin:.35rem 0 .75rem} +.universal-check-list label,.universal-radio-list label{display:flex;align-items:flex-start;gap:.55rem;min-height:36px;color:var(--text);font-weight:650} +.universal-check-list input,.universal-radio-list input{width:auto;min-width:18px;margin-top:.18rem;accent-color:var(--accent)} +.universal-add{display:grid;gap:.4rem;color:var(--muted);font-size:.86rem;font-weight:750;margin-top:.75rem} +.universal-add textarea{min-height:82px;resize:vertical} +.universal-compare .universal-hinge-quote{margin:.45rem 0 .7rem;padding:.75rem .85rem;border-left:3px solid var(--line-strong);background:var(--surface-2)} +@media(min-width:780px){.universal-compare-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.universal-compare-field:first-child{grid-column:1/-1}} +@media(max-width:620px){.universal-hero{margin-top:2rem;margin-bottom:3rem;padding:0}.universal-front-door h1{font-size:clamp(2.35rem,11vw,3.5rem);line-height:1.04;white-space:normal}.universal-actions{align-items:stretch;flex-direction:column}.universal-actions button,.universal-actions .button{width:100%}.universal-structure,.universal-boundary{border-radius:18px}.universal-structure-head{align-items:flex-start;flex-direction:column}#universal-input{min-height:210px}.universal-check-list label,.universal-radio-list label{min-height:44px;align-items:center}.universal-check-list input,.universal-radio-list input{margin-top:0}} +@media(max-width:360px){.universal-front-door h1{font-size:2.2rem}.universal-structure,.universal-boundary{padding:.9rem}} +@media(forced-colors:active){#universal-input,.universal-structure,.universal-boundary,.universal-compare-field,.universal-preserved{box-shadow:none;border:1px solid CanvasText}.universal-check-list input,.universal-radio-list input{forced-color-adjust:auto}#universal-input:focus-visible{outline:3px solid Highlight}} +@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} \ No newline at end of file diff --git a/site/index.html b/site/index.html index 6f6b747..0edc22b 100644 --- a/site/index.html +++ b/site/index.html @@ -40,19 +40,20 @@ Decision Lab Governed context + Partner
- + \ No newline at end of file diff --git a/site/manifest.sha256 b/site/manifest.sha256 index 79b4ada..a3d5421 100644 --- a/site/manifest.sha256 +++ b/site/manifest.sha256 @@ -3,8 +3,8 @@ ce128e04445e29dea244477a790359fc3780fea1892e80c19d1fac489a5a298c assets/beginne ec3aec4472e9c355d37f0ed61bead6beb538490acbeeb211dcc6f26c131f9715 assets/bridge-node-7-shell.css 188cc544ad3366f510e2f1c5f7460690b1fadac0372a3e3c3aebf6888e3a546c assets/guided-framing.css b0ad22294c9acc9f75ecd176c7aa1b0f6ccdd43385503756cfab2bed631ad977 assets/styles.css -35353a0ecd07426983a28dccd3d79944cd73124ef5cd4a87e8becaa9d334fec4 assets/universal-decision.css -88503ae7ac7e0fdb33acfb276eb413f8d6e7cefdda86cfb984dfcd3d065305f4 index.html +b032189409cdc41f95d91ddec2587eb602cf1d8e7674668fb7d2199fc0dccf92 assets/universal-decision.css +83383c6441782d7dff8d3aba2fa3bd9fbdb98255014203ac8cc4d29f065aaaf3 index.html e8d0b10949d707b26ff91e0e5f8724a87d5503756a5e740112e7e4e64f521a42 schemas/decision-0.3.0.schema.json d80fae451587b952b8d2ae96fd8c9dfaddbe59b19015dc00d2ef23577e255f1c schemas/decision-receipt-v2.schema.json 05a89ebda18651378d654b3228d0ef17bf75ba748ae7d4d9675b3d89781e9daf schemas/decision.schema.json @@ -38,6 +38,6 @@ b9223f8cfc4fdd5a1946e37521752c2eea294ec281ac7586d9850aea8c093437 src/lib/semant 51534a039a1ec13c63ff8c299793a5b3fbb0ab747d0788776a84887b85b29480 src/lib/synthesis.js 31f58170ff2fc6eb7576b53d36816bdd3ac632341ec464fc4ae1d00d8fd2d72c src/shell.js be26c2df8340667ef8bed104a6947784210dd53acb6a24afb06172bdd3510634 src/theme.js -dbff91fd6079f1b29178ee1d1ad0c0a57c3cfb185b15484ab128071e77fae50a src/universal-ui.js -25eedcfdb218257a51ea410ed6d78152fa39423847c1182abca6976f54e2c78c src/version.js +78f338a8554da3b3962f025dbe515d25e03f4459d115a2eb33ff972c92d09807 src/universal-ui.js +decfef25e11a07fa9af8c567ff96d0aa6ee6746cd0a244be53d86e613d5bfc54 src/version.js ddb19f7de43dc2b5194d75b334c61df7bcfe1cba6ffa8ce99222e41ee73371fb start.html diff --git a/site/src/universal-ui.js b/site/src/universal-ui.js index 24ba00f..6fec865 100644 --- a/site/src/universal-ui.js +++ b/site/src/universal-ui.js @@ -4,11 +4,15 @@ import { DECISION_STORAGE_KEY, getBrowserStorage, saveDecision } from './lib/per import { boundaryForInput } from './lib/input-boundaries.js'; import { deriveDecisionHinge } from './lib/decision-hinge.js'; -const MAX_SUGGESTIONS = Object.freeze({ choices: 3, goals: 4, futures: 4 }); -const SESSION_KEY = 'fde.universal.session.v1'; +const FORMAL_LIMITS = Object.freeze({ + choices: DRAFT_TOPOLOGY_BOUNDS.strategies, + goals: DRAFT_TOPOLOGY_BOUNDS.objectives, + futures: DRAFT_TOPOLOGY_BOUNDS.scenarios, +}); +const SESSION_KEY = 'fde.universal.session.v2'; const CONTEXT_KEY = 'fde.universal.context.v1'; const HANDOFF_KEY = 'fde.universal.handoff'; -const SESSION_VERSION = 1; +const SESSION_VERSION = 2; const KEYWORDS = { goals: [ @@ -36,6 +40,8 @@ const KEYWORDS = { const decisionPattern = /should i|should we|do i|do we|whether|which should|choose|decid(?:e|ing)\s+(?:between|whether)/i; const informationPattern = /^(how much|what is|what's|when is|where is|who is|can you explain|explain|compare|what does|how does|tell me about)\b/i; +const uncertainPattern = /\b(?:incomplete|unknown|uncertain|not sure|don't know|do not know|contested|conflicting|unverified|stale)\b/i; + function normalize(value) { return String(value ?? '').replace(/\r\n?/g, '\n').trim(); } @@ -59,7 +65,7 @@ function titleFrom(text) { return sentence.length > 120 ? `${sentence.slice(0, 117)}…` : sentence; } -function unique(values, max) { +function unique(values, max = Number.POSITIVE_INFINITY) { return [...new Set(values.map((value) => normalize(value)).filter(Boolean))].slice(0, max); } @@ -88,6 +94,11 @@ function getIntent(text) { return decisionPattern.test(clean) ? 'decision' : 'open'; } +function extractDecisionCandidates(text) { + const sentences = normalize(text).split(/(?<=[.!?])\s+|\n+/).map((value) => value.trim()).filter(Boolean); + return unique(sentences.filter((value) => decisionPattern.test(value)).map((value) => value.replace(/[.!?]+$/, '').trim())); +} + const criteriaClausePattern = /\b(?:care about|what matters|criteria|goals?|priorities|requirements?|matters?|important|must\s+(?:balance|protect|preserve|meet)|need(?:s)?\s+to\s+(?:balance|protect|preserve|meet))\b/i; function extractListedChoices(text) { @@ -100,28 +111,24 @@ function extractListedChoices(text) { .split(/\s*,\s*(?:\b(?:or|and)\b\s*)?|\s+\b(?:or|and)\b\s+/i) .map((value) => cleanChoice(value).replace(/\bwhich\s+should\s+(?:i|we)\s+choose\b.*$/i, '').trim()) .filter((value) => value.length >= 2); - if (parts.length >= 3) return [...new Set(parts)]; + if (parts.length >= 3) return unique(parts); } return []; } -function hasUnresolvedOptionList(text) { - return extractListedChoices(text).length > MAX_SUGGESTIONS.choices; -} - function extractChoices(text) { const choices = []; const clauses = normalize(text).split(/[.!?\n]+/).map((value) => value.trim()).filter(Boolean); for (const clause of clauses) { const decisionLike = decisionPattern.test(clause) || /^(?:should\b|choose|pick|select|decide)\b/i.test(clause); if (!decisionLike) continue; - for (const match of clause.matchAll(/(?:either\s+)?([^\n,.!?]{2,80})\s+(?:or|versus|vs\.?|instead of)\s+([^\n,.!?]{2,80})/gi)) { + for (const match of clause.matchAll(/(?:either\s+)?([^\n,.!?]{2,100})\s+(?:or|versus|vs\.?|instead of)\s+([^\n,.!?]{2,100})/gi)) { choices.push(cleanChoice(match[1]), cleanChoice(match[2])); } } - const numbered = [...text.matchAll(/(?:^|\n)\s*(?:\d+[.)]?|[-*•])\s+([^\n]{2,100})/g)] + const numbered = [...normalize(text).matchAll(/(?:^|\n)\s*(?:\d+[.)]?|[-*•])\s+([^\n]{2,120})/g)] .map((match) => cleanChoice(match[1])); - return unique([...choices, ...numbered], MAX_SUGGESTIONS.choices); + return unique([...choices, ...numbered]); } function phraseMatches(text, needle) { @@ -133,7 +140,7 @@ function phraseMatches(text, needle) { }); } -function extractKeywordLabels(text, entries, max) { +function extractKeywordLabels(text, entries, max = Number.POSITIVE_INFINITY) { const matches = []; for (const [needle, label] of entries) { for (const span of phraseMatches(text, needle)) matches.push({ ...span, needle, label }); @@ -151,7 +158,7 @@ function extractKeywordLabels(text, entries, max) { } function extractGoals(text) { - return extractKeywordLabels(text, KEYWORDS.goals, MAX_SUGGESTIONS.goals); + return extractKeywordLabels(text, KEYWORDS.goals); } function criterionLabel(value) { @@ -173,7 +180,7 @@ function extractExplicitCriteria(text) { for (const clause of clauses) { if (!prefixCue.test(clause) && !suffixCue.test(clause)) continue; - let body = clause + const body = clause .replace(/^.*?\bcare about\b\s*/i, '') .replace(/^.*?\bwhat matters(?: most)?(?: is| are)?\b\s*/i, '') .replace(/^.*?\b(?:criteria|goals?|priorities|requirements?)\s+(?:are|include)\b\s*/i, '') @@ -187,47 +194,45 @@ function extractExplicitCriteria(text) { .filter((value) => value.length >= 2); criteria.push(...items); } - return [...new Set(criteria)]; + return unique(criteria); } function extractFutures(text) { - return extractKeywordLabels(text, KEYWORDS.futures, MAX_SUGGESTIONS.futures); + return extractKeywordLabels(text, KEYWORDS.futures); } -function splitUserItems(text, max) { +function splitUserItems(text) { const clean = normalize(text); if (!clean) return []; - return unique( - clean - .split(/\n|;|,|\s+\bor\b\s+/i) - .map((item) => item.replace(/^\s*(?:\d+[.)]?|[-*•])\s*/, '').trim()), - max, - ); + return unique(clean + .split(/\n|;|,/) + .map((item) => item.replace(/^\s*(?:\d+[.)]?|[-*•])\s*/, '').trim()) + .filter(Boolean)); } export function draftFromInput(text) { const clean = normalize(text); const intent = getIntent(clean); const listedChoices = extractListedChoices(clean); - const detectedChoiceCount = listedChoices.length; - const optionListAmbiguous = detectedChoiceCount > MAX_SUGGESTIONS.choices; - const choices = optionListAmbiguous ? [] : unique([...extractChoices(clean), ...listedChoices], MAX_SUGGESTIONS.choices); + const choices = unique([...extractChoices(clean), ...listedChoices]); const explicitCriteria = extractExplicitCriteria(clean); - const detectedCriterionCount = explicitCriteria.length; - const criteriaListAmbiguous = detectedCriterionCount > MAX_SUGGESTIONS.goals; - const goals = criteriaListAmbiguous - ? [] - : unique([...extractGoals(clean), ...explicitCriteria], MAX_SUGGESTIONS.goals); + const goals = unique([...extractGoals(clean), ...explicitCriteria]); const futures = extractFutures(clean); - const possibleDecision = intent !== 'multi' && (decisionPattern.test(clean) || (choices.length >= 2 && clean.includes('?'))) ? titleFrom(clean) : ''; + const decisionCandidates = extractDecisionCandidates(clean); + const possibleDecision = intent !== 'multi' && decisionCandidates.length + ? decisionCandidates[0] + : intent !== 'multi' && choices.length >= 2 && clean.includes('?') + ? titleFrom(clean) + : ''; return { startingPoint: clean, intent, possibleDecision, - optionListAmbiguous, - detectedChoiceCount, - criteriaListAmbiguous, - detectedCriterionCount, + decisionCandidates, + optionListAmbiguous: choices.length > FORMAL_LIMITS.choices.max, + detectedChoiceCount: choices.length, + criteriaListAmbiguous: goals.length > FORMAL_LIMITS.goals.max, + detectedCriterionCount: goals.length, hingeCandidate: deriveDecisionHinge(clean), choices, goals, @@ -238,31 +243,16 @@ export function draftFromInput(text) { export function responseFor(state) { const clean = normalize(state?.startingPoint); const boundary = boundaryForInput(clean); - if (boundary) { - return { kind: 'boundary', title: boundary.title, body: boundary.body }; - } - if (state?.optionListAmbiguous) { - const count = Number(state.detectedChoiceCount) || 4; - return { kind: 'question', question: `I found ${count} possible choices. Choose up to ${MAX_SUGGESTIONS.choices} to compare.` }; - } - if (state?.criteriaListAmbiguous) { - const count = Number(state.detectedCriterionCount) || (MAX_SUGGESTIONS.goals + 1); - return { kind: 'question', question: `I found ${count} possible criteria. Choose up to ${MAX_SUGGESTIONS.goals} to keep.` }; - } - if (!clean || (!state?.possibleDecision && state?.intent !== 'information' && state?.intent !== 'multi')) { - return { kind: 'question', question: 'Which decision or question should we focus on?' }; - } - if (state.intent === 'information') { + if (boundary) return { kind: 'boundary', title: boundary.title, body: boundary.body }; + if (!clean) return { kind: 'empty' }; + if (state?.intent === 'information') { return { kind: 'boundary', title: 'FDE compares decisions; it does not retrieve outside facts.', body: 'Gather the fact first, or state the decision that fact will inform.', }; } - if (state.intent === 'multi') { - return { kind: 'question', question: 'Which decision or question should we focus on first?' }; - } - return { kind: 'structure' }; + return { kind: 'brief' }; } function storage() { @@ -293,151 +283,159 @@ function clearSession() { try { storage()?.removeItem(SESSION_KEY); } catch { /* no-op */ } } -function hingeTarget(state) { - const hinge = state.hingeCandidate; - if (!hinge || state.hingeStatus) return ''; - if (['hard_requirement', 'deadline'].includes(hinge.basis_type)) { - if (state.goals.includes(hinge.text)) return ''; - return state.goals.length < MAX_SUGGESTIONS.goals ? 'goals' : ''; +function supportableSection(label, semantic, value, fallback = '') { + if (Array.isArray(value)) { + const values = value.map(normalize).filter(Boolean); + if (!values.length && !fallback) return ''; + const body = values.length + ? `` + : `

${escapeHtml(fallback)}

`; + return `

${escapeHtml(label)}

${body}
`; } - if (state.futures.includes(hinge.text)) return ''; - return state.futures.length < MAX_SUGGESTIONS.futures ? 'futures' : ''; + const text = normalize(value); + if (!text && !fallback) return ''; + return `

${escapeHtml(label)}

${escapeHtml(text || fallback)}

`; } -function hingeQuestion(state) { - if (['hard_requirement', 'deadline'].includes(state.hingeCandidate?.basis_type)) { - return 'Does this condition need to be true for the decision?'; +function briefDecisionMarkup(state) { + if (state.decisionCandidates.length > 1) { + return supportableSection('Decisions identified', 'decision', state.decisionCandidates); } - return 'Could this condition change which choice holds up?'; -} - -function missingRequirement(state) { - if (state.choices.length < DRAFT_TOPOLOGY_BOUNDS.strategies.min) return 'choices'; - if (state.goals.length < DRAFT_TOPOLOGY_BOUNDS.objectives.min) return 'goals'; - if (state.futures.length < DRAFT_TOPOLOGY_BOUNDS.scenarios.min) return 'futures'; - return ''; + return supportableSection('Decision', 'decision', state.possibleDecision, 'The decision is not explicit yet.'); } -function nextQuestion(state, kind) { - if (kind === 'choices') return state.choices.length === 1 ? 'What is one other choice to compare?' : 'What choices should we compare?'; - if (kind === 'goals') return state.goals.length === 1 ? 'What else matters when comparing these choices?' : 'What matters most when comparing these choices?'; - if (kind === 'futures') return state.futures.length === 1 ? 'What else could change the choice?' : 'What conditions or uncertainties could change the choice?'; - return 'Which decision or question should we focus on?'; +function briefUnknowns(state) { + const unknowns = []; + if (!state.possibleDecision && state.decisionCandidates.length === 0) unknowns.push('The decision itself is not explicit yet.'); + if (state.choices.length < FORMAL_LIMITS.choices.min) unknowns.push('The alternatives are not fully established.'); + if (state.goals.length < FORMAL_LIMITS.goals.min) unknowns.push('What matters most is not fully established.'); + if (uncertainPattern.test(state.startingPoint)) unknowns.push('Some supporting evidence remains uncertain.'); + return unique(unknowns); } -function supportableSection(label, semantic, value) { - if (Array.isArray(value)) { - if (!value.length) return ''; - return `

${escapeHtml(label)}

`; +function nextUsefulMove(state) { + if (!state.possibleDecision && state.decisionCandidates.length === 0) { + return 'Refine only if you want FDE to isolate a formal decision.'; + } + if (state.decisionCandidates.length > 1) { + return 'Refine or open Compare options when you want to focus one formal decision.'; + } + if (state.choices.length < FORMAL_LIMITS.choices.min) { + return 'Refine only if you want to name alternatives for formal comparison.'; + } + if (state.goals.length < FORMAL_LIMITS.goals.min) { + return 'Refine only if you want to make the comparison criteria explicit.'; } - if (!normalize(value)) return ''; - return `

${escapeHtml(label)}

${escapeHtml(value)}

`; + return 'Open Compare options when you want a formal deterministic comparison.'; } function hingeMarkup(state) { const hinge = state.hingeCandidate; if (!hinge) return ''; - const status = state.hingeStatus === 'yes' - ? 'You confirmed' - : state.hingeStatus === 'no' - ? 'You did not keep this as a controlling condition' - : state.hingeStatus === 'not_sure' - ? 'You marked this as uncertain' - : 'FDE suggests'; return `
-
${escapeHtml(status)}From your words
+
Potential hingeFrom your words

This decision may turn on

${escapeHtml(hinge.text)}

`; } +function preservedMarkup(state) { + const messages = []; + if (state.decisionCandidates.length > 1) messages.push(`${state.decisionCandidates.length} decision questions preserved.`); + if (state.choices.length > FORMAL_LIMITS.choices.max) messages.push(`${state.choices.length} possible choices preserved.`); + if (state.goals.length > FORMAL_LIMITS.goals.max) messages.push(`${state.goals.length} criteria preserved.`); + if (!messages.length) return ''; + return ``; +} + function entryMarkup(state, hasSavedDecision) { return `
Frontier Decision Engine

What are you considering?

-

Share the situation, decision, question, or context in your own words.

- -

Use your own words. Press Ctrl or Command + Enter to continue. Natural-language intake currently supports English.

+
-
Advanced paths

Already know the decision and choices? Open Decision Lab →

Need more help framing the decision? Use guided framing →

${hasSavedDecision ? '

Continue saved work →

' : ''} -

Private by design. Your working decision stays in this browser unless you choose to export it.

+

Browser-local. Public or sanitized material only.

`; } -function structureMarkup(state) { - return `
+function briefMarkup(state, hasSavedDecision) { + const canCompare = Boolean(state.possibleDecision) || state.decisionCandidates.length > 0; + return `
Frontier Decision Engine -

What are you considering?

-
-

Decision structure

Needs confirmation
+
+

Decision brief

+ ${briefDecisionMarkup(state)} + ${supportableSection('What matters', 'what_matters', state.goals, 'Not established yet.')} + ${supportableSection("What's still unclear", 'uncertainty', briefUnknowns(state), 'Nothing obvious from the supplied context.')} + ${supportableSection('Next useful move', 'next_useful_move', nextUsefulMove(state))} ${hingeMarkup(state)} - ${supportableSection('Decision', 'decision', state.possibleDecision)} - ${supportableSection('What matters', 'what_matters', state.goals)} - ${supportableSection('Choices', 'options', state.choices)} - ${supportableSection('What may change', 'what_may_change', state.futures)} -
-

Is this the decision you want to evaluate?

-
+ ${preservedMarkup(state)} +
+ + ${canCompare ? '' : ''} + ${hasSavedDecision ? 'Continue saved work' : ''} +
-

Private by design. Your working decision stays in this browser unless you choose to export it.

+
See how FDE got here

FDE organized only what appeared in the supplied context. The original text remains the source context and is not treated as evidence by itself.

${escapeHtml(state.startingPoint)}
+

Browser-local. Public or sanitized material only. The brief is working context, not a Decision Receipt.

`; } -function questionMarkup(state, question) { - return `
- Frontier Decision Engine -

What are you considering?

-
-

${escapeHtml(question)}

- - -

One useful answer is enough to continue.

- -
-

Private by design. Your working decision stays in this browser unless you choose to export it.

-

-
-
`; +function checkboxList(name, values, selected, max) { + if (!values.length) return '

None detected yet. Add items below if you want to compare.

'; + return `
${values.map((value, index) => { + const checked = selected.includes(value) ? ' checked' : ''; + return ``; + }).join('')}
`; +} + +function decisionChoiceMarkup(state) { + const decisions = state.decisionCandidates.length ? state.decisionCandidates : state.possibleDecision ? [state.possibleDecision] : []; + if (decisions.length <= 1) { + return ``; + } + return `
Decision
${decisions.map((decision, index) => ``).join('')}
`; } -function hingeQuestionMarkup(state) { +function compareMarkup(state) { + const selectedChoices = state.selectedChoices.length ? state.selectedChoices : state.choices.slice(0, FORMAL_LIMITS.choices.max); + const selectedGoals = state.selectedGoals.length ? state.selectedGoals : state.goals.slice(0, FORMAL_LIMITS.goals.max); + const selectedFutures = state.selectedFutures.length ? state.selectedFutures : state.futures.slice(0, FORMAL_LIMITS.futures.max); const hinge = state.hingeCandidate; - return `
+ return `
Frontier Decision Engine -

What are you considering?

-
-
FDE suggestsFrom your words
-

${escapeHtml(hingeQuestion(state))}

-

${escapeHtml(hinge?.text || '')}

-
- - - +
+

Compare options

Optional. Set the bounded formal comparison here, then open Decision Lab.

+
+ ${decisionChoiceMarkup(state)} +
Options Select 2–${FORMAL_LIMITS.choices.max}${checkboxList('formal-choice', state.choices, selectedChoices, FORMAL_LIMITS.choices.max)}
+
What matters Select 2–${FORMAL_LIMITS.goals.max}${checkboxList('formal-goal', state.goals, selectedGoals, FORMAL_LIMITS.goals.max)}
+
What may change Select 2–${FORMAL_LIMITS.futures.max}${checkboxList('formal-future', state.futures, selectedFutures, FORMAL_LIMITS.futures.max)}
+ ${hinge ? `
Potential hinge

${escapeHtml(hinge.text)}

` : ''}
-
-

FDE is organizing your input. You remain the decision-maker.

-

-
+ +
+

Formal comparison remains deterministic and human-governed. The comparison informs; a person decides.

+
`; } -function boundaryMarkup(state, title, body, saved = false) { - return `
+function boundaryMarkup(title, body, saved = false) { + return `
Frontier Decision Engine -

What are you considering?

-
-

${escapeHtml(title)}

+
+

${escapeHtml(title)}

${escapeHtml(body)}

-
${saved ? 'Open Decision Lab →' : ''}
-

Private by design. Your working decision stays in this browser unless you choose to export it.

+
${saved ? 'Open Decision Lab' : ''}
+

Browser-local. Public or sanitized material only.

`; @@ -449,23 +447,22 @@ export function renderUniversalDecisionExperience(root) { startingPoint: normalize(restored.startingPoint || ''), intent: restored.intent || '', possibleDecision: restored.possibleDecision || '', + decisionCandidates: Array.isArray(restored.decisionCandidates) ? restored.decisionCandidates : [], optionListAmbiguous: Boolean(restored.optionListAmbiguous), detectedChoiceCount: Number(restored.detectedChoiceCount) || 0, criteriaListAmbiguous: Boolean(restored.criteriaListAmbiguous), detectedCriterionCount: Number(restored.detectedCriterionCount) || 0, hingeCandidate: restored.hingeCandidate || null, - hingeStatus: restored.hingeStatus || '', - hingeApplied: Boolean(restored.hingeApplied), - choices: Array.isArray(restored.choices) ? restored.choices.slice(0, MAX_SUGGESTIONS.choices) : [], - goals: Array.isArray(restored.goals) ? restored.goals.slice(0, MAX_SUGGESTIONS.goals) : [], - futures: Array.isArray(restored.futures) ? restored.futures.slice(0, MAX_SUGGESTIONS.futures) : [], - view: ['entry', 'structure', 'question', 'boundary'].includes(restored.view) ? restored.view : 'entry', - questionKind: restored.questionKind || '', - question: restored.question || '', + choices: Array.isArray(restored.choices) ? restored.choices : [], + goals: Array.isArray(restored.goals) ? restored.goals : [], + futures: Array.isArray(restored.futures) ? restored.futures : [], + selectedChoices: Array.isArray(restored.selectedChoices) ? restored.selectedChoices : [], + selectedGoals: Array.isArray(restored.selectedGoals) ? restored.selectedGoals : [], + selectedFutures: Array.isArray(restored.selectedFutures) ? restored.selectedFutures : [], + view: ['entry', 'brief', 'compare', 'boundary'].includes(restored.view) ? restored.view : 'entry', boundaryTitle: restored.boundaryTitle || '', boundaryBody: restored.boundaryBody || '', savedBoundary: Boolean(restored.savedBoundary), - answerDraft: normalize(restored.answerDraft || ''), }; const hasSavedDecision = () => Boolean(getBrowserStorage(globalThis)?.getItem?.(DECISION_STORAGE_KEY)); @@ -475,241 +472,162 @@ export function renderUniversalDecisionExperience(root) { state.boundaryTitle = title; state.boundaryBody = body; state.savedBoundary = saved; - state.questionKind = ''; - state.question = ''; - state.answerDraft = ''; - saveSession(state); - paint('#universal-response-title'); - } - - function setQuestion(kind, question = '') { - state.view = 'question'; - state.questionKind = kind; - state.question = question || nextQuestion(state, kind); - state.answerDraft = ''; saveSession(state); paint('#universal-response-title'); } function returnToEntry() { state.view = 'entry'; - state.questionKind = ''; - state.question = ''; state.boundaryTitle = ''; state.boundaryBody = ''; state.savedBoundary = false; - state.answerDraft = ''; - state.hingeCandidate = null; - state.hingeStatus = ''; - state.hingeApplied = false; saveSession(state); paint('#universal-input'); } - function handoffWhenReady() { - const hingeNext = hingeTarget(state); - if (hingeNext) { - setQuestion('hinge', hingeQuestion(state)); - return; - } - const missing = missingRequirement(state); - if (missing) { - setQuestion(missing); - return; - } - if (hasSavedDecision()) { - setBoundary('A saved FDE decision already exists.', 'Open Decision Lab to review it before replacing anything.', true); - return; - } - const counts = { objectives: state.goals.length, strategies: state.choices.length, scenarios: state.futures.length }; - const withinBounds = counts.objectives >= DRAFT_TOPOLOGY_BOUNDS.objectives.min - && counts.objectives <= DRAFT_TOPOLOGY_BOUNDS.objectives.max - && counts.strategies >= DRAFT_TOPOLOGY_BOUNDS.strategies.min - && counts.strategies <= DRAFT_TOPOLOGY_BOUNDS.strategies.max - && counts.scenarios >= DRAFT_TOPOLOGY_BOUNDS.scenarios.min - && counts.scenarios <= DRAFT_TOPOLOGY_BOUNDS.scenarios.max; - if (!withinBounds || !state.possibleDecision) { - setQuestion(missing || 'decision'); - return; - } - const decision = createGuidedDecisionCase({ - objectiveCount: counts.objectives, - strategyCount: counts.strategies, - scenarioCount: counts.scenarios, - }); - decision.question = state.possibleDecision; - decision.title = state.possibleDecision.slice(0, 120); - state.goals.forEach((label, index) => { decision.objectives[index].label = label; }); - state.choices.forEach((label, index) => { decision.strategies[index].label = label; }); - state.futures.forEach((label, index) => { decision.scenarios[index].label = label; }); - const result = saveDecision(getBrowserStorage(globalThis), decision, null); - if (!result.ok) { - setBoundary('FDE could not save this browser-local draft.', 'Keep this page open, review browser storage settings, then try again.'); - return; - } + function startAnother() { clearSession(); - try { - storage()?.setItem(CONTEXT_KEY, JSON.stringify({ version: 1, startingPoint: state.startingPoint })); - storage()?.setItem(HANDOFF_KEY, '1'); - } catch { /* optional */ } - location.hash = '#/decision'; + Object.assign(state, { + startingPoint: '', intent: '', possibleDecision: '', decisionCandidates: [], + optionListAmbiguous: false, detectedChoiceCount: 0, criteriaListAmbiguous: false, + detectedCriterionCount: 0, hingeCandidate: null, choices: [], goals: [], futures: [], + selectedChoices: [], selectedGoals: [], selectedFutures: [], view: 'entry', + boundaryTitle: '', boundaryBody: '', savedBoundary: false, + }); + paint('#universal-input'); } - function showInputLimit() { - const message = root.querySelector('#universal-limit'); - if (message) { - message.hidden = false; - message.textContent = `This is longer than FDE can safely structure at once. Choose or paste the section containing the decision, question, or conditions you want to evaluate, and keep it under ${GUIDED_MAX_INPUT_CHARS.toLocaleString()} characters.`; + function showInputLimit(message = '') { + const element = root.querySelector('#universal-limit') || root.querySelector('#universal-compare-validation'); + if (element) { + element.hidden = false; + element.textContent = message || `This is longer than FDE can safely structure at once. Keep the working context under ${GUIDED_MAX_INPUT_CHARS.toLocaleString()} characters.`; } - root.querySelector('#universal-input')?.focus({ preventScroll: true }); } function analyzeEntry() { const raw = String(root.querySelector('#universal-input')?.value || ''); if (raw.length > GUIDED_MAX_INPUT_CHARS) { - state.startingPoint = normalize(raw.slice(0, GUIDED_MAX_INPUT_CHARS + 1)); - saveSession(state); - showInputLimit(); + showInputLimit(`This is longer than FDE can safely structure at once. Keep the working context under ${GUIDED_MAX_INPUT_CHARS.toLocaleString()} characters.`); + root.querySelector('#universal-input')?.focus({ preventScroll: true }); return; } state.startingPoint = normalize(raw); const draft = draftFromInput(state.startingPoint); - Object.assign(state, draft, { hingeStatus: '', hingeApplied: false }); + Object.assign(state, draft, { selectedChoices: [], selectedGoals: [], selectedFutures: [] }); const response = responseFor(state); - if (response.kind === 'question') { - setQuestion(state.optionListAmbiguous ? 'choices' : state.criteriaListAmbiguous ? 'goals' : 'decision', response.question); + if (response.kind === 'empty') { + showInputLimit('Enter a decision, question, or context to continue.'); + root.querySelector('#universal-input')?.focus({ preventScroll: true }); return; } if (response.kind === 'boundary') { setBoundary(response.title, response.body); return; } - state.view = 'structure'; - state.questionKind = ''; - state.question = ''; - state.answerDraft = ''; + state.view = 'brief'; saveSession(state); paint('#universal-response-title'); } - function applyHingeAnswer(answer) { - const hinge = state.hingeCandidate; - if (!hinge || !['yes', 'no', 'not_sure'].includes(answer)) return; - state.hingeStatus = answer; - state.hingeApplied = false; - if (answer === 'yes' && ['hard_requirement', 'deadline'].includes(hinge.basis_type) && state.goals.length < MAX_SUGGESTIONS.goals) { - const before = state.goals.length; - state.goals = unique([...state.goals, hinge.text], MAX_SUGGESTIONS.goals); - state.hingeApplied = state.goals.length > before; - } else if (answer === 'yes' && state.futures.length < MAX_SUGGESTIONS.futures) { - const before = state.futures.length; - state.futures = unique([...state.futures, hinge.text], MAX_SUGGESTIONS.futures); - state.hingeApplied = state.futures.length > before; - } else if (answer === 'not_sure' && state.futures.length < MAX_SUGGESTIONS.futures) { - const before = state.futures.length; - state.futures = unique([...state.futures, hinge.text], MAX_SUGGESTIONS.futures); - state.hingeApplied = state.futures.length > before; - } - state.questionKind = ''; - state.question = ''; - state.answerDraft = ''; - saveSession(state); - handoffWhenReady(); + function checkedValues(name) { + return [...root.querySelectorAll(`input[name="${name}"]:checked`)].map((element) => normalize(element.value)).filter(Boolean); } - function applyQuestionAnswer() { - const raw = String(root.querySelector('#universal-input')?.value || ''); - if (raw.length > GUIDED_MAX_INPUT_CHARS) { - showInputLimit(); - return; - } - const answer = normalize(raw); - state.answerDraft = answer; - if (!answer) { - saveSession(state); - paint('#universal-response-title'); + function enforceCheckboxLimit(name, max) { + root.querySelectorAll(`input[name="${name}"]`).forEach((checkbox) => checkbox.addEventListener('change', () => { + const checked = checkedValues(name); + if (checked.length > max) { + checkbox.checked = false; + const label = name === 'formal-choice' ? 'options' : name === 'formal-goal' ? 'criteria' : 'conditions'; + showInputLimit(`Select no more than ${max} ${label} for one formal comparison.`); + } else { + const message = root.querySelector('#universal-compare-validation'); + if (message) message.hidden = true; + } + })); + } + + function formalDecision() { + return normalize(root.querySelector('input[name="formal-decision"]:checked')?.value || root.querySelector('#universal-decision-text')?.value || state.possibleDecision); + } + + function formalSelections(name, additionsId) { + return unique([...checkedValues(name), ...splitUserItems(root.querySelector(additionsId)?.value || '')]); + } + + function openDecisionLab() { + if (hasSavedDecision()) { + setBoundary('A saved FDE decision already exists.', 'Open Decision Lab to review it before replacing anything.', true); return; } + const decisionText = formalDecision(); + const selectedChoices = formalSelections('formal-choice', '#universal-add-choices'); + const selectedGoals = formalSelections('formal-goal', '#universal-add-goals'); + const selectedFutures = formalSelections('formal-future', '#universal-add-futures'); + const hingeStatus = root.querySelector('input[name="hinge-status"]:checked')?.value || ''; + const hinge = state.hingeCandidate; - // A complete new decision entered during a follow-up replaces the prior - // intake context instead of inheriting choices, goals, or uncertainties. - const replacementDraft = draftFromInput(answer); - const answerDefinesNewFocus = Boolean(replacementDraft.possibleDecision) - || replacementDraft.intent === 'multi' - || replacementDraft.intent === 'information'; - if (answerDefinesNewFocus) { - Object.assign(state, replacementDraft, { hingeStatus: '', hingeApplied: false }); - const replacementResponse = responseFor(state); - if (replacementResponse.kind === 'question') { - setQuestion(state.optionListAmbiguous ? 'choices' : state.criteriaListAmbiguous ? 'goals' : 'decision', replacementResponse.question); - return; + if (hinge && ['yes', 'not_sure'].includes(hingeStatus)) { + if (hingeStatus === 'yes' && ['hard_requirement', 'deadline'].includes(hinge.basis_type)) { + if (!selectedGoals.includes(hinge.text) && selectedGoals.length < FORMAL_LIMITS.goals.max) selectedGoals.push(hinge.text); + } else if (!selectedFutures.includes(hinge.text) && selectedFutures.length < FORMAL_LIMITS.futures.max) { + selectedFutures.push(hinge.text); } - if (replacementResponse.kind === 'boundary') { - setBoundary(replacementResponse.title, replacementResponse.body); - return; - } - state.view = 'structure'; - state.questionKind = ''; - state.question = ''; - state.answerDraft = ''; - saveSession(state); - paint('#universal-response-title'); - return; } - if (state.questionKind === 'decision') { - const draft = draftFromInput(answer); - state.startingPoint = state.startingPoint ? `${state.startingPoint}\n${answer}` : answer; - Object.assign(state, { - intent: draft.intent, - possibleDecision: draft.possibleDecision, - choices: unique([...state.choices, ...draft.choices], MAX_SUGGESTIONS.choices), - goals: unique([...state.goals, ...draft.goals], MAX_SUGGESTIONS.goals), - futures: unique([...state.futures, ...draft.futures], MAX_SUGGESTIONS.futures), - }); - if (!state.possibleDecision) { - state.question = 'Which decision should FDE evaluate?'; - state.answerDraft = ''; - saveSession(state); - paint('#universal-response-title'); - return; - } - state.view = 'structure'; - state.questionKind = ''; - state.question = ''; - state.answerDraft = ''; - saveSession(state); - paint('#universal-response-title'); + state.selectedChoices = selectedChoices; + state.selectedGoals = selectedGoals; + state.selectedFutures = selectedFutures; + saveSession(state); + + const errors = []; + if (!decisionText) errors.push('State one decision to compare.'); + if (selectedChoices.length < FORMAL_LIMITS.choices.min) errors.push(`Select at least ${FORMAL_LIMITS.choices.min} options.`); + if (selectedChoices.length > FORMAL_LIMITS.choices.max) errors.push(`Select no more than ${FORMAL_LIMITS.choices.max} options.`); + if (selectedGoals.length < FORMAL_LIMITS.goals.min) errors.push(`Select at least ${FORMAL_LIMITS.goals.min} criteria.`); + if (selectedGoals.length > FORMAL_LIMITS.goals.max) errors.push(`Select no more than ${FORMAL_LIMITS.goals.max} criteria.`); + if (selectedFutures.length < FORMAL_LIMITS.futures.min) errors.push(`Select at least ${FORMAL_LIMITS.futures.min} conditions or uncertainties.`); + if (selectedFutures.length > FORMAL_LIMITS.futures.max) errors.push(`Select no more than ${FORMAL_LIMITS.futures.max} conditions or uncertainties.`); + if (errors.length) { + showInputLimit(errors.join(' ')); + root.querySelector('#universal-compare-validation')?.focus?.({ preventScroll: true }); return; } - if (state.questionKind === 'choices') state.choices = unique([...state.choices, ...splitUserItems(answer, MAX_SUGGESTIONS.choices)], MAX_SUGGESTIONS.choices); - if (state.questionKind === 'goals') { - state.goals = unique([...state.goals, ...splitUserItems(answer, MAX_SUGGESTIONS.goals)], MAX_SUGGESTIONS.goals); - state.criteriaListAmbiguous = false; - state.detectedCriterionCount = state.goals.length; - } - if (state.questionKind === 'futures') state.futures = unique([...state.futures, ...splitUserItems(answer, MAX_SUGGESTIONS.futures)], MAX_SUGGESTIONS.futures); - state.answerDraft = ''; - const missing = missingRequirement(state); - if (missing) { - setQuestion(missing); + + const decision = createGuidedDecisionCase({ + objectiveCount: selectedGoals.length, + strategyCount: selectedChoices.length, + scenarioCount: selectedFutures.length, + }); + decision.question = decisionText; + decision.title = decisionText.slice(0, 120); + selectedGoals.forEach((label, index) => { decision.objectives[index].label = label; }); + selectedChoices.forEach((label, index) => { decision.strategies[index].label = label; }); + selectedFutures.forEach((label, index) => { decision.scenarios[index].label = label; }); + + const result = saveDecision(getBrowserStorage(globalThis), decision, null); + if (!result.ok) { + setBoundary('FDE could not save this browser-local draft.', 'Keep this page open, review browser storage settings, then try again.'); return; } - handoffWhenReady(); + clearSession(); + try { + storage()?.setItem(CONTEXT_KEY, JSON.stringify({ version: 1, startingPoint: state.startingPoint })); + storage()?.setItem(HANDOFF_KEY, '1'); + } catch { /* optional */ } + location.hash = '#/decision'; } function paint(focusSelector = '') { - if (state.view === 'structure') root.innerHTML = structureMarkup(state); - else if (state.view === 'question' && state.questionKind === 'hinge') root.innerHTML = hingeQuestionMarkup(state); - else if (state.view === 'question') root.innerHTML = questionMarkup(state, state.question || nextQuestion(state, state.questionKind)); - else if (state.view === 'boundary') root.innerHTML = boundaryMarkup(state, state.boundaryTitle, state.boundaryBody, state.savedBoundary); + if (state.view === 'brief') root.innerHTML = briefMarkup(state, hasSavedDecision()); + else if (state.view === 'compare') root.innerHTML = compareMarkup(state); + else if (state.view === 'boundary') root.innerHTML = boundaryMarkup(state.boundaryTitle, state.boundaryBody, state.savedBoundary); else root.innerHTML = entryMarkup(state, hasSavedDecision()); root.querySelector('#universal-input')?.addEventListener('input', (event) => { const rawValue = String(event.currentTarget.value || ''); - const value = rawValue.slice(0, GUIDED_MAX_INPUT_CHARS + 1); - if (state.view === 'entry') state.startingPoint = value; - else state.answerDraft = value; + state.startingPoint = rawValue.slice(0, GUIDED_MAX_INPUT_CHARS + 1); const limit = root.querySelector('#universal-limit'); if (limit && rawValue.length <= GUIDED_MAX_INPUT_CHARS) limit.hidden = true; saveSession(state); @@ -717,20 +635,30 @@ export function renderUniversalDecisionExperience(root) { root.querySelector('#universal-input')?.addEventListener('keydown', (event) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); - if (state.view === 'entry') analyzeEntry(); - else if (state.view === 'question') applyQuestionAnswer(); + analyzeEntry(); } }); - root.querySelector('#universal-analyze')?.addEventListener('click', () => { - if (state.view === 'entry') analyzeEntry(); - else if (state.view === 'question') applyQuestionAnswer(); - }); + root.querySelector('#universal-analyze')?.addEventListener('click', analyzeEntry); root.querySelector('#universal-adjust')?.addEventListener('click', returnToEntry); - root.querySelector('#universal-confirm')?.addEventListener('click', handoffWhenReady); - root.querySelectorAll('[data-hinge-answer]').forEach((button) => button.addEventListener('click', () => applyHingeAnswer(button.dataset.hingeAnswer))); + root.querySelector('#universal-refine')?.addEventListener('click', returnToEntry); + root.querySelector('#universal-start-over')?.addEventListener('click', startAnother); + root.querySelector('#universal-compare')?.addEventListener('click', () => { + state.view = 'compare'; + saveSession(state); + paint('#universal-response-title'); + }); + root.querySelector('#universal-back-brief')?.addEventListener('click', () => { + state.view = 'brief'; + saveSession(state); + paint('#universal-response-title'); + }); + root.querySelector('#universal-open-lab')?.addEventListener('click', openDecisionLab); + enforceCheckboxLimit('formal-choice', FORMAL_LIMITS.choices.max); + enforceCheckboxLimit('formal-goal', FORMAL_LIMITS.goals.max); + enforceCheckboxLimit('formal-future', FORMAL_LIMITS.futures.max); if (focusSelector) root.querySelector(focusSelector)?.focus({ preventScroll: true }); } paint(state.view === 'entry' ? '#universal-title' : '#universal-response-title'); -} +} \ No newline at end of file diff --git a/site/src/version.js b/site/src/version.js index 68ec588..3dc0944 100644 --- a/site/src/version.js +++ b/site/src/version.js @@ -1 +1 @@ -export const APPLICATION_VERSION = '0.5.15'; +export const APPLICATION_VERSION = '0.5.16'; diff --git a/tests/synthesis.test.js b/tests/synthesis.test.js index 9831c0f..1181c37 100644 --- a/tests/synthesis.test.js +++ b/tests/synthesis.test.js @@ -74,9 +74,9 @@ test('public source exposes progressive projections and truthful traceability', assert.match(source, /not an arithmetic conversion/); const noJs = await readFile(new URL('../site/index.html', import.meta.url), 'utf8'); assert.match(noJs, /What are you considering\?/); - assert.match(noJs, /Private by design/); - assert.match(noJs, /FDE structures and calculates; it does not approve or authorize the decision/); - assert.match(noJs, /confidential or controlled information/i); + assert.match(noJs, /browser-local decision workspace/i); + assert.match(noJs, /FDE structures and calculates; it does not approve or authorize a decision/); + assert.match(noJs, /public or sanitized material only/i); }); test('legacy walkthrough is only a compatibility shim into integrated help', async () => { diff --git a/tests/universal-response.test.js b/tests/universal-response.test.js index b86985e..013a43f 100644 --- a/tests/universal-response.test.js +++ b/tests/universal-response.test.js @@ -2,27 +2,24 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { draftFromInput, responseFor } from '../site/src/universal-ui.js'; -test('clear decision input produces only supportable deterministic structure', () => { +test('clear decision input produces a supportable brief candidate', () => { const draft = draftFromInput('Should we qualify an alternate source or redesign around the dependency? Schedule risk and resilience matter, but qualification may be late.'); assert.equal(draft.intent, 'decision'); assert.equal(draft.possibleDecision, 'Should we qualify an alternate source or redesign around the dependency'); assert.deepEqual(draft.choices, ['qualify an alternate source', 'redesign around the dependency']); assert.deepEqual(draft.goals, ['Schedule risk', 'Resilience', 'Qualification']); assert.deepEqual(draft.futures, ['Timing gets worse']); - assert.equal(responseFor(draft).kind, 'structure'); + assert.equal(responseFor(draft).kind, 'brief'); }); -test('criteria lists do not override explicit binary choices from ordinary language', () => { - const input = 'Should we qualify a second gallium nitride wafer supplier in Japan or keep our current Chinese supplier? We care about cost, schedule risk, and DFARS compliance.'; - const draft = draftFromInput(input); - assert.equal(draft.intent, 'decision'); - assert.equal(draft.optionListAmbiguous, false); +test('criteria lists do not override explicit binary choices', () => { + const draft = draftFromInput('Should we qualify a second gallium nitride wafer supplier in Japan or keep our current Chinese supplier? We care about cost, schedule risk, and DFARS compliance.'); assert.deepEqual(draft.choices, [ 'qualify a second gallium nitride wafer supplier in Japan', 'keep our current Chinese supplier', ]); assert.deepEqual(draft.goals, ['Cost', 'Schedule risk', 'Compliance']); - assert.equal(responseFor(draft).kind, 'structure'); + assert.equal(responseFor(draft).kind, 'brief'); }); test('decision title prefers the actual decision over leading blocker context', () => { @@ -35,15 +32,18 @@ test('institutional should-question produces readable choice labels', () => { assert.deepEqual(draft.choices, ['Ministry — expand port capacity', 'defer investment']); }); -test('sparse input produces exactly one useful clarification question', () => { - for (const input of ['', 'qualification evidence incomplete', 'The mission dependency is unclear.']) { - const response = responseFor(draftFromInput(input)); - assert.equal(response.kind, 'question'); - assert.match(response.question, /which decision or question/i); - assert.equal(Object.keys(response).filter((key) => key === 'question').length, 1); +test('sparse in-scope context returns a partial brief instead of a compulsory question', () => { + for (const input of ['qualification evidence incomplete', 'The mission dependency is unclear.']) { + const draft = draftFromInput(input); + assert.equal(responseFor(draft).kind, 'brief', input); + assert.equal(draft.possibleDecision, '', input); } }); +test('empty input remains an inline entry-state validation case', () => { + assert.equal(responseFor(draftFromInput('')).kind, 'empty'); +}); + test('information request returns an honest capability boundary and useful next action', () => { const response = responseFor(draftFromInput('What is the current spot price of gallium?')); assert.equal(response.kind, 'boundary'); @@ -51,13 +51,16 @@ test('information request returns an honest capability boundary and useful next assert.match(response.body, /gather the fact|state the decision/i); }); -test('multiple decisions ask for one focus instead of fabricating a combined model', () => { - const draft = draftFromInput('Should we qualify a second source? Should we redesign around the dependency?'); +test('multiple decisions are preserved instead of forcing an immediate focus question', () => { + const draft = draftFromInput('Should we qualify a second source? Should we redesign around the dependency? Should we build strategic inventory?'); assert.equal(draft.intent, 'multi'); assert.equal(draft.possibleDecision, ''); - const response = responseFor(draft); - assert.equal(response.kind, 'question'); - assert.match(response.question, /focus on first/i); + assert.deepEqual(draft.decisionCandidates, [ + 'Should we qualify a second source', + 'Should we redesign around the dependency', + 'Should we build strategic inventory', + ]); + assert.equal(responseFor(draft).kind, 'brief'); }); test('human input remains inert context and extracted fields remain explicit', () => { @@ -70,55 +73,68 @@ test('human input remains inert context and extracted fields remain explicit', ( assert.ok(draft.goals.includes('Safety')); }); -test('listed choices are preserved when they fit the comparison bound', () => { +test('listed choices are preserved when they fit the formal comparison bound', () => { for (const input of [ 'Should we qualify Supplier Alpha, Supplier Bravo, or Supplier Charlie?', 'We can dual-source, stockpile, or redesign. Which should we choose?', 'Should we qualify the incumbent material, a substitute material, or a redesigned subsystem?', ]) { const draft = draftFromInput(input); - assert.equal(draft.optionListAmbiguous, false, input); assert.equal(draft.detectedChoiceCount, 3, input); assert.equal(draft.choices.length, 3, input); - assert.equal(responseFor(draft).kind, 'structure', input); + assert.equal(responseFor(draft).kind, 'brief', input); } }); -test('oversized listed choices ask the user to choose up to the supported bound', () => { +test('oversized choice sets are preserved in capture state rather than truncated', () => { const input = 'Choose between Supplier Alpha, Supplier Bravo, an alternate material, a reserve, or subsystem redesign.'; const draft = draftFromInput(input); assert.equal(draft.optionListAmbiguous, true); assert.equal(draft.detectedChoiceCount, 5); - assert.deepEqual(draft.choices, []); - const response = responseFor(draft); - assert.equal(response.kind, 'question'); - assert.equal(response.question, 'I found 5 possible choices. Choose up to 3 to compare.'); + assert.deepEqual(draft.choices, ['Supplier Alpha', 'Supplier Bravo', 'an alternate material', 'a reserve', 'subsystem redesign']); + assert.equal(responseFor(draft).kind, 'brief'); }); -test('binary option extraction remains deterministic', () => { - const draft = draftFromInput('Should we qualify an alternate source or redesign around the dependency?'); - assert.equal(draft.optionListAmbiguous, false); - assert.deepEqual(draft.choices, ['qualify an alternate source', 'redesign around the dependency']); - assert.equal(responseFor(draft).kind, 'structure'); -}); - -test('generated oversized-choice corpus never exposes a strict partial option set', () => { +test('generated oversized-choice corpus never drops source candidates', () => { let cases = 0; for (let count = 4; count <= 7; count += 1) { - for (let variant = 0; variant < 60; variant += 1) { + for (let variant = 0; variant < 40; variant += 1) { const options = Array.from({ length: count }, (_, index) => `qualification-path-${variant}-${index + 1}`); - const prefix = variant % 2 === 0 ? 'Should we choose ' : 'Choose between '; - const separator = variant % 3 === 0 ? ', ' : variant % 3 === 1 ? ', ' : ', '; - const input = `${prefix}${options.slice(0, -1).join(separator)}, or ${options.at(-1)}?`; + const input = `Choose between ${options.slice(0, -1).join(', ')}, or ${options.at(-1)}?`; const draft = draftFromInput(input); assert.equal(draft.optionListAmbiguous, true, input); assert.equal(draft.detectedChoiceCount, count, input); - assert.equal(draft.choices.length, 0, input); - assert.equal(responseFor(draft).question, `I found ${count} possible choices. Choose up to 3 to compare.`, input); + assert.equal(draft.choices.length, count, input); + assert.equal(responseFor(draft).kind, 'brief', input); cases += 1; } } - assert.ok(cases >= 200); + assert.ok(cases >= 160); +}); + +test('explicit criteria outside the keyword dictionary remain visible', () => { + const draft = draftFromInput('Should we qualify Supplier Alpha or Supplier Bravo? Data residency, capex, measurement traceability, and corrosion resistance matter.'); + assert.deepEqual(draft.goals, ['Data residency', 'Capex', 'Measurement traceability', 'Corrosion resistance']); +}); + +test('oversized criteria sets remain preserved in capture state', () => { + const draft = draftFromInput('Should we qualify Supplier Alpha or Supplier Bravo? Data residency, capex, measurement traceability, corrosion resistance, repairability, maintainability, and schedule matter.'); + assert.equal(draft.criteriaListAmbiguous, true); + assert.equal(draft.detectedCriterionCount, 7); + assert.equal(draft.goals.length, 7); + assert.equal(responseFor(draft).kind, 'brief'); +}); + +test('informational versus language is not converted into a decision choice set', () => { + const information = draftFromInput('Explain qualification versus redesign for a new engineer.'); + assert.equal(information.intent, 'information'); + assert.deepEqual(information.choices, []); + assert.equal(responseFor(information).kind, 'boundary'); + + const decision = draftFromInput('Should we qualify Supplier Alpha versus Supplier Bravo?'); + assert.equal(decision.intent, 'decision'); + assert.deepEqual(decision.choices, ['qualify Supplier Alpha', 'Supplier Bravo']); + assert.equal(responseFor(decision).kind, 'brief'); }); test('out-of-scope personal treatment decisions fail closed', () => { @@ -134,66 +150,54 @@ test('out-of-scope personal treatment decisions fail closed', () => { assert.equal(response.kind, 'boundary', input); assert.equal(response.title, 'This decision is outside FDE’s comparison scope.', input); assert.match(response.body, /technical, organizational, mission, and strategic/i, input); - assert.match(response.body, /qualified support/i, input); } }); test('out-of-scope severe personal restriction decisions fail closed', () => { - const examples = [ + for (const input of [ 'Should I stop eating to lose weight faster?', 'Should I fast for five days or seven days?', 'Should I skip all meals this week?', 'Should I starve myself to reach my goal sooner?', - ]; - for (const input of examples) { + ]) { const response = responseFor(draftFromInput(input)); assert.equal(response.kind, 'boundary', input); assert.equal(response.title, 'This decision is outside FDE’s comparison scope.', input); - assert.match(response.body, /technical, organizational, mission, and strategic/i, input); } }); test('out-of-scope personal dosing comparisons fail closed', () => { - const examples = [ + for (const input of [ 'Should I take 2 ibuprofen or 4 ibuprofen for this?', 'Should I take 500 mg or 1000 mg acetaminophen?', 'Should I use one dose or two doses of cough medicine?', 'Should I take one tablet or three tablets of this medication?', - ]; - for (const input of examples) { + ]) { const response = responseFor(draftFromInput(input)); assert.equal(response.kind, 'boundary', input); assert.equal(response.title, 'This decision is outside FDE’s comparison scope.', input); - assert.match(response.body, /technical, organizational, mission, and strategic/i, input); - assert.match(response.body, /qualified support/i, input); } }); test('out-of-scope immediate personal safety decisions fail closed', () => { - const examples = [ + for (const input of [ 'Should I drive myself to the ER or wait it out?', 'Should I go to urgent care now or wait until morning?', 'Should I call 911 or wait it out?', 'Should I wait it out or head to the emergency room?', - ]; - for (const input of examples) { + ]) { const response = responseFor(draftFromInput(input)); assert.equal(response.kind, 'boundary', input); assert.equal(response.title, 'This decision is outside FDE’s comparison scope.', input); - assert.match(response.body, /technical, organizational, mission, and strategic/i, input); - assert.match(response.body, /qualified real-time support/i, input); } }); -test('ordinary non-medical decisions are not caught by personal safety boundaries', () => { +test('ordinary organizational decisions are not caught by personal boundaries', () => { for (const input of [ 'Should we qualify an alternate source or redesign around the dependency to save money?', 'Should we reduce qualification scope or delay the readiness gate?', 'Should we hold deployment pending evidence or proceed at the next readiness window?', - 'Should we qualify the faster source or the more reliable source?', 'Should we field 2 redundant ground nodes or 4 redundant ground nodes?', - 'Should the mission processor reserve 2 GB or 4 GB of memory?', - 'Should we delay the mission-control software release or proceed at the current readiness gate?', ]) { const response = responseFor(draftFromInput(input)); assert.notEqual(response.kind === 'boundary' && response.title === 'This decision is outside FDE’s comparison scope.', true, input); @@ -210,113 +214,53 @@ test('self-directed crisis language fails closed before ordinary decision struct 'I want to die.', 'I want to end it all.', 'end it all', - 'Should I cut myself or call someone?', - 'I cannot keep living like this.', ]) { const response = responseFor(draftFromInput(input)); assert.equal(response.kind, 'boundary', input); - assert.equal(response.title, 'This decision is outside FDE’s comparison scope.', input); - assert.match(response.body, /does not compare or optimize self-harm/i, input); - assert.match(response.body, /988/i, input); + assert.match(response.body, /988|emergency|support/i, input); } }); -test('specific crisis language and neutral personal scope both fail closed without blocking supplier idiom', () => { - const crisis = responseFor(draftFromInput('Should I just end it all?')); - assert.equal(crisis.kind, 'boundary'); - assert.match(crisis.body, /988/); - - for (const input of [ - 'Should I stop living in London and move to Leeds?', - 'Should I give up on life coaching as a career or continue?', - ]) { - const personal = responseFor(draftFromInput(input)); - assert.equal(personal.kind, 'boundary', input); - assert.match(personal.body, /personal-life decisions/i, input); - assert.match(personal.body, /not a clinical judgment/i, input); - assert.doesNotMatch(personal.body, /does not compare or optimize self-harm/i, input); - } - - const supplier = responseFor(draftFromInput('Should we end it all with this supplier or renegotiate?')); - assert.notEqual(supplier.kind === 'boundary' && supplier.title === 'This decision is outside FDE’s comparison scope.', true); -}); - -test('organizational prevention decisions are not mistaken for first-person self-harm', () => { - for (const input of [ - 'Should the ministry fund suicide prevention hotlines or school counselors?', - 'Should we expand suicide prevention training or crisis-response staffing?', - 'Should the program measure self-harm prevention outcomes or referral completion?', - ]) { - const response = responseFor(draftFromInput(input)); - assert.notEqual(response.kind === 'boundary' && response.title === 'This decision is outside FDE’s comparison scope.', true, input); - } +test('supplier language using end-it-all idiom is not misclassified as personal crisis', () => { + const input = 'Should we end it all with this supplier or renegotiate the contract? Cost and resilience matter.'; + const response = responseFor(draftFromInput(input)); + assert.equal(response.kind, 'brief'); }); -test('ordinary engineering and business harm language does not trigger the personal boundary', () => { - for (const input of [ - 'Should we terminate the failed qualification run or restart it?', - 'Could this dependency harm mission readiness enough to change the decision?', - 'Should we terminate the failed verification run or continue collecting evidence?', - ]) { - const response = responseFor(draftFromInput(input)); - assert.notEqual(response.kind === 'boundary' && response.title === 'This decision is outside FDE’s comparison scope.', true, input); - } -}); - -test('mission criteria use bounded phrase matching without substring false positives', () => { - const costa = draftFromInput('Should we qualify Costara Materials or retain the incumbent source? Reliability and resilience matter.'); - assert.deepEqual(costa.choices, ['qualify Costara Materials', 'retain the incumbent source']); - assert.equal(costa.goals.includes('Cost'), false); - assert.deepEqual(costa.goals, ['Reliability', 'Resilience']); - - const mission = draftFromInput('Should we impose export controls or negotiate supply agreements with allies? National security, cost, and time matter.'); - assert.equal(mission.optionListAmbiguous, false); - assert.deepEqual(mission.choices, ['impose export controls', 'negotiate supply agreements with allies']); - assert.deepEqual(mission.goals, ['National security', 'Cost', 'Time']); - assert.equal(responseFor(mission).kind, 'structure'); -}); - -test('ordinary criteria-list forms do not override explicit choices', () => { +test('ordinary criteria-list forms remain visible without becoming compulsory questions', () => { for (const input of [ 'Should we qualify or redesign? Cost, schedule risk, and readiness matter.', - 'Should we qualify or redesign? Cost, schedule risk, and readiness are important.', 'Should we qualify or redesign? Our priorities are cost, readiness, and resilience.', 'Should we qualify or redesign? The decision must balance cost, readiness, and interoperability.', ]) { const draft = draftFromInput(input); - assert.equal(draft.optionListAmbiguous, false, input); assert.deepEqual(draft.choices, ['qualify', 'redesign'], input); - assert.equal(responseFor(draft).kind, 'structure', input); + assert.equal(responseFor(draft).kind, 'brief', input); } }); - - -test('explicit criteria outside the keyword dictionary remain visible for human confirmation', () => { - const draft = draftFromInput('Should we qualify Supplier Alpha or Supplier Bravo? Data residency, capex, measurement traceability, and corrosion resistance matter.'); - assert.equal(draft.criteriaListAmbiguous, false); - assert.deepEqual(draft.goals, ['Data residency', 'Capex', 'Measurement traceability', 'Corrosion resistance']); - assert.equal(responseFor(draft).kind, 'structure'); +test('large criteria corpus preserves every explicit criterion before formal selection', () => { + for (let count = 5; count <= 7; count += 1) { + const criteria = Array.from({ length: count }, (_, index) => `criterion-${count}-${index + 1}`); + const input = `Should we qualify Supplier Alpha or Supplier Bravo? ${criteria.join(', ')} matter.`; + const draft = draftFromInput(input); + assert.equal(draft.criteriaListAmbiguous, true, input); + assert.equal(draft.goals.length, count, input); + assert.equal(responseFor(draft).kind, 'brief', input); + } }); -test('oversized explicit criteria sets ask for bounded human selection instead of silently truncating', () => { - const draft = draftFromInput('Should we qualify Supplier Alpha or Supplier Bravo? Data residency, capex, measurement traceability, corrosion resistance, and repairability matter.'); - assert.equal(draft.criteriaListAmbiguous, true); - assert.equal(draft.detectedCriterionCount, 5); - assert.deepEqual(draft.goals, []); - const response = responseFor(draft); - assert.equal(response.kind, 'question'); - assert.equal(response.question, 'I found 5 possible criteria. Choose up to 4 to keep.'); +test('supportable decision remains distinct from unresolved evidence language', () => { + const draft = draftFromInput('Evidence is incomplete and supplier capacity is uncertain. Should we qualify Supplier Alpha or retain the incumbent? Cost and continuity matter.'); + assert.equal(draft.possibleDecision, 'Should we qualify Supplier Alpha or retain the incumbent'); + assert.deepEqual(draft.choices, ['qualify Supplier Alpha', 'retain the incumbent']); + assert.equal(responseFor(draft).kind, 'brief'); }); -test('informational versus language is not converted into a decision choice set', () => { - const information = draftFromInput('Explain qualification versus redesign for a new engineer.'); - assert.equal(information.intent, 'information'); - assert.deepEqual(information.choices, []); - assert.equal(responseFor(information).kind, 'boundary'); - - const decision = draftFromInput('Should we qualify Supplier Alpha versus Supplier Bravo?'); - assert.equal(decision.intent, 'decision'); - assert.deepEqual(decision.choices, ['qualify Supplier Alpha', 'Supplier Bravo']); - assert.equal(responseFor(decision).kind, 'structure'); +test('open context without a fabricated decision still produces a partial brief', () => { + const draft = draftFromInput('The integration milestone is approaching and qualification evidence is incomplete.'); + assert.equal(draft.intent, 'open'); + assert.equal(draft.possibleDecision, ''); + assert.equal(responseFor(draft).kind, 'brief'); }); +