diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..2752eb9
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+.DS_Store
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..61e179b
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,92 @@
+# Confluence Research - Web UI
+
+Minimalist, secure Web UI for Confluence Research, designed to operate against the backend API contracts specified in `docs/SPECIFICATION.md` and `docs/implementation/CONTRACTS.md`.
+
+## Directory Layout
+
+```text
+frontend/
+├── index.html # Main HTML entrypoint (clean white minimalist theme)
+├── css/
+│ └── style.css # Responsive styling, accessible components, gear animation
+├── js/
+│ ├── app.js # State transitions, keyboard handling, memory credentials, staleness guards
+│ ├── api.js # Relative /api/v1/... fetch boundary with UTF-8 byte validation
+│ ├── render.js # marked.js + DOMPurify, fail-safe render, bounded sectioning
+│ └── history.js # Sources, lazy bounded history serialization, artifacts listing
+├── vendor/ # Pinned vendor libraries & licenses (locally served)
+│ ├── marked.min.js
+│ ├── marked.LICENSE
+│ ├── purify.min.js
+│ └── dompurify.LICENSE
+├── dev/
+│ ├── mock-server.js # Zero-dependency same-origin mock server & scenario runner
+│ ├── scenario-toolbar.js # External dev toolbar script (CSP compliant, no inline scripts)
+│ └── scenario-toolbar.css # External dev toolbar styling (CSP compliant, no inline styles)
+├── tests/
+│ ├── contract.test.js # Wire format, status code, header, & scenario tests (14 tests)
+│ ├── api.test.js # UTF-8 byte boundary and credential validation tests (6 tests)
+│ ├── render.test.js # Markdown section partitioning and fallback tests (10 tests)
+│ └── e2e_runner.js # End-to-end browser test runner connecting to Chrome (9444) via CDP (16 tests)
+├── package.json
+├── package-lock.json
+├── .gitignore
+├── README.md
+└── HANDOFF.md
+```
+
+## Security & Architecture Highlights
+
+1. **In-Memory Credentials**:
+ - Confluence Base URL and Personal Access Token (PAT) reside strictly in browser JavaScript memory.
+ - Never written to `localStorage`, `sessionStorage`, cookies, query parameters, console logs, or exported files.
+ - A `cw_session` HttpOnly cookie is set by the origin for artifact download ownership.
+2. **Content Security Policy (CSP)**:
+ - `default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; media-src 'none'; font-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`
+ - Completely prevents automatic third-party network requests, tracking pixels, and unauthorized script injection.
+ - Verified via browser network tracing (zero automatic external requests).
+3. **Markdown Sanitization & Link Safety**:
+ - Restricted element allowlist using locally vendored DOMPurify.
+ - Fail-safe rendering: if parser or sanitizer are absent or fail, displays a safe notice without ever injecting raw untrusted HTML.
+ - All links rewritten to require explicit user clicks with `target="_blank"` and `rel="noopener noreferrer"`.
+ - Disallowed protocols (`javascript:`, `data:`, `file:`) have `href` stripped.
+4. **Large Result Handling & Memory Bounding**:
+ - Large answers partitioned into bounded sections (~48 KiB soft target, ~64 KiB hard cap) rendered on demand.
+ - Giant code fences (e.g. 12 MB) are safely split and re-opened so every section is a valid Markdown code block.
+ - Tables preserve row boundaries and repeat column headers across sections.
+ - Pathological blocks fall back to a bounded plain-text preview with full export available.
+ - "Export to MD" always exports the complete, untouched raw Markdown client-side via Blob.
+ - Tool call results in history are rendered lazily with bounded serialization buffers (`serializeBounded`).
+
+## Development & Testing
+
+### Running the Dev Mock Server
+
+The mock server runs entirely with Node.js built-ins (zero dependencies) on loopback:
+
+```bash
+cd frontend
+npm run dev
+# Or custom port:
+node dev/mock-server.js --port 5173
+```
+
+Open `http://127.0.0.1:5173/` in your browser. A floating dev toolbar in the bottom-right corner allows toggling between all 13 deterministic mock scenarios (e.g. normal shared example, 403 verify, 409 busy, 504 timeout, malicious content, large output, delayed cancellation).
+
+### Running Unit & Contract Tests
+
+Tests verify API limits, wire contracts, headers, cookies, and markdown partitioning (30 tests):
+
+```bash
+cd frontend
+npm test
+```
+
+### Running E2E Browser Tests
+
+Runs comprehensive browser tests against Chrome on port 9444 via CDP (16 tests):
+
+```bash
+cd frontend
+npm run test:e2e
+```
diff --git a/frontend/assets/book.gif b/frontend/assets/book.gif
new file mode 100644
index 0000000..40ae435
Binary files /dev/null and b/frontend/assets/book.gif differ
diff --git a/frontend/assets/key.svg b/frontend/assets/key.svg
new file mode 100644
index 0000000..23a71c2
--- /dev/null
+++ b/frontend/assets/key.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/css/style.css b/frontend/css/style.css
new file mode 100644
index 0000000..1445e67
--- /dev/null
+++ b/frontend/css/style.css
@@ -0,0 +1,1085 @@
+/* Confluence Research Web UI - Clean White Minimalist Theme */
+:root {
+ --color-bg-primary: #FFFFFF;
+ --color-bg-secondary: #FAFAFA;
+ --color-border: #E5E7EB;
+ --color-border-hover: #D1D5DB;
+ --color-text-primary: #111827;
+ --color-text-secondary: #6B7280;
+ --color-accent: #2563EB;
+ --color-accent-hover: #1D4ED8;
+ --color-accent-light: #EFF6FF;
+ --color-error: #DC2626;
+ --color-error-bg: #FEF2F2;
+ --color-error-border: #FCA5A5;
+ --color-success: #16A34A;
+ --color-success-bg: #F0FDF4;
+ --color-success-border: #86EFAC;
+ --color-warning: #D97706;
+ --color-warning-bg: #FFFBEB;
+ --color-warning-border: #FCD34D;
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
+ --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ --max-width: 860px;
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+}
+
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: var(--font-sans);
+ font-size: 15px;
+ line-height: 1.6;
+ color: var(--color-text-primary);
+ background-color: var(--color-bg-primary);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Accessibility */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+
+.hidden {
+ display: none !important;
+}
+
+/* Header */
+.app-header {
+ position: relative;
+ width: 100%;
+ max-width: var(--max-width);
+ margin: 0 auto;
+ padding: 16px 24px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.app-brand {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ font-size: 17px;
+ color: var(--color-text-primary);
+ letter-spacing: -0.01em;
+}
+
+.key-btn {
+ position: relative;
+ background: transparent;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ width: 38px;
+ height: 38px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: var(--color-text-secondary);
+ transition: all 0.15s ease-in-out;
+}
+
+.key-btn:hover {
+ background-color: var(--color-bg-secondary);
+ border-color: var(--color-border-hover);
+ color: var(--color-text-primary);
+}
+
+.cred-indicator {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background-color: #9CA3AF; /* Gray outline/unset state */
+ transition: background-color 0.2s ease;
+}
+
+.cred-indicator.active {
+ background-color: var(--color-success); /* Green dot when active */
+ box-shadow: 0 0 0 1.5px var(--color-bg-primary);
+}
+
+/* App Main Container */
+.app-main {
+ flex: 1;
+ width: 100%;
+ max-width: var(--max-width);
+ margin: 0 auto;
+ padding: 0 24px 48px 24px;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Alert Boxes */
+.alert-box {
+ width: 100%;
+ padding: 12px 16px;
+ border-radius: var(--radius-md);
+ font-size: 14px;
+ margin-bottom: 16px;
+}
+
+.error-box {
+ background-color: var(--color-error-bg);
+ border: 1px solid var(--color-error-border);
+ color: var(--color-error);
+}
+
+.warning-box {
+ background-color: var(--color-warning-bg);
+ border: 1px solid var(--color-warning-border);
+ color: var(--color-warning);
+}
+
+.success-box {
+ background-color: var(--color-success-bg);
+ border: 1px solid var(--color-success-border);
+ color: var(--color-success);
+}
+
+/* State 1: Prompt View */
+.view-prompt {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ min-height: calc(80vh - 120px);
+ padding: 24px 0;
+}
+
+.prompt-wrapper {
+ width: 100%;
+ max-width: 680px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.prompt-title {
+ font-size: 28px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin-bottom: 24px;
+ text-align: center;
+ color: var(--color-text-primary);
+}
+
+.prompt-box {
+ width: 100%;
+ position: relative;
+ background-color: var(--color-bg-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 6px -1px rgba(0, 0, 0, 0.03);
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+ padding: 14px 16px 10px 16px;
+}
+
+.prompt-box:focus-within {
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
+}
+
+.prompt-input {
+ width: 100%;
+ border: none;
+ background: transparent;
+ outline: none;
+ font-family: var(--font-sans);
+ font-size: 16px;
+ line-height: 1.5;
+ color: var(--color-text-primary);
+ resize: none;
+ min-height: 72px;
+ max-height: 280px;
+ overflow-y: auto;
+ display: block;
+}
+
+.prompt-input::placeholder {
+ color: #9CA3AF;
+}
+
+.prompt-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 10px;
+ padding-top: 8px;
+ border-top: 1px solid #F3F4F6;
+}
+
+.prompt-helper {
+ font-size: 12px;
+ color: var(--color-text-secondary);
+ user-select: none;
+}
+
+.submit-btn {
+ background-color: var(--color-accent);
+ color: #FFFFFF;
+ border: none;
+ border-radius: var(--radius-md);
+ padding: 6px 14px;
+ font-size: 14px;
+ font-weight: 500;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+}
+
+.submit-btn:hover:not(:disabled) {
+ background-color: var(--color-accent-hover);
+}
+
+.submit-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* State 3: Loading View */
+.view-loading {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ min-height: calc(80vh - 120px);
+ text-align: center;
+}
+
+.spinner-wrapper {
+ margin-bottom: 20px;
+}
+
+.gear-spinner {
+ width: 48px;
+ height: 48px;
+ color: var(--color-accent);
+ stroke-width: 2px;
+ animation: spin 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .gear-spinner {
+ animation: none;
+ }
+}
+
+.loading-status {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+ margin-bottom: 24px;
+}
+
+.cancel-btn {
+ background: transparent;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 8px 18px;
+ font-size: 14px;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.cancel-btn:hover {
+ background-color: var(--color-bg-secondary);
+ color: var(--color-text-primary);
+ border-color: var(--color-border-hover);
+}
+
+/* State 4: Result View */
+.view-result {
+ width: 100%;
+}
+
+.action-bar {
+ position: sticky;
+ top: 0;
+ background-color: var(--color-bg-primary);
+ border-bottom: 1px solid var(--color-border);
+ padding: 12px 0;
+ margin-bottom: 24px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ z-index: 20;
+}
+
+.action-btn {
+ background: transparent;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 7px 14px;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ transition: all 0.15s ease;
+}
+
+.action-btn:hover {
+ background-color: var(--color-bg-secondary);
+ border-color: var(--color-border-hover);
+}
+
+.export-btn {
+ background-color: var(--color-bg-secondary);
+}
+
+.export-btn:hover {
+ background-color: #F3F4F6;
+}
+
+/* Rendered Markdown Output */
+.output-content {
+ line-height: 1.7;
+ color: var(--color-text-primary);
+ word-break: break-word;
+ margin-bottom: 32px;
+}
+
+.output-content h1,
+.output-content h2,
+.output-content h3,
+.output-content h4,
+.output-content h5,
+.output-content h6 {
+ margin-top: 28px;
+ margin-bottom: 12px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--color-text-primary);
+}
+
+.output-content h1 { font-size: 24px; border-bottom: 1px solid var(--color-border); padding-bottom: 8px; }
+.output-content h2 { font-size: 20px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; }
+.output-content h3 { font-size: 17px; }
+.output-content h4 { font-size: 15px; }
+
+.output-content p {
+ margin-bottom: 16px;
+}
+
+.output-content ul,
+.output-content ol {
+ margin-bottom: 16px;
+ padding-left: 24px;
+}
+
+.output-content li {
+ margin-bottom: 6px;
+}
+
+.output-content blockquote {
+ border-left: 3px solid var(--color-border-hover);
+ padding-left: 16px;
+ margin: 16px 0;
+ color: var(--color-text-secondary);
+ font-style: italic;
+}
+
+.output-content hr {
+ border: 0;
+ border-top: 1px solid var(--color-border);
+ margin: 24px 0;
+}
+
+.output-content a {
+ color: var(--color-accent);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.output-content a:hover {
+ color: var(--color-accent-hover);
+}
+
+.output-content code {
+ font-family: var(--font-mono);
+ font-size: 13.5px;
+ background-color: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ padding: 2px 5px;
+ border-radius: var(--radius-sm);
+}
+
+.output-content pre {
+ font-family: var(--font-mono);
+ font-size: 13.5px;
+ background-color: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ padding: 14px 16px;
+ border-radius: var(--radius-md);
+ overflow-x: auto;
+ margin-bottom: 18px;
+}
+
+.output-content pre code {
+ background: transparent;
+ border: none;
+ padding: 0;
+}
+
+.output-content table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 18px 0;
+ font-size: 14px;
+}
+
+.output-content th,
+.output-content td {
+ border: 1px solid var(--color-border);
+ padding: 8px 12px;
+ text-align: left;
+}
+
+.output-content th {
+ background-color: var(--color-bg-secondary);
+ font-weight: 600;
+}
+
+.output-content tr:nth-child(even) td {
+ background-color: #FCFCFC;
+}
+
+/* Pathological Fallback & Bounded Section Controls */
+.pathological-banner,
+.section-nav-banner {
+ background-color: var(--color-warning-bg);
+ border: 1px solid var(--color-warning-border);
+ border-radius: var(--radius-md);
+ padding: 10px 14px;
+ font-size: 13px;
+ color: var(--color-warning);
+ margin-bottom: 16px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+}
+
+.section-nav-controls {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.section-btn {
+ background: #FFFFFF;
+ border: 1px solid var(--color-warning-border);
+ border-radius: var(--radius-sm);
+ padding: 4px 10px;
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.section-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.pathological-preview {
+ background-color: var(--color-bg-subtle);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 12px;
+ font-family: var(--font-mono);
+ font-size: 13px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 400px;
+ overflow-y: auto;
+ margin-bottom: 16px;
+}
+
+.render-error-notice {
+ background-color: var(--color-error-bg);
+ border: 1px solid var(--color-error-border);
+ border-radius: var(--radius-md);
+ padding: 12px 16px;
+ font-size: 14px;
+ color: var(--color-error);
+ margin-bottom: 16px;
+}
+
+/* Warnings Area */
+.warnings-container {
+ margin-bottom: 20px;
+}
+
+.warning-item {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ padding: 8px 12px;
+ border-radius: var(--radius-sm);
+ background-color: var(--color-warning-bg);
+ border: 1px solid var(--color-warning-border);
+ font-size: 13px;
+ color: var(--color-warning);
+ margin-bottom: 6px;
+}
+
+.warning-code {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ font-size: 12px;
+}
+
+/* Artifacts Section */
+.artifacts-section {
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 16px 20px;
+ margin-bottom: 24px;
+ background-color: var(--color-bg-secondary);
+}
+
+.artifacts-heading {
+ font-size: 15px;
+ font-weight: 600;
+ margin-bottom: 12px;
+ color: var(--color-text-primary);
+}
+
+.artifacts-list {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.artifact-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background-color: #FFFFFF;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 10px 14px;
+}
+
+.artifact-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.artifact-name {
+ font-weight: 500;
+ font-size: 14px;
+ color: var(--color-text-primary);
+ font-family: var(--font-mono);
+}
+
+.artifact-meta {
+ font-size: 12px;
+ color: var(--color-text-secondary);
+}
+
+.download-btn {
+ background-color: #FFFFFF;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 6px 12px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.download-btn:hover {
+ background-color: var(--color-bg-secondary);
+ border-color: var(--color-border-hover);
+}
+
+/* Sources & Request History */
+.history-section {
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ margin-top: 24px;
+ overflow: hidden;
+ background-color: #FFFFFF;
+}
+
+.history-toggle-btn {
+ width: 100%;
+ padding: 14px 18px;
+ background: transparent;
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--color-text-primary);
+ text-align: left;
+ transition: background-color 0.15s ease;
+}
+
+.history-toggle-btn:hover {
+ background-color: var(--color-bg-secondary);
+}
+
+.toggle-icon {
+ font-size: 12px;
+ color: var(--color-text-secondary);
+ transition: transform 0.2s ease;
+}
+
+.history-toggle-btn[aria-expanded="true"] .toggle-icon {
+ transform: rotate(90deg);
+}
+
+.history-content {
+ padding: 0 18px 18px 18px;
+ border-top: 1px solid var(--color-border);
+}
+
+.history-subheading {
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-secondary);
+ margin: 16px 0 10px 0;
+}
+
+/* Pages Accessed Cards */
+.pages-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.page-card {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 14px;
+ background-color: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+}
+
+.page-card-left {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ overflow: hidden;
+}
+
+.badge-space {
+ font-size: 11px;
+ font-weight: 600;
+ background-color: var(--color-accent-light);
+ color: var(--color-accent);
+ border: 1px solid #BFDBFE;
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
+}
+
+.page-title-link {
+ font-weight: 500;
+ font-size: 14px;
+ color: var(--color-text-primary);
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.page-title-link:hover {
+ text-decoration: underline;
+ color: var(--color-accent);
+}
+
+.page-card-meta {
+ font-size: 12px;
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+}
+
+/* Tool History Cards */
+.tool-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.tool-card {
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background-color: #FFFFFF;
+ overflow: hidden;
+}
+
+.tool-card-header {
+ padding: 10px 14px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background-color: var(--color-bg-secondary);
+ border-bottom: 1px solid var(--color-border);
+ font-size: 13px;
+}
+
+.tool-card-header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.tool-id {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ font-size: 12px;
+ color: var(--color-text-secondary);
+}
+
+.tool-name {
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.tool-badges {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.badge {
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ font-weight: 500;
+}
+
+.badge-success {
+ background-color: var(--color-success-bg);
+ color: var(--color-success);
+ border: 1px solid var(--color-success-border);
+}
+
+.badge-error {
+ background-color: var(--color-error-bg);
+ color: var(--color-error);
+ border: 1px solid var(--color-error-border);
+}
+
+.badge-cache {
+ background-color: #F3F4F6;
+ color: #4B5563;
+ border: 1px solid #D1D5DB;
+}
+
+.badge-truncated {
+ background-color: var(--color-warning-bg);
+ color: var(--color-warning);
+ border: 1px solid var(--color-warning-border);
+}
+
+.tool-card-body {
+ padding: 10px 14px;
+ font-size: 13px;
+}
+
+.tool-meta-row {
+ display: flex;
+ gap: 16px;
+ color: var(--color-text-secondary);
+ font-size: 12px;
+ margin-bottom: 8px;
+}
+
+.tool-expand-btn {
+ background: transparent;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ padding: 3px 8px;
+ font-size: 12px;
+ cursor: pointer;
+ color: var(--color-text-secondary);
+}
+
+.tool-expand-btn:hover {
+ background-color: var(--color-bg-secondary);
+ color: var(--color-text-primary);
+}
+
+.tool-result-box {
+ margin-top: 8px;
+ background-color: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ padding: 10px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ max-height: 240px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+/* Modal View */
+.modal-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background-color: rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(4px);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 100;
+}
+
+.modal-dialog {
+ background-color: #FFFFFF;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
+ width: 90%;
+ max-width: 480px;
+ padding: 24px;
+ position: relative;
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.modal-title {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.modal-close-btn {
+ background: transparent;
+ border: none;
+ font-size: 20px;
+ line-height: 1;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ padding: 4px;
+}
+
+.modal-close-btn:hover {
+ color: var(--color-text-primary);
+}
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-label {
+ display: block;
+ font-size: 13px;
+ font-weight: 500;
+ margin-bottom: 6px;
+ color: var(--color-text-primary);
+}
+
+.modal-input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ font-size: 14px;
+ font-family: var(--font-sans);
+ outline: none;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.modal-input:focus {
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
+}
+
+.password-input-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.toggle-password-btn {
+ position: absolute;
+ right: 8px;
+ background: transparent;
+ border: none;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.toggle-password-btn:hover {
+ color: var(--color-text-primary);
+}
+
+.field-hint {
+ display: block;
+ font-size: 11px;
+ color: var(--color-text-secondary);
+ margin-top: 4px;
+}
+
+.modal-feedback {
+ margin-bottom: 16px;
+ padding: 8px 12px;
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 24px;
+ padding-top: 16px;
+ border-top: 1px solid var(--color-border);
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.modal-actions-right {
+ display: flex;
+ gap: 8px;
+}
+
+.btn-primary {
+ background-color: var(--color-accent);
+ color: #FFFFFF;
+ border: none;
+ border-radius: var(--radius-md);
+ padding: 8px 14px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+}
+
+.btn-primary:hover {
+ background-color: var(--color-accent-hover);
+}
+
+.btn-secondary {
+ background-color: transparent;
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 8px 14px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.btn-secondary:hover {
+ background-color: var(--color-bg-secondary);
+ border-color: var(--color-border-hover);
+}
+
+.btn-secondary.danger {
+ color: var(--color-error);
+ border-color: var(--color-error-border);
+}
+
+.btn-secondary.danger:hover {
+ background-color: var(--color-error-bg);
+}
+
+/* Mobile responsive adjustments */
+@media (max-width: 640px) {
+ .app-main {
+ padding: 0 16px 32px 16px;
+ }
+ .prompt-title {
+ font-size: 22px;
+ }
+ .modal-actions {
+ flex-direction: column-reverse;
+ align-items: stretch;
+ }
+ .modal-actions-right {
+ flex-direction: column;
+ }
+ .modal-actions-left {
+ margin-top: 8px;
+ display: flex;
+ justify-content: center;
+ }
+ .page-card {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 6px;
+ }
+ .page-card-meta {
+ font-size: 11px;
+ }
+ .tool-card-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 6px;
+ }
+}
diff --git a/frontend/dev/mock-server.js b/frontend/dev/mock-server.js
new file mode 100644
index 0000000..c5a201e
--- /dev/null
+++ b/frontend/dev/mock-server.js
@@ -0,0 +1,823 @@
+/**
+ * Same-origin Mock HTTP Server for the Confluence Research Web UI.
+ * Implements exact HTTP contracts, security headers, cookie sessions, and deterministic scenarios.
+ * Strictly binds loopback (default 5173). Zero external dependencies.
+ */
+
+import http from 'node:http';
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const FRONTEND_ROOT = path.resolve(__dirname, '..');
+
+const DEFAULT_PORT = 5173;
+const HOST = '127.0.0.1';
+
+// Active state
+let currentGlobalScenario = 'normal';
+let isQueryBusy = false;
+
+// Predefined available scenarios
+export const SCENARIOS = [
+ 'normal',
+ 'empty_search',
+ 'no_artifacts',
+ 'repeated_cached_view',
+ 'failed_tool',
+ 'warning_truncated_history',
+ '403_verify',
+ '409_busy',
+ '504_timeout',
+ 'delayed_cancellation',
+ 'unknown_expired_download',
+ 'malicious_content',
+ 'large_output'
+];
+
+/**
+ * Standard Security Headers
+ */
+const SECURITY_HEADERS = {
+ 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; media-src 'none'; font-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
+ 'Referrer-Policy': 'no-referrer',
+ 'X-Content-Type-Options': 'nosniff'
+};
+
+const MIME_TYPES = {
+ '.html': 'text/html; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.md': 'text/markdown; charset=utf-8',
+ '.map': 'application/json'
+};
+
+/**
+ * Parses cookies from request.
+ */
+function parseCookies(cookieHeader) {
+ const list = {};
+ if (!cookieHeader) return list;
+ cookieHeader.split(';').forEach((cookie) => {
+ const parts = cookie.split('=');
+ if (parts.length >= 2) {
+ list[parts[0].trim()] = decodeURIComponent(parts.slice(1).join('=').trim());
+ }
+ });
+ return list;
+}
+
+/**
+ * Determines the active scenario for a request.
+ */
+function getScenarioForRequest(req, urlObj) {
+ // 1. Query parameter
+ const qScenario = urlObj.searchParams.get('scenario');
+ if (qScenario && SCENARIOS.includes(qScenario)) return qScenario;
+
+ // 2. Custom header
+ const hScenario = req.headers['x-mock-scenario'];
+ if (hScenario && SCENARIOS.includes(hScenario)) return hScenario;
+
+ // 3. Cookie
+ const cookies = parseCookies(req.headers.cookie);
+ if (cookies.mock_scenario && SCENARIOS.includes(cookies.mock_scenario)) {
+ return cookies.mock_scenario;
+ }
+
+ // 4. Global fallback
+ return currentGlobalScenario;
+}
+
+/**
+ * Generates fixture data for the requested scenario.
+ */
+function buildScenarioResponse(scenario, prompt) {
+ const now = new Date();
+ const nowIso = now.toISOString();
+ const expiresAtIso = new Date(now.getTime() + 15 * 60 * 1000).toISOString();
+
+ switch (scenario) {
+ case 'empty_search':
+ return {
+ session_id: 'mock-session-empty',
+ markdown: '# Research Results\n\nNo Confluence documentation matched your query.',
+ pages_accessed: [],
+ tool_history: [
+ {
+ tool_call_id: 'b_search_01',
+ tool: 'confluence_search',
+ parameters: { query: prompt || 'empty query', limit: 10 },
+ parameters_truncated: false,
+ started_at: nowIso,
+ completed_at: nowIso,
+ status: 'success',
+ cache_hit: false,
+ result: {
+ pages: [],
+ pagination: { offset: 0, limit: 10, has_more: false }
+ },
+ error: null,
+ result_truncated: false
+ }
+ ],
+ artifacts: [],
+ warnings: [],
+ duration_seconds: 1.1
+ };
+
+ case 'no_artifacts':
+ return {
+ session_id: 'mock-session-no-art',
+ markdown: '# Research Summary\n\nInformation gathered from [Deployment Guide](https://approved.example.com/pages/viewpage.action?pageId=847291).\n\nNo artifacts produced.',
+ pages_accessed: [
+ {
+ page_id: '847291',
+ title: 'Deployment Guide',
+ space: 'OPS',
+ url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
+ accessed_at: nowIso
+ }
+ ],
+ tool_history: [
+ {
+ tool_call_id: 'b_view_01',
+ tool: 'confluence_view',
+ parameters: { page_id: '847291' },
+ parameters_truncated: false,
+ started_at: nowIso,
+ completed_at: nowIso,
+ status: 'success',
+ cache_hit: false,
+ result: {
+ page_id: '847291',
+ title: 'Deployment Guide',
+ space: 'OPS',
+ url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
+ markdown: 'Deploy service X.',
+ truncated: false
+ },
+ error: null,
+ result_truncated: false
+ }
+ ],
+ artifacts: [],
+ warnings: [],
+ duration_seconds: 2.3
+ };
+
+ case 'repeated_cached_view':
+ return {
+ session_id: 'mock-session-cached',
+ markdown: '# Deployment Summary\n\nReferenced [Deployment Guide](https://approved.example.com/pages/viewpage.action?pageId=847291) across multiple steps.',
+ pages_accessed: [
+ {
+ page_id: '847291',
+ title: 'Deployment Guide',
+ space: 'OPS',
+ url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
+ accessed_at: nowIso
+ }
+ ],
+ tool_history: [
+ {
+ tool_call_id: 'b_call_01',
+ tool: 'confluence_view',
+ parameters: { page_id: '847291' },
+ parameters_truncated: false,
+ started_at: nowIso,
+ completed_at: nowIso,
+ status: 'success',
+ cache_hit: false,
+ result: {
+ page_id: '847291',
+ title: 'Deployment Guide',
+ space: 'OPS',
+ url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
+ markdown: 'Initial view.',
+ truncated: false
+ },
+ error: null,
+ result_truncated: false
+ },
+ {
+ tool_call_id: 'b_call_02',
+ tool: 'confluence_view',
+ parameters: { page_id: '847291' },
+ parameters_truncated: false,
+ started_at: nowIso,
+ completed_at: nowIso,
+ status: 'success',
+ cache_hit: true,
+ result: {
+ page_id: '847291',
+ title: 'Deployment Guide',
+ space: 'OPS',
+ url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
+ markdown: 'Initial view (from cache).',
+ truncated: false
+ },
+ error: null,
+ result_truncated: false
+ }
+ ],
+ artifacts: [],
+ warnings: [],
+ duration_seconds: 1.8
+ };
+
+ case 'failed_tool':
+ return {
+ session_id: 'mock-session-failed-tool',
+ markdown: '# Partial Summary\n\nSearch succeeded, but page 999999 could not be accessed due to an upstream error.',
+ pages_accessed: [],
+ tool_history: [
+ {
+ tool_call_id: 'b_search_01',
+ tool: 'confluence_search',
+ parameters: { query: 'archived docs', limit: 5 },
+ parameters_truncated: false,
+ started_at: nowIso,
+ completed_at: nowIso,
+ status: 'success',
+ cache_hit: false,
+ result: {
+ pages: [{ page_id: '999999', title: 'Archived Page', space: 'ARCH', url: 'https://approved.example.com/pages/viewpage.action?pageId=999999', snippet: 'Missing page' }],
+ pagination: { offset: 0, limit: 5, has_more: false }
+ },
+ error: null,
+ result_truncated: false
+ },
+ {
+ tool_call_id: 'b_view_02',
+ tool: 'confluence_view',
+ parameters: { page_id: '999999' },
+ parameters_truncated: false,
+ started_at: nowIso,
+ completed_at: nowIso,
+ status: 'error',
+ cache_hit: false,
+ result: null,
+ error: {
+ code: 'page_not_found',
+ message: 'Confluence page 999999 was not found or has been deleted.'
+ },
+ result_truncated: false
+ }
+ ],
+ artifacts: [],
+ warnings: [
+ { code: 'page_not_found', message: 'Page 999999 access failed', tool_call_id: 'b_view_02' }
+ ],
+ duration_seconds: 2.7
+ };
+
+ case 'warning_truncated_history':
+ return {
+ session_id: 'mock-session-warn-trunc',
+ markdown: '# Bounded Results\n\nExtensive data retrieved with truncated history logs.',
+ pages_accessed: [
+ {
+ page_id: '12345',
+ title: 'Large Architecture Document',
+ space: 'ARCH',
+ url: 'https://approved.example.com/pages/viewpage.action?pageId=12345',
+ accessed_at: nowIso
+ }
+ ],
+ tool_history: [
+ {
+ tool_call_id: 'b_call_trunc_01',
+ tool: 'confluence_view',
+ parameters: { page_id: '12345', details: 'A'.repeat(500) },
+ parameters_truncated: true,
+ started_at: nowIso,
+ completed_at: nowIso,
+ status: 'success',
+ cache_hit: false,
+ result: {
+ page_id: '12345',
+ summary: 'Summary preserved while large raw content was truncated.'
+ },
+ error: null,
+ result_truncated: true
+ }
+ ],
+ artifacts: [],
+ warnings: [
+ {
+ code: 'history_truncated',
+ message: 'History result budget exceeded; 1 entry truncated.',
+ tool_call_id: 'b_call_trunc_01'
+ },
+ {
+ code: 'unknown_custom_warning',
+ message: 'Custom backend warning code test.'
+ }
+ ],
+ duration_seconds: 4.1
+ };
+
+ case 'malicious_content':
+ return {
+ session_id: 'mock-session-malicious',
+ markdown: [
+ '# Malicious Input Test',
+ '',
+ 'Attempting XSS and unsafe content:',
+ '',
+ '
',
+ '',
+ '',
+ '
', `${devBar}`); + res.writeHead(200, { + 'Content-Type': contentType, + 'Content-Length': Buffer.byteLength(modifiedHtml) + }); + res.end(modifiedHtml); + }); + return; + } + + // Other static files + res.writeHead(200, { + 'Content-Type': contentType, + 'Content-Length': stats.size + }); + if (method === 'HEAD') { + res.end(); + return; + } + const stream = fs.createReadStream(filePath); + stream.pipe(res); + }); + return; + } + + res.writeHead(405, { 'Content-Type': 'text/plain' }); + res.end('Method Not Allowed'); + }); + + return server; +} + +// If run directly from CLI +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const portArgIdx = process.argv.indexOf('--port'); + const port = portArgIdx !== -1 ? parseInt(process.argv[portArgIdx + 1], 10) : (parseInt(process.env.PORT, 10) || DEFAULT_PORT); + + const server = createMockServer(); + server.listen(port, HOST, () => { + console.log(`Mock server running at http://${HOST}:${port}/`); + console.log(`Current default scenario: ${currentGlobalScenario}`); + }); +} diff --git a/frontend/dev/scenario-toolbar.css b/frontend/dev/scenario-toolbar.css new file mode 100644 index 0000000..87ea28d --- /dev/null +++ b/frontend/dev/scenario-toolbar.css @@ -0,0 +1,32 @@ +#dev-scenario-bar { + position: fixed; + bottom: 12px; + right: 12px; + z-index: 9999; + background: #111827; + color: #F9FAFB; + padding: 8px 12px; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + gap: 8px; +} + +#dev-scenario-label { + font-weight: 600; + color: #9CA3AF; +} + +#dev-scenario-select { + background: #1F2937; + color: #FFFFFF; + border: 1px solid #374151; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + outline: none; + cursor: pointer; +} diff --git a/frontend/dev/scenario-toolbar.js b/frontend/dev/scenario-toolbar.js new file mode 100644 index 0000000..5603fba --- /dev/null +++ b/frontend/dev/scenario-toolbar.js @@ -0,0 +1,21 @@ +/** + * Mock Server Dev Toolbar client logic. + * External module to comply with strict CSP (script-src 'self'). + */ +(function() { + const sel = document.getElementById('dev-scenario-select'); + if (sel) { + sel.addEventListener('change', async function() { + try { + await fetch('/dev/scenario', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scenario: sel.value }) + }); + } catch (err) { + console.error('Failed to change scenario:', err); + } + window.location.reload(); + }); + } +})(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0d0e1b0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,168 @@ + + +
+ + +
+ + + + + +
+