diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4567f7f..d4d8508 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index a94a3a4..23cbc75 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -29,6 +29,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/README.md b/README.md index cccf444..ffd1c0d 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,46 @@ # Frontier Decision Engine -**Bring a messy situation. Find the decision. Test choices when useful. Keep the final judgment human-owned.** +**Bring the whole mess. FDE gives you something useful back, helps make uncertainty visible, and keeps the final decision human-owned.** [Open the live application](https://bridgenode7.com/frontier-decision-engine/) ![Frontier Decision Engine](docs/screenshots/reference/desktop-decision-frame.png) -## Start with Decision Rescue +## Start with one input -Decision Rescue accepts ordinary language first. A visitor can put down what is happening, choose one useful next action, and build a Decision Frame without knowing decision-science terminology. +The FDE front door starts with one free-form input. Put down a problem, question, worry, idea, decision, or a complete mess. FDE responds immediately with a provisional Decision Map showing only what can be safely organized from the words provided. -FDE preserves what the person confirms and does not pretend browser JavaScript inferred evidence, probabilities, thresholds, scores, or a recommendation. A partial Decision Frame is a valid stopping point. +The map is a draft, not a hidden machine decision. Possible signals stay possible until a person confirms them. When enough explicit structure exists, the map can continue into the deterministic Decision Lab. Otherwise, it offers a simple path to shape the missing pieces without pretending the missing information is known. -Decision Rescue uses tab-scoped session storage so an accidental refresh can recover in-progress framing. It does not treat the original brain dump as model evidence. When a complete frame continues into the Decision Lab, existing browser-saved Lab work is never replaced without an explicit human choice. +The experience is designed around a simple rule: **every meaningful input receives a useful response, and questions are used only when human judgment is actually needed to move the decision forward.** -## What the Decision Lab does +## Decision Map -- Frames the decision, goals, choices, and plausible futures. -- Evaluates the same choices across explicit goals and named conditions. -- Shows unmet goals, ties, incomplete outcomes, and vulnerabilities. -- Keeps final selection, rationale, and next action human-owned. -- Exports a portable decision file and a readable decision summary. +The map can show: -Guided work supports 2–4 objectives, 2–3 choices, and 2–4 plausible futures. The minimum comparison is a real 2 × 2 × 2 model. FDE never fills missing analytical inputs on the person's behalf. +- a possible decision; +- choices mentioned explicitly; +- things that may matter; +- conditions that could change the answer; and +- the smallest useful next step. -The included ready example is a **synthetic critical-material source-qualification case**. It does not describe or certify a real supplier, material, capacity, compliance status, or investment. +This is deliberately conservative. The browser does not call a remote AI service, retrieve outside facts, invent probabilities, or promote provisional text into verified evidence. -## Privacy and authority +## Decision Lab -The application is static and browser-local. It has no backend, account system, analytics, telemetry, cookies, or default upload endpoint. +When a person confirms enough structure, FDE can hand the work into its existing deterministic comparison engine. Guided work supports 2–4 objectives, 2–3 choices, and 2–4 plausible futures. The minimum comparison is a true 2 × 2 × 2 model. -Browser storage is a convenience, not encrypted confidential storage. Do not enter information that requires an approved confidential or controlled-data environment. +The comparison informs. A person decides. -FDE provides decision support. It does not approve, authorize, certify, qualify, consent, or make an investment decision. The comparison informs; a person decides. +## Synthetic reference -## Run locally +The repository includes a synthetic critical-material source-qualification case to demonstrate the formal Decision Lab without making claims about a real supplier, material, capacity, compliance status, or investment. -```bash -node scripts/run-python.mjs -m http.server 8000 --directory site -``` +## Privacy and authority + +The application is static and browser-local. It has no backend, account system, analytics, telemetry, cookies, remote AI provider, or default upload endpoint. Session recovery and browser autosave are convenience features, not encrypted confidential storage. -Open `http://localhost:8000`. +FDE provides decision support. It does not approve, authorize, certify, qualify, consent, or make an investment decision. ## Verify @@ -53,14 +53,14 @@ npm ci --ignore-scripts --no-audit --no-fund npm run check ``` -`npm run check` runs the repository's unit, version, integrity, browser, Decision Rescue, and release-closeout gates. Current generated counts and versions are recorded in [`project-facts.json`](project-facts.json). +Generated release facts and current application identity are recorded in [`project-facts.json`](project-facts.json). The full verification gate is defined by the repository's `npm run check` command. ## Documentation - [Architecture](docs/ARCHITECTURE.md) - [Methodology](docs/METHODOLOGY.md) - [Privacy](docs/PRIVACY.md) -- [Releases](docs/RELEASING.md) +- [Releasing](docs/RELEASING.md) ## Project diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7197a15..5820b88 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,55 +2,54 @@ Frontier Decision Engine is a static browser application served from `site/`. There is no backend or account service. -## Runtime +## Human-first runtime -- Decision Rescue is the human-language front door. -- Decision Lab is the structured six-stage comparison workflow. -- The ranking engine is deterministic and runs in the browser. -- Portable decision schemas remain versioned under `schemas/`. -- Validation and release checks run from `scripts/` and GitHub Actions. +The public root is a one-input response experience. A person can write a problem, question, worry, idea, decision, or incomplete thought without knowing decision-science vocabulary. FDE immediately returns a useful response and a provisional Decision Map. -## Human-first entry +The Decision Map can show: -The root experience accepts bounded inert text and asks for the smallest useful human contribution next. It does not claim to understand arbitrary prose, retrieve external facts, infer evidence, assign probabilities, create scores, or recommend an action. +- a possible decision; +- choices mentioned explicitly; +- things that may matter; +- conditions that could change the answer; and +- the most useful next step. -A Decision Frame can remain incomplete and still be useful. Full comparison is available only after the person has confirmed a decision, at least two goals, two choices, and two plausible futures. +Possible elements are not silently promoted into facts. The map is a draft until a person confirms the information that belongs in the formal decision model. -Guided comparison supports: +## Minimum necessary human contribution -- 2–4 objectives -- 2–3 strategies -- 2–4 scenarios +FDE follows this interaction rule: -The ready example remains a larger synthetic reference case. Missing thresholds, scores, modifiers, critical flags, and evidence stay missing until a person supplies them. +> Continue without bothering the person when the system can proceed safely; otherwise ask for the smallest human contribution that materially advances the decision. -## Browser storage +No normal input is treated as a dead end. Empty, vague, messy, ambiguous, and non-decision input receive a useful orientation response rather than an error-only state. -Decision Rescue uses tab-scoped session storage for accidental-refresh recovery. The original starting context remains context only and is not scored or promoted into model evidence. +## Formal Decision Lab -Decision Lab uses bounded browser autosave for in-progress structured work. If a browser draft already exists, Decision Rescue does not replace it silently; replacement requires an explicit human choice. +Once enough explicit structure exists and the person confirms the map, the confirmed 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. -Browser storage is not encrypted confidential storage. Local files are processed in the browser and are not uploaded by the default application. +The comparison core is isolated from the Map layer so UX changes do not silently alter ranking semantics. Missing analytical values are not fabricated. -## Decision result +## Epistemic boundaries -The engine evaluates explicit human-supplied inputs across the same named futures. It preserves honest outcomes including: +The public experience separates: -- a unique leader under the tested model; -- tied leaders; -- no acceptable strategy under declared boundaries; and -- insufficient data. +1. **You said** — the original human input. +2. **FDE organized** — provisional structure derived from safe, explicit textual patterns. +3. **You confirmed** — information promoted into the formal model by a human. +4. **FDE calculated** — deterministic output from confirmed model inputs. +5. **You decided** — the human-owned choice, rationale, and next action. -The result is advisory. A person selects the strategy and records the rationale and next action. +The default runtime does not use a remote AI provider, retrieve external facts, infer evidence, assign probabilities, or make the final decision. -## Compatibility +## Browser storage -Legacy completed decision schema `0.2.10` remains supported. Optional decision-semantics state uses schema `0.3.0`. The guided browser editor is intentionally bounded even though portable schemas may permit broader cases. +The universal response experience uses bounded tab-scoped session storage for accidental-refresh recovery. Decision Lab uses bounded browser autosave for structured work. Browser storage is a convenience, not encrypted confidential storage. -The deterministic ranking core is isolated from the human-entry layer so UX changes do not silently alter comparison semantics. +## Privacy and security -## Security and privacy boundaries +The public application has no backend, account system, analytics, telemetry, cookies, remote AI provider, or default upload endpoint. User input is rendered as text, not executable markup. Local files remain in the browser unless the person explicitly downloads or shares them. -The public runtime has no account system, analytics, telemetry, remote AI provider, or default upload endpoint. User-entered text is rendered as text rather than executable markup. +## Long-term direction -Generated visualizations and software calculations are not evidence. FDE does not provide legal approval, organizational authorization, certification, qualification, consent, or investment approval. +The architecture intentionally leaves room for an optional assisted-understanding adapter and later Decision Memory without making either a dependency of the deterministic core. Any future semantic provider must produce provisional output that passes through the same human-confirmation boundary. diff --git a/package.json b/package.json index e367893..4d879bc 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "description": "Browser-local, human-governed decision support for choices under deep uncertainty.", "scripts": { "test": "node --test", + "test:universal": "node --test tests/universal-response.test.js", "facts": "node scripts/generate-project-facts.mjs", "facts:check": "node scripts/validate-project-facts.mjs", "validate": "node scripts/run-python.mjs scripts/validate_repository.py", @@ -18,8 +19,6 @@ "validate:version": "node scripts/run-python.mjs scripts/validate_version_integrity.py", "test:closeout": "node scripts/run-python.mjs scripts/browser_closeout_regressions.py" }, - "engines": { - "node": ">=22" - }, + "engines": { "node": ">=22" }, "license": "Apache-2.0" } diff --git a/project-facts.json b/project-facts.json index 71de019..fc479f4 100644 --- a/project-facts.json +++ b/project-facts.json @@ -2,11 +2,10 @@ "applicationVersion": "0.3.3", "schemaVersions": { "decision": "0.2.10", - "semanticDecision": "0.3.0", - "opvCase": "0.1.0" + "semanticDecision": "0.3.0" }, - "testCount": 143, - "discoveredHashRouteLiteralCount": 6, + "testCount": 148, + "discoveredHashRouteLiteralCount": 7, "browserModes": [ "desktop-light", "mobile-light", @@ -16,11 +15,5 @@ "reflow-400-equivalent", "forced-colors" ], - "manifestEntryCount": 35, - "retainedReferenceArtifactItemCounts": { - "profiles/phenomena/profile.json": 5, - "site/data/experiences.json": 41, - "site/data/morphology.json": 209, - "site/data/references.json": 53 - } + "manifestEntryCount": 37 } diff --git a/scripts/browser_rescue_e2e.py b/scripts/browser_rescue_e2e.py index 5b15672..a8286a2 100644 --- a/scripts/browser_rescue_e2e.py +++ b/scripts/browser_rescue_e2e.py @@ -1,21 +1,20 @@ #!/usr/bin/env python3 -"""Focused browser verification for the human-first Decision Rescue entry.""" +"""Focused browser verification for the universal FDE response surface.""" from __future__ import annotations import functools import http.server import os -import re import socketserver import threading from pathlib import Path -from playwright.sync_api import expect, sync_playwright +from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[1] SITE = ROOT / "site" +SESSION_KEY = "fde.universal.session.v1" DECISION_KEY = "fde.decision.autosave.v0.2.11" -RECORD_KEY = "fde.decision.record.v0.3.1" RESCUE_SESSION_KEY = "fde.rescue.session.v1" @@ -25,47 +24,7 @@ def log_message(self, *_args): def browser_executable() -> str | None: - explicit = os.environ.get("CHROME_BIN") - if explicit: - return explicit - return None - - -def complete_frame(page) -> None: - page.locator('#rescue-intake').fill('We need to decide whether to move now or test first.') - page.locator('#rescue-start').click() - page.get_by_role('button', name='Compare choices I already have').click() - page.get_by_role('button', name='Continue →').click() - page.locator('#rescue-decision').fill('Should we move now or test first?') - page.get_by_role('button', name='Continue →').click() - page.get_by_role('button', name='Time').click() - assert page.evaluate("document.activeElement?.textContent.trim()") == 'Time' - page.get_by_role('button', name='Reliability').click() - page.get_by_role('button', name='Continue →').click() - page.get_by_role('button', name='Keep things as they are').click() - page.get_by_role('button', name='Test or pilot first').click() - page.get_by_role('button', name='Continue →').click() - page.get_by_role('button', name='Things stay roughly the same').click() - page.get_by_role('button', name='A key dependency fails').click() - page.get_by_role('button', name='Build my Decision Frame →').click() - assert 'Decision Frame is ready' in page.locator('#rescue-question').inner_text() - assert page.locator('#rescue-open-lab').is_enabled() - - -def assert_handoff(page) -> None: - page.locator('#decision-question').wait_for(state='visible') - assert page.locator('#decision-question').input_value() == 'Should we move now or test first?' - assert page.locator('input[id^="objective-label-"]').count() == 2 - assert page.locator('input[id^="strategy-label-"]').count() == 2 - assert page.locator('input[id^="scenario-label-"]').count() == 2 - assert page.locator('#objective-label-0').input_value() == 'Time' - assert page.locator('#objective-label-1').input_value() == 'Reliability' - assert page.locator('#strategy-label-0').input_value() == 'Keep things as they are' - assert page.locator('#strategy-label-1').input_value() == 'Test or pilot first' - assert page.locator('#scenario-label-0').input_value() == 'Things stay roughly the same' - assert page.locator('#scenario-label-1').input_value() == 'A key dependency fails' - assert page.locator('.rescue-starting-context').is_visible() - assert 'not scored or treated as evidence' in page.locator('.rescue-starting-context').inner_text() + return os.environ.get("CHROME_BIN") or None def run() -> None: @@ -88,111 +47,91 @@ def run() -> None: page = context.new_page() page.goto(base, wait_until="networkidle") - assert page.locator('main h1').inner_text() == 'Frontier Decision Engine' + hero = page.locator('[data-surface="fde-hero"]') + headline = hero.locator('h1').inner_text() + assert headline.startswith('Bring the whole mess. Find the decision.') + assert page.title() == 'Frontier Decision Engine' + assert page.locator('#universal-input').is_visible() + assert page.locator('#universal-input').get_attribute('aria-describedby') == 'universal-help' + assert page.locator('#universal-response-title').inner_text() + assert page.locator('.universal-surface').is_visible() + assert page.locator('#universal-input').bounding_box()['height'] >= 160 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth + 1") + + page.locator('#universal-analyze').click() + page.locator('#universal-status').wait_for(state='attached') + assert page.locator('#universal-response-title').inner_text() + assert page.get_by_text('Most useful next step', exact=True).count() >= 1 + + messy = 'Should we build internally or partner externally? Time and quality matter, but the supplier may be late. ' + page.locator('#universal-input').fill(messy) + page.locator('#universal-analyze').click() + assert 'decision forming' in page.locator('#universal-response-title').inner_text().lower() + assert page.get_by_text('Should we build internally', exact=True).is_visible() + assert page.get_by_text('partner externally', exact=True).is_visible() + assert page.get_by_text('Time', exact=True).is_visible() + assert page.get_by_text('Quality', exact=True).is_visible() + assert page.get_by_text('Timing gets worse', exact=True).is_visible() + assert page.get_by_text('Possible is not confirmed.', exact=True).is_visible() + assert page.locator('script').filter(has_text='alert(1)').count() == 0 + assert page.locator('#universal-confirm').count() == 0 + assert page.get_by_role('link', name='Help me shape the missing pieces →').is_visible() + + first_remove = page.locator('[data-remove-kind="choices"]').first + assert first_remove.is_visible() + first_remove.click() + assert page.locator('[data-remove-kind="choices"]').count() == 1 + assert 'Updated. Review the map' in page.locator('#universal-next-title').locator('..').inner_text() + + page.locator('#universal-input').fill('How much does a new MRI machine cost?') + page.locator('#universal-analyze').click() + assert 'information question' in page.locator('#universal-response-title').inner_text().lower() + assert 'does not fetch outside facts' in page.locator('#universal-response-title').locator('..').inner_text() + + page.locator('#universal-input').fill('Should we hire someone? Should we expand next year?') + page.locator('#universal-analyze').click() + assert 'more than one possible decision' in page.locator('#universal-response-title').inner_text().lower() + assert page.locator('#universal-confirm').count() == 0 + + page.locator('#universal-input').fill('Should we build internally or partner externally? Time, quality, and cost matter. The supplier may be late and demand changes.') + page.locator('#universal-analyze').click() + page.locator('#universal-confirm').wait_for(state='visible') + + # Saved-work regression / rescue-collision: never silently replace an existing Lab draft. + page.evaluate(f"localStorage.setItem('{DECISION_KEY}', JSON.stringify({{sentinel:'keep-me'}}))") + page.locator('#universal-confirm').click() + assert page.evaluate(f"localStorage.getItem('{DECISION_KEY}')") == '{"sentinel":"keep-me"}' + assert 'A saved FDE decision already exists' in page.locator('#universal-next-title').locator('..').inner_text() + assert page.url.endswith('/') and '#/decision' not in page.url + + page.locator('#universal-input').fill('banana moon 777') + page.locator('#universal-analyze').click() + assert page.locator('#universal-response-title').inner_text() + assert page.get_by_text('Most useful next step', exact=True).count() >= 1 + + page.locator('#universal-input').fill('Refresh should not erase this situation.') + 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 situation.' + + # Rescue route starts from a deterministic empty session for this isolated browser case. + page.evaluate(f"sessionStorage.removeItem('{RESCUE_SESSION_KEY}')") + page.goto(f'{base}#/rescue', wait_until='networkidle') + page.locator('#rescue-question').wait_for(state='visible') assert page.locator('#rescue-intake').is_visible() - assert page.locator('#rescue-intake').get_attribute('aria-describedby') == 'rescue-intake-help' - assert page.locator('#rescue-status').count() == 1 - assert not page.locator('#rescue-status').is_visible() - assert page.locator('#rescue-start').bounding_box()["height"] >= 44 - assert page.locator('html').evaluate("document.documentElement.scrollWidth <= window.innerWidth + 1") - - page.locator('#rescue-start').click() - page.locator('#rescue-status').wait_for(state='visible') - expect(page.locator('#rescue-status')).to_have_text(re.compile(r'\S+')) - - messy = ' Supplier is late 🚀 and I do not know what to do.' - page.locator('#rescue-intake').fill(messy) - page.locator('#rescue-start').click() - assert page.locator('#rescue-question').inner_text() == 'What would help most?' - assert messy in page.locator('.rescue-starting-point').inner_text() - assert page.locator('script').filter(has_text='alert("no")').count() == 0 - continue_button = page.get_by_role('button', name='Continue →') - assert continue_button.get_attribute('aria-describedby') == 'rescue-next-hint' - assert continue_button.is_disabled() - focus_box = page.locator('.rescue-focus').bounding_box() - frame_box = page.locator('.rescue-frame').bounding_box() - assert focus_box and frame_box and focus_box['y'] < frame_box['y'], 'mobile prompt must appear before accumulated frame' - - page.get_by_role('button', name="I'm not sure").first.click() - page.get_by_role('button', name='Continue →').click() - assert 'If one choice had to be made first' in page.locator('#rescue-question').inner_text() - assert page.locator('#rescue-question').bounding_box()['y'] < 844 - for _ in range(4): - page.locator('[data-rescue-skip]').click() - assert 'taking shape' in page.locator('#rescue-question').inner_text() - assert page.locator('#rescue-open-lab').is_disabled() - assert 'No recommendation has been made' in page.locator('body').inner_text() - assert not remote_requests, f"Decision Rescue made unexpected remote requests: {remote_requests}" - context.close() + assert 'What’s going on?' in page.locator('#rescue-question').inner_text() - recovery = browser.new_context(viewport={"width": 390, "height": 844}) - recovery_page = recovery.new_page() - recovery_page.goto(base, wait_until='networkidle') - recovery_page.locator('#rescue-intake').fill('Refresh should not erase this starting point.') - recovery_page.locator('#rescue-start').click() - recovery_page.get_by_role('button', name='Figure out what I need to decide').click() - recovery_page.get_by_role('button', name='Continue →').click() - recovery_page.locator('#rescue-decision').fill('Should we proceed now?') - assert recovery_page.evaluate(f"Boolean(sessionStorage.getItem('{RESCUE_SESSION_KEY}'))") - recovery_page.reload(wait_until='networkidle') - assert recovery_page.locator('#rescue-decision').input_value() == 'Should we proceed now?' - assert 'Refresh should not erase this starting point.' in recovery_page.locator('.rescue-starting-point').inner_text() - recovery.close() - - handoff = browser.new_context(viewport={"width": 390, "height": 844}) - handoff_page = handoff.new_page() - handoff_page.goto(base, wait_until='networkidle') - complete_frame(handoff_page) - handoff_page.locator('#rescue-open-lab').click() - handoff_page.wait_for_load_state('networkidle') - assert_handoff(handoff_page) - assert 'Saved in this browser' in handoff_page.locator('body').inner_text() - handoff.close() - - collision = browser.new_context(viewport={"width": 390, "height": 844}) - collision_page = collision.new_page() - collision_page.goto(base, wait_until='networkidle') - collision_page.evaluate(f"localStorage.setItem('{DECISION_KEY}', JSON.stringify({{sentinel:'keep-me'}})); localStorage.setItem('{RECORD_KEY}', 'keep-record');") - complete_frame(collision_page) - collision_page.locator('#rescue-open-lab').click() - assert collision_page.locator('.rescue-collision').is_visible() - assert collision_page.evaluate(f"localStorage.getItem('{DECISION_KEY}')") == '{"sentinel":"keep-me"}' - assert collision_page.evaluate(f"localStorage.getItem('{RECORD_KEY}')") == 'keep-record' - collision_page.get_by_role('button', name='Replace saved browser draft and continue').click() - collision_page.wait_for_load_state('networkidle') - assert_handoff(collision_page) - assert collision_page.evaluate(f"localStorage.getItem('{RECORD_KEY}')") is None - collision.close() - - blocked = browser.new_context(viewport={"width": 390, "height": 844}) - blocked.add_init_script("Storage.prototype.setItem = function(){ throw new Error('blocked by test'); };") - blocked_page = blocked.new_page() - blocked_page.goto(base, wait_until="networkidle") - complete_frame(blocked_page) - blocked_page.locator('#rescue-open-lab').click() - blocked_page.locator('#rescue-status').wait_for(state='visible') - expect(blocked_page.locator('#rescue-status')).to_have_text(re.compile(r'Autosave unavailable')) - assert 'Decision Frame is ready' in blocked_page.locator('#rescue-question').inner_text() - blocked.close() + assert not remote_requests, f"Universal FDE made unexpected remote requests: {remote_requests}" + context.close() 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' - toggle = theme_page.locator('#theme-toggle') - assert toggle.inner_text() in toggle.get_attribute('aria-label') - danger = theme_page.evaluate("getComputedStyle(document.documentElement).getPropertyValue('--danger').trim()") - assert danger.lower() == '#9d3030' 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' - toggle.click() - assert theme_page.locator('html').get_attribute('data-theme') == 'dark' - assert theme_page.locator('html').get_attribute('data-theme-preference') == 'dark' - toggle.click() - assert theme_page.locator('html').get_attribute('data-theme') == 'light' - assert toggle.inner_text() in toggle.get_attribute('aria-label') theme.close() finally: browser.close() @@ -203,4 +142,4 @@ def run() -> None: if __name__ == '__main__': run() - print('DECISION RESCUE E2E PASS') + print('UNIVERSAL RESPONSE E2E PASS') diff --git a/scripts/generate-project-facts.mjs b/scripts/generate-project-facts.mjs index 9dddb95..dfdea85 100644 --- a/scripts/generate-project-facts.mjs +++ b/scripts/generate-project-facts.mjs @@ -1,5 +1,5 @@ import { readdir, readFile, writeFile } from 'node:fs/promises'; -import { join, relative, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('../', import.meta.url)); @@ -27,31 +27,11 @@ function countTests(text) { return (text.match(/(?:^|\s)test\s*\(/gm) || []).length; } -async function collectRetainedReferenceArtifactItemCounts() { - const candidates = [join(root, 'site', 'data'), join(root, 'profiles', 'phenomena')]; - const counts = {}; - for (const directory of candidates) { - let files = []; - try { files = await walk(directory); } catch { continue; } - for (const path of files.filter((file) => file.endsWith('.json'))) { - let value; - try { value = await readJson(path); } catch { continue; } - const arrays = []; - if (Array.isArray(value)) arrays.push(value.length); - else if (value && typeof value === 'object') { - for (const item of Object.values(value)) if (Array.isArray(item)) arrays.push(item.length); - } - counts[relative(root, path).replaceAll('\\', '/')] = arrays.length ? Math.max(...arrays) : 1; - } - } - return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b))); -} - export async function collectProjectFacts() { const packageData = await readJson(join(root, 'package.json')); const decisionSchema = await readJson(join(root, 'schemas', 'decision.schema.json')); const semanticDecisionSchema = await readJson(join(root, 'schemas', 'decision-0.3.0.schema.json')); - const caseSchema = await readJson(join(root, 'schemas', 'case.schema.json')); + const testFiles = (await walk(join(root, 'tests'))).filter((path) => path.endsWith('.test.js')); let testCount = 0; for (const path of testFiles) testCount += countTests(await readFile(path, 'utf8')); @@ -86,13 +66,11 @@ export async function collectProjectFacts() { schemaVersions: { decision: schemaVersion(decisionSchema), semanticDecision: schemaVersion(semanticDecisionSchema), - opvCase: schemaVersion(caseSchema), }, testCount, discoveredHashRouteLiteralCount: routes.size, browserModes, manifestEntryCount, - retainedReferenceArtifactItemCounts: await collectRetainedReferenceArtifactItemCounts(), }; } diff --git a/scripts/validate_repository.py b/scripts/validate_repository.py index 715addf..ffc1e1e 100644 --- a/scripts/validate_repository.py +++ b/scripts/validate_repository.py @@ -8,104 +8,38 @@ ROOT = Path(__file__).resolve().parents[1] SITE = ROOT / "site" -errors: list[str] = [] - - -def require(relative: str) -> None: - if not (ROOT / relative).exists(): - errors.append(f"missing required path: {relative}") def load_json(relative: str): - try: - return json.loads((ROOT / relative).read_text(encoding="utf-8")) - except Exception as exc: - errors.append(f"invalid JSON {relative}: {exc}") - return {} + return json.loads((ROOT / relative).read_text(encoding="utf-8")) + +package = load_json("package.json") +version = package["version"] required = [ - "README.md", "LICENSE", "SECURITY.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "CITATION.cff", - "docs/ARCHITECTURE.md", "docs/METHODOLOGY.md", "docs/PRIVACY.md", "docs/RELEASE_NOTES.md", "docs/RELEASING.md", - "schemas/decision.schema.json", "schemas/decision-0.3.0.schema.json", - "schemas/asset.schema.json", "schemas/calibration.schema.json", "schemas/case.schema.json", - "schemas/dataset.schema.json", "schemas/measurement.schema.json", "schemas/profile.schema.json", - "site/data/experiences.json", "site/data/morphology.json", "site/data/references.json", - "profiles/phenomena/profile.json", "examples/phenomena-second-station/decision.fde.json", - "docs/DATA_DICTIONARY.md", "docs/STYLE_LAYERS.md", + "CITATION.cff", "CONTRIBUTING.md", "LICENSE", "README.md", "SECURITY.md", + "docs/ARCHITECTURE.md", "docs/METHODOLOGY.md", "docs/DATA_DICTIONARY.md", "docs/PRIVACY.md", + "docs/RELEASE_NOTES.md", "docs/RELEASING.md", "docs/STYLE_LAYERS.md", "site/schemas/decision.schema.json", "site/schemas/decision-0.3.0.schema.json", "site/index.html", "site/404.html", - "site/assets/styles.css", "site/assets/bridge-node-7-shell.css", "site/assets/beginner-first.css", "site/assets/luxury-rescue.css", - "site/src/app.js", "site/src/decision-ui.js", "site/src/rescue-ui.js", "site/src/theme.js", + "site/assets/styles.css", "site/assets/bridge-node-7-shell.css", "site/assets/beginner-first.css", "site/assets/luxury-rescue.css", "site/assets/universal-decision.css", + "site/src/app.js", "site/src/decision-ui.js", "site/src/rescue-ui.js", "site/src/universal-ui.js", "site/src/theme.js", "site/src/lib/case.js", "site/src/lib/decision-core.js", "site/src/lib/decision.js", "site/src/lib/intake.js", "site/src/lib/persistence.js", "site/src/lib/recording.js", "site/src/lib/semantics.js", "site/src/lib/synthesis.js", "scripts/browser_e2e.py", "scripts/browser_rescue_e2e.py", "scripts/browser_closeout_regressions.py", "scripts/validate_version_integrity.py", "scripts/package_release.py", "scripts/verify_release_tag.py", + "tests/universal-response.test.js", "requirements-dev.txt", ".github/workflows/ci.yml", ".github/workflows/pages.yml", ".github/workflows/release.yml", ] +errors: list[str] = [] for item in required: - require(item) + if not (ROOT / item).exists(): + errors.append(f"required public/release path missing: {item}") -package = load_json("package.json") -package_lock = load_json("package-lock.json") -facts = load_json("project-facts.json") -example = load_json("examples/phenomena-second-station/decision.fde.json") -decision_schema = load_json("schemas/decision.schema.json") -semantic_schema = load_json("schemas/decision-0.3.0.schema.json") - -if package.get("name") != "frontier-decision-engine": - errors.append("package name mismatch") -version = str(package.get("version", "")) -if not re.fullmatch(r"\d+\.\d+\.\d+", version): - errors.append("package version is not semantic") -if str(package_lock.get("version", "")) != version: - errors.append("package-lock top-level version mismatch") -if str(package_lock.get("packages", {}).get("", {}).get("version", "")) != version: - errors.append("package-lock root package version mismatch") -if str(facts.get("applicationVersion", "")) != version: - errors.append("project-facts application version mismatch") - -schema_versions = { - "decision": decision_schema.get("properties", {}).get("schema_version", {}).get("const"), - "semanticDecision": semantic_schema.get("properties", {}).get("schema_version", {}).get("const"), -} -if schema_versions != {"decision": "0.2.10", "semanticDecision": "0.3.0"}: - errors.append("decision schema versions changed unexpectedly") -facts_schema_versions = facts.get("schemaVersions", {}) -if facts_schema_versions.get("decision") != schema_versions["decision"] or facts_schema_versions.get("semanticDecision") != schema_versions["semanticDecision"]: - errors.append("project-facts decision schema versions mismatch") -if example.get("schema_version") != schema_versions["decision"]: - errors.append("reference decision example schema mismatch") -if example.get("provenance", {}).get("probability_model_used") is not False: - errors.append("reference decision must disclose no probability model") -if example.get("provenance", {}).get("values_are_analyst_assigned") is not True: - errors.append("reference decision must disclose analyst-assigned values") -if not str(example.get("human_decision", {}).get("rationale", "")).strip(): - errors.append("reference decision lacks human rationale") -if not str(example.get("human_decision", {}).get("next_action", "")).strip(): - errors.append("reference decision lacks a next action") - -for schema in (ROOT / "schemas").glob("*.json"): - published = SITE / "schemas" / schema.name - if not published.exists() or published.read_bytes() != schema.read_bytes(): - errors.append(f"published schema drift: {schema.name}") - -release_notes = ROOT / "docs" / "RELEASE_NOTES.md" -if not release_notes.is_file() or not re.search( - rf"(?m)^#\s+v{re.escape(version)}\s*$", release_notes.read_text(encoding="utf-8") if release_notes.exists() else "" -): - errors.append("current release notes version mismatch") - -if any(path.suffix.lower() in {".xlsx", ".xls"} for path in ROOT.rglob("*")): - errors.append("source spreadsheet detected in public repository") -if any("__pycache__" in path.parts or path.suffix.lower() in {".pyc", ".pyo"} for path in ROOT.rglob("*")): - errors.append("compiled Python cache detected") -if (ROOT / "hosted-verification").exists(): - errors.append("hosted verification downloads detected in source tree") - -allowed_root_markdown = {"README.md", "SECURITY.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md"} -root_markdown = {path.name for path in ROOT.glob("*.md")} -if root_markdown != allowed_root_markdown: +root_markdown = {str(path.relative_to(ROOT)).replace("\\", "/") for path in ROOT.glob("*.md")} +expected_root_markdown = {"CODE_OF_CONDUCT.md", "CONTRIBUTING.md", "README.md", "SECURITY.md"} +if root_markdown != expected_root_markdown: errors.append("root Markdown surface mismatch: " + ", ".join(sorted(root_markdown))) allowed_docs_markdown = { @@ -120,11 +54,10 @@ def load_json(relative: str): if len(readme.splitlines()) > 100: errors.append("README exceeds 100 lines") readme_normalized = re.sub(r"\s+", " ", readme) -for required_text in ["project-facts.json", "browser-local", "no backend"]: +for required_text in ["project-facts.json", "browser-local", "no backend", "Decision Map"]: if required_text.lower() not in readme_normalized.lower(): errors.append(f"README missing required boundary: {required_text}") -# Generic public-surface OPSEC checks avoid embedding confidential source phrases in public code. scan_roots = [ROOT / "README.md", ROOT / "docs", ROOT / "examples", ROOT / "site" / "src"] path_patterns = [ re.compile(r"[A-Za-z]:\\\\Users\\\\[^\\\s]+", re.I), @@ -149,6 +82,8 @@ def load_json(relative: str): errors.append("site index lacks Content-Security-Policy") if re.search(r"]+src=[\"']https?://", index, re.I): errors.append("site index loads an external script") +if "./assets/universal-decision.css" not in index: + errors.append("site index does not load the Universal Decision Map stylesheet") for path in (SITE / "src").rglob("*.js"): try: @@ -164,6 +99,7 @@ def load_json(relative: str): app_text = (SITE / "src/app.js").read_text(encoding="utf-8") decision_text = (SITE / "src/decision-ui.js").read_text(encoding="utf-8") rescue_text = (SITE / "src/rescue-ui.js").read_text(encoding="utf-8") +universal_text = (SITE / "src/universal-ui.js").read_text(encoding="utf-8") if len(re.findall(r'id="decision-step-heading-[0-5]" tabindex="-1"', decision_text)) != 7: errors.append("FDE must expose six focusable decision-stage headings and one incomplete-analysis variant") if "rescue-intake" not in rescue_text or "Decision Frame" not in rescue_text: @@ -172,16 +108,21 @@ def load_json(relative: str): errors.append("Decision Rescue session recovery is missing") if "A decision is already saved in this browser." not in rescue_text: errors.append("Decision Rescue saved-work collision boundary is missing") +if "renderUniversalDecisionExperience" not in app_text: + errors.append("Universal Response is not the public root experience") +for token in ("draftFromInput", "responseFor", "What FDE sees so far", "Possible is not confirmed", "Decision Map"): + if token not in universal_text: + errors.append(f"Universal Decision Map is missing required boundary: {token}") runner = (ROOT / "scripts/browser_e2e.py").read_text(encoding="utf-8") -rescue_runner = (ROOT / "scripts/browser_rescue_e2e.py").read_text(encoding="utf-8") +universal_runner = (ROOT / "scripts/browser_rescue_e2e.py").read_text(encoding="utf-8") requirements = (ROOT / "requirements-dev.txt").read_text(encoding="utf-8") for required_flow in ("decision_flow", "route_suite", "print_flow"): if required_flow not in runner: errors.append(f"browser end-to-end flow is not wired: {required_flow}") for required_rescue_check in ("sessionStorage", "rescue-collision", "color_scheme"): - if required_rescue_check not in rescue_runner: - errors.append(f"Decision Rescue browser regression missing: {required_rescue_check}") + if required_rescue_check not in universal_runner: + errors.append(f"Universal/Decision Rescue browser regression missing: {required_rescue_check}") if "playwright==1.57.0" not in requirements: errors.append("expected browser tool pin is missing") @@ -191,12 +132,15 @@ def load_json(relative: str): shell = (SITE / "assets/bridge-node-7-shell.css").read_text(encoding="utf-8") rescue_css = (SITE / "assets/luxury-rescue.css").read_text(encoding="utf-8") +universal_css = (SITE / "assets/universal-decision.css").read_text(encoding="utf-8") if "--line-strong:" not in shell: errors.append("interactive boundary token is missing") if "--focus-ring:" not in rescue_css: errors.append("theme-aware focus token is missing") if ".rescue-frame{position:static;order:-1}" in rescue_css.replace(" ", ""): errors.append("mobile Rescue frame still precedes the active question") +if "--surface-glow:" not in universal_css or ".universal-layout" not in universal_css: + errors.append("Universal Decision Map stylesheet is incomplete") if "Bridge Node 7 Home" not in index: errors.append("explicit Bridge Node 7 Home path is missing") @@ -205,7 +149,7 @@ def load_json(relative: str): errors.append("branded FDE 404 contract is incomplete") expected_stylesheet_order = [ - "./assets/styles.css", "./assets/bridge-node-7-shell.css", "./assets/beginner-first.css", "./assets/luxury-rescue.css", + "./assets/styles.css", "./assets/bridge-node-7-shell.css", "./assets/beginner-first.css", "./assets/luxury-rescue.css", "./assets/universal-decision.css", ] positions = [index.find(item) for item in expected_stylesheet_order] if any(position < 0 for position in positions) or positions != sorted(positions): @@ -216,13 +160,12 @@ def load_json(relative: str): errors.append("contribution guide title mismatch") if errors: - print("REPOSITORY VALIDATION FAILED") for error in errors: - print(f"- {error}") + print(f"ERROR: {error}") sys.exit(1) print("REPOSITORY VALIDATION PASS") -print("- public product surface and compatibility artifacts are bounded by repository validation") +print("- Universal Response and Decision Map are part of the public product boundary") print("- Decision Rescue and Decision Lab preserve human authority and browser-local boundaries") print("- public decision schemas, release identity, accessibility controls, and OPSEC 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 new file mode 100644 index 0000000..f8f3810 --- /dev/null +++ b/site/assets/universal-decision.css @@ -0,0 +1,39 @@ +:root{--surface-glow:rgba(117,214,255,.08);--surface-border:rgba(117,214,255,.18)} +.universal-hero{width:100%;max-width:1240px;margin:clamp(2rem,5vw,4.5rem) auto 1.65rem;text-align:left} +.universal-hero h1{margin:.5rem 0 .75rem;font-size:clamp(2.55rem,4.2vw,5rem);line-height:1;letter-spacing:-.055em;max-width:none;text-wrap:balance} +.universal-hero-product{display:inline-block;vertical-align:.14em;margin-left:.4rem;padding:.22em .5em;border:1px solid var(--line-strong);border-radius:999px;font-size:.16em;font-weight:850;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;color:var(--muted);background:rgba(255,255,255,.035)} +.universal-subtitle{max-width:840px;margin:0;color:var(--muted);font-size:clamp(1rem,1.45vw,1.18rem);line-height:1.55} +.universal-layout{width:100%;max-width:1240px;display:grid;grid-template-columns:minmax(0,1.16fr) minmax(360px,.84fr);gap:1.6rem;align-items:start;margin:0 auto 5rem} +.universal-entry,.universal-surface{border:1px solid var(--line-strong);background:var(--surface);box-shadow:0 24px 70px rgba(0,0,0,.16);border-radius:24px} +.universal-entry{padding:1.4rem} +.universal-response{padding:1rem 1rem 1.05rem;border:1px solid var(--surface-border);background:var(--surface-glow);border-radius:18px;margin-bottom:1rem} +.universal-response-kicker,.universal-card-label{display:block;font-size:.78rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:.4rem} +.universal-response h2{margin:.1rem 0 .55rem;font-size:clamp(1.5rem,2.45vw,2rem);line-height:1.08} +.universal-response p{margin:0;color:var(--muted);line-height:1.55} +.universal-entry>label{display:block;font-weight:800;margin:.5rem 0 .55rem} +#universal-input{width:100%;min-height:170px;resize:vertical;border-radius:18px;font:inherit;line-height:1.55} +.universal-entry .help{margin:.6rem 0 1rem} +.universal-actions{display:flex;gap:.65rem;flex-wrap:wrap;align-items:center} +.universal-actions .primary{min-height:48px} +.universal-return{margin:.8rem 0 0}.universal-return a{font-weight:800;color:var(--text)} +.universal-status{min-height:1.4rem;margin-top:.65rem;color:var(--muted)} +.universal-surface{position:sticky;top:1rem;overflow:hidden} +.universal-surface-head{display:flex;justify-content:space-between;gap:1rem;align-items:start;padding:1.2rem 1.25rem;border-bottom:1px solid var(--line)} +.universal-surface-head h2{margin:.15rem 0 0;font-size:1.45rem} +.universal-badge{font-size:.74rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;padding:.35rem .55rem;border:1px solid var(--line-strong);border-radius:999px;color:var(--muted)} +.universal-card{padding:.95rem 1.25rem;border-bottom:1px solid var(--line)} +.universal-list{list-style:none;margin:0;padding:0;display:grid;gap:.5rem} +.universal-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:.6rem;align-items:center} +.surface-value{overflow-wrap:anywhere;min-width:0} +.surface-state{font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);white-space:nowrap} +.universal-remove{min-height:32px;padding:.35rem .55rem;border:1px solid var(--line-strong);border-radius:999px;background:transparent;color:var(--muted);font:inherit;font-size:.76rem;font-weight:750} +.universal-remove:hover{color:var(--text);background:rgba(255,255,255,.05)} +.universal-empty{margin:0;color:var(--muted)} +.universal-next{margin:1rem;border:1px solid var(--line-strong);border-radius:18px;padding:1rem;background:var(--surface-glow)} +.universal-next p{margin:.35rem 0 1rem;color:var(--muted);line-height:1.55} +.universal-confirm-row{display:flex;gap:.55rem;flex-wrap:wrap} +.universal-confirm-row button,.universal-confirm-row .button{min-height:44px} +.universal-truth{padding:0 1.25rem 1.25rem;margin:0;color:var(--muted);font-size:.82rem;line-height:1.5} +@media(max-width:900px){.universal-layout{grid-template-columns:1fr;gap:1rem}.universal-surface{position:static;order:2}.universal-entry{order:1}.universal-hero{margin-top:2.25rem}.universal-hero h1{max-width:900px}} +@media(max-width:620px){.universal-entry,.universal-surface{border-radius:18px}.universal-actions,.universal-confirm-row{align-items:stretch;flex-direction:column}.universal-actions button,.universal-confirm-row button,.universal-confirm-row .button{width:100%}.universal-list li{grid-template-columns:minmax(0,1fr) auto}.universal-list .universal-remove{grid-column:2;grid-row:1}.universal-list .surface-state{grid-column:1;grid-row:2;justify-self:start}.universal-hero{margin-top:1.75rem}.universal-hero h1{font-size:clamp(2.35rem,11vw,3.5rem);line-height:1.04}.universal-hero-product{display:block;width:max-content;margin:.7rem 0 0;font-size:.62rem;padding:.35rem .6rem}.universal-subtitle{font-size:1rem}} +@media(forced-colors:active){.universal-entry,.universal-surface,.universal-response,.universal-next{box-shadow:none;border:1px solid CanvasText}.universal-badge,.universal-remove{border-color:CanvasText}} diff --git a/site/index.html b/site/index.html index e53e603..018260b 100644 --- a/site/index.html +++ b/site/index.html @@ -3,11 +3,11 @@ - + - + @@ -21,6 +21,7 @@ + @@ -43,7 +44,7 @@