From a9908a533f1fce5c287c529062e495c5676f01d0 Mon Sep 17 00:00:00 2001
From: Artur Mukhamadiev
Date: Mon, 14 Sep 2026 21:57:54 +0300
Subject: [PATCH] frontend: web UI track handoff (contract revision 1)
Static frontend with vendored marked/DOMPurify, bounded Markdown
pipeline, same-origin mock server with scenario selection, unit,
contract and CDP end-to-end tests under frontend/**.
---
frontend/.gitignore | 2 +
frontend/README.md | 92 +++
frontend/assets/book.gif | Bin 0 -> 14207 bytes
frontend/assets/key.svg | 1 +
frontend/css/style.css | 1085 +++++++++++++++++++++++++++++
frontend/dev/mock-server.js | 823 ++++++++++++++++++++++
frontend/dev/scenario-toolbar.css | 32 +
frontend/dev/scenario-toolbar.js | 21 +
frontend/index.html | 168 +++++
frontend/js/api.js | 237 +++++++
frontend/js/app.js | 538 ++++++++++++++
frontend/js/history.js | 419 +++++++++++
frontend/js/render.js | 464 ++++++++++++
frontend/package-lock.json | 45 ++
frontend/package.json | 17 +
frontend/tests/api.test.js | 110 +++
frontend/tests/contract.test.js | 303 ++++++++
frontend/tests/e2e_runner.js | 592 ++++++++++++++++
frontend/tests/render.test.js | 149 ++++
frontend/vendor/dompurify.LICENSE | 202 ++++++
frontend/vendor/marked.LICENSE | 44 ++
frontend/vendor/marked.min.js | 80 +++
frontend/vendor/purify.min.js | 3 +
23 files changed, 5427 insertions(+)
create mode 100644 frontend/.gitignore
create mode 100644 frontend/README.md
create mode 100644 frontend/assets/book.gif
create mode 100644 frontend/assets/key.svg
create mode 100644 frontend/css/style.css
create mode 100644 frontend/dev/mock-server.js
create mode 100644 frontend/dev/scenario-toolbar.css
create mode 100644 frontend/dev/scenario-toolbar.js
create mode 100644 frontend/index.html
create mode 100644 frontend/js/api.js
create mode 100644 frontend/js/app.js
create mode 100644 frontend/js/history.js
create mode 100644 frontend/js/render.js
create mode 100644 frontend/package-lock.json
create mode 100644 frontend/package.json
create mode 100644 frontend/tests/api.test.js
create mode 100644 frontend/tests/contract.test.js
create mode 100644 frontend/tests/e2e_runner.js
create mode 100644 frontend/tests/render.test.js
create mode 100644 frontend/vendor/dompurify.LICENSE
create mode 100644 frontend/vendor/marked.LICENSE
create mode 100644 frontend/vendor/marked.min.js
create mode 100644 frontend/vendor/purify.min.js
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 0000000000000000000000000000000000000000..40ae4358e318671f628a3c56386681073dee1aa5
GIT binary patch
literal 14207
zcmZ{rc~lcw+wH3=NhKix62<}<0t5_mfPjFYl@Kr>V3-sYHB5?%HUh1PXjj5K2Sfx!
z4Ty@08W0s*Y{Q^{paw-nTkU|Th(qH%wB6j;cE8{I-F5GOtWc{?t(ENa?E39}76b-(
zdd0c|uE0kC(A3oY{rBHzW@i5S>#tX@Uj6y!p8x=8X=#1^`t{|@msBeC)2B~#I=!*6
z@$K8Uzy0>xn>TNCb#=*PvbMIig@uJmrTX&a%k=d0=g*%bBO?O?1NHUwK@il_(=#gec@NF*AKW@Ka}6bj?x<8e6LqD6~hV`J~#yZ7P4
zhf}9cy?_7y*|TS_U%wt18F~8j>4OIk>g(&bY}sOMZM|vJrp(ODh=>Re50Ave#OUbg
z_3PL3`TUTOkc$^D=H}+s)YO!gmR46+pF4N%#EBDEu3R~O{5XOjr%#`@v9aOtc#e*a
z4h{~MmX;YA8Sd`x8#iwJ>8GE3e0<{K;#^%_DHO`Ob?dynz0=atZrr#rJUrac(2$dp
zQ&LjW+uK`JRn^nebLh~ap`oFQii)PDCSzmcyLazCdGh4p!-tO^JqivEc6N4-ii%pe
zaN(?3v$$L?lgXSjXHHO1(25l+Qc_Y@uU@@%>(=GVm)qOhhlPbDBqS_Yu)yEnAA%qj
zixnCgYG!7Zo}RvB$r3+5zm}Gk$B!RhxNt$KRLW$s?Ck9E@$p@|cAYS0|N9&zw2){Q2|y_wN&l#CPxB
zO-xMS@pwZ+!^MjiXJuu1d3mi`waUcABq=Fr+qP|eZ`7MMW5f?cBNZ8rY^Dp1I96A4!H3fwS!VMA
z7Rb%?(os;cbV>FPFo4gDB0wJYFjGIVvJ$VQ4-mI*3*2r8J1&pX`k`)NBtZ|hfDA$j
zC*`eet+--*Q(;h~8&TsRw4y-~33luWhAsgIBPrya31kXXeC1+b@8xJsYEC*;lXv^v
z=s-s?VhwM8m_mfK)df4#Fvwm<@djLwXq{tG^nmM!a_YVGrhKV-b_-tAIrlaz7MRAp`RUANzp;+n!9
zEp6)`i0R^4crL3V-L!~k^a$jfu#If&4vu@U)cx4-@y9=BUa<3Mxz-wa-PK|42iqm5
zS3amaxF)Lj{^^uc53klN8C`ii`H|$(Lc`q+YO^43PV|zo5oEO)Bsvj8)Nq%1%
z6U>Er`~$qSj)^1$%6u{JSh}rlz;V1*jZ)4PU)`KXyA{I6Kn6}X`8R_E;`Qez@!p8Q
z!ct*$>*)0t^XjMZM7nG^d~2&XyW~YutDG%u$V;14ItLrJ_2=Tzu6*6ys@PZ^2MbQR
zbp9l;{g01iIxcha$m}6~l{o*dM97?!(Yb&B1_*fd*Y5xW5MT{B0RU+R07)hSAw!Z)
zB(fa(HcQM%{rB`9_B$nzqQ|WzGQ68V<@lrV!|6-?^u!$YZSjaL0
zaDP34*gQZBx1Th!-yXid73kwS+)
zCbnF}Lu7&6Rmo{R?t5QI^R?&8X*o;1nhsvlfEf}l-v^PB7ReDI9H(^Yp9(kqQ`Q
zbm{8D0^U;@6cY>B_;6zZ&FmYtm=nzE+NlS(53}MRRUGCH#VT^Z8
zL2A5xT44)!%vjJ!2iPj~Sbm_w4oF56y>%TP%$_+^lp&l23&z6a)Ln53TLTh58x%SW
zI&hs3VP9J%g65vSqcM>SZg*G?$E)&=<$z}#junEurfn~2uup4?HQa0Ad`j@JHQD$I
zna%6KS;wL9pg}CE59vBcUEyZs^z7^IFWAh7?k@_!q3C4grk$o+?!{gT34g7ad7z@_
zn^Yc>@^K;t=B)Jr4@kLyif<4Dj1$d|r&knl5`?b7F2yNtFByBn)7+nQUxFCoaXn6f
z2HKo!nqx+-%(df9&?ee8v`Ko6gQ~!*I<*;bczOiznrOEL&-K^y8ahn)v7TD{E)QJ&
zq8P!GS#f^WYf8kyI&-4!l)ni`SQp7aBnM#HcG+hl1sz$J5#!U-mc)35PfNr(2=AdT
z1siyJOJf?iju}8~f&N+dHomSOD>mNNHD)?}kUk*oW-VQ>seo5}yz%zBd-U@#)^2uO
zFq6$m!?{mAlP#zbcS0q)Y3VY;a{s-?#wVQbe-@BRNT*2DKt2UUzwOv+wQByWR`%a&
zokh}P$+`-%Ni=Fqzr3MG48g3dPjVD2q7wA<6qk24tW6rx{QPDTf=XEtjlCTeL(TcX
z{X{UQPswDSS1^VV*H(VVBd#fOClu6ReCFe`Vwc|)_9c~peU
z;GgwsM^)k`!Rl)-yDamIs&vElr!uZc7%sRB<7m%W34nLq?qU?J=6|t`nIkDp62?c07VS!oaA<-Ze8tS1ewMw=L!LTGjL@
zti|QVa!W_)RgE8@z=3G17UW>)R#kSt=Q_c3Bu+SCC}t#+Cj4Cr3{9|7jQ-(D7&E
z_$Dvkb>)zS7p#<;nqOnByflU34Sp0%l+EsnRibk=JGz(RY&pUq8ICF%_G=ox0T|pm`5j*|b`-bB3{x+Va6#;i=hv8zUV`SH>RKi)9m2pyPIB
zOBCK!3g=}s2CaFY%^$Q9Gb-K>w}V5gYp3|{97iRPH~lP*dlO~gj@R03pQx-3EGu8^
zWqtmBxFg#L=5e>GK5B=ntHf<15oRwBNd+_tQsE{MSYl
zLQ65=g5q-HQB)tH4F=&b{Lxjv@
zom&8Xwj|i?PRE;k+R2eFboZu;96!BOnlv^?&z&T8dDm4QqLi31RMiL1L^WXl4q>xz
zvc~^s2>X&uqHTs0qFRTw-FlAuSpL_YvJe+}qkl1tgLJTz*n;vyK(feMYxEeyLF>3$V!-VM9X}1>O!<1g@~ICk@#yE3x+P_
zzn|y&{wknRWQG{^SC0BM$6m@4y?w*3!ch!$m%A|*R>z8Ks0wcfm87cCJaMncGO>Oy
z!&d7V$y!(_UIk~GZj592$BpNX6D_!H467f2_3d}OOTt@7SY%e4>SG~Sw
zq{Zd*KvJ?^$mR$-u7-G?%E=`@jci)vA26_4gAgVhE;CO@(7iw>=)P!utZ_(*_
zMBjXfI7UncLtc-#@X%s>5x6Xa){BZVRDP$;gz41!9aMfPF%Mf9
zMo&4zmRgx9mOJ{t;5_4+;lPYnoo2z{Q_iD5$GAAvlce8gKS~s#I#ZdnveGLhh6Uh7
z0|(-EIMU6mA+K4HO++W!v|Scpt$f|zQ+foX@&Z&rkuAj!2EYurm0sIS{p$=&hqzqx
zGc~#-%BPYkF*wRNn&j|nbHr-9C{+bydZxgQh^q)X{C0TZR<<7zW}bQyY0i`hNW;{I
z>mCWT3T?0a`aAZS3%k*!WN>+rt?HBEb%>A|w$DdJWF>oB;aMW>Ot
zulc}L*Hxp!9s&~iY;lq_;r#6d0DCOQDZNRq;n-Q9=4UCubg$zqQ}}@E^Qv<_PFbW_
zZFYD@8ne=STlk=s$sa=oB3#HH3`n5@O@y-Lsv&x()f5>w_yQJeIVZ+!|mu21`C
z-jcrw6I2WH`@GdvyJI<{i%wcVV)rXmq&dL$5=1sQ@C;46yzb5S+6+XYF$0K!sK&
zuS4-!f3;;-LPE=Zjl*4Ko;DY$llKSGX6suGb?1zoen#Np-%&X}?8D_=7RR3C25xB?
zsEhI4B4Mz4F$42GMbzxP4Vrt5APo{iYu(UmNq%8;_m1PPLK0|TBTGQ98eecC*pvva
zDzwfL^(OTDFJa!GIW&hEpCMuafI;7+RAr>XYg_z$TB@o8x9uD)t!ZGq?FMRa0<$sI
z(%g(7c;BO~;wl56LRSHYrsP$`FtyfZ+{)L_{$cK~{sC#;@7EE!bxDZRHWA|>)k<$>
zDykc0f+G>+`)14MeiVc4HIq%rflTY$DqpM9D3*t>lkLW|o?qx&xtd^>0zd@Ek!lkd
zwGtUZ(ef1#$!zq&W(`*X4W{*rvRO+IB_B$nMl|*)%jAquXV`ty(mA+dUrSFV)xUrnsPl2@TLH_3p{&l`&y+{}aPjn~hv@!_
zT5Q?cwYJ#W<|Ahk^5V66Wqe&@Oi+$2Loh0!1Wu0v;Ep)HvFHY7kCK|8*C3Me}U+?tl
zsknQgktk2wQNC`^oy1}z(%E$j*7!*y4Tck^?9e){nYdR|XG*U%aNfLWT8?Q8A}u}-
zG2s)CE@RVGGxArN*e*=(M%Nq@Qsu4CWr*ln%#Wp#J3M^7bn3FNW`bfdnI?pY2kze}
zMfvQWp?QA0~dSL-%6ve~L@yg{mQPZL2!spfs0{N?i>$fgeBqjt{
zPj}xD7M?6B
zK^$|310R;S{c`qhR)FKyk?2|85Jb|G)4P+44=wrbR|$ajH~j!={W$;aSIxiaM^zvp
z>Dt8zq#?}BFaPpUD5MPlh_et;Zd+;)ag0^;&Vtus9Fat!@fqs^Pi&rzN)o2Ei^aTs
z-eZi{er4x2wK97HSv>Re)Q<_qY&Aa745Z<6mWhQm$^t$L3l8PQ;`^cEwqt*Rvq
zZK0j75Zpw6WTKIZlshE;y
zG{3nut|!0JmP*qVjfwol<5cz^9D<+Q*B>>1Y0ne
z%CZ}{g6(+#&Cggn613X^r^_SB;uYokoBi`FX25(dIz__`8a4O+%A5VuKw@>u8buMn
z)4nG1alneeKj~}el_Kd@gPYs8JY@TZ*)e)?Gls^(U{U$-D|4WjHhL-Bn(IOlA7npP=pMI35w9Tf*SP1@=C
zkyrSlH_&oKQM7m?!p=W&*q6<-fScN`JH4A0YFirkh#*`O2`?efmQ_uoVXP<<0fV7X
z%(+f_$0Hu7X=)qaQi{Bt+2c*hdJhT&dmb
z(!gm~iTA}GMHHp6PY}k?AkoYAChpoN2MmV!aWy6|1N?x?`xMlRTj10EYgr(YR}L=h
zoVid4CYd1va!Swl-gH72$-m=%&
zwHJtLp+9N9vK$N@y1GcG!r^TJAVsA4vCj~6u+KTeW$57|Qs`}OxIZ3w^R9%igm!b{
zNv5yG_ySd@bu8+b=N=1C@UgURwkO^-KgymRtL6JkL2Lf{A7a3KJ!?c%7MqCB_|R1J
zYnX5dS>9b>1!^JzI@+EXgjg1n*%k&V>^{@!R>~5jOSeav2+{Hgh&5tT?K0cS|-s>2J3
zxOdqW1~p>RJryB7DGsS;1lS!#wG9-FhCh1d8hA2zKZW?wSfBV--iL8pKY{c!G@^zU
zgLG^(%;u0tVe8a2>s1t=a=6*gS=0q(D!UDzc68~;x-^X=RYqD#dx2_s@#2Yb(7&JW
z(pEt=lv@B4b{y55_U~-B2OPfPFWY}+`x5ouBvp~{_V|`)nWK4*#GY1C<4JGy&&Ka}
z>epZ24;VilR-Dx@$FrDG2W}t>>kFiw?^>8#%H^!YwVyTVt;C~L
z(pc=_P%_u?z>aeTDgFhkGkp+R#=6C)j9m%xBwJ-9anqCVPo#*A1e;=#{)4-A^6~}?
z=L8+*8emYGxSlUHrY#13(ZA(1FX9x0(Ofw1PBeR_)QQt#9igRks%7^w`>FRP8Om`w
z?yYNno5g_90sTLyp59)Qrnh_)@GHwmn=NYMV+ho~s?}9QWXzNPB#S6a-i{3WKF;}?V3I(k!
z)UQ!9?X#uspOVof3?U+<+QNF3cd3>%wS2cLZG^6H!lp_>E~%-Aew&n142noHjA83T
zC)i7x#a(ETnkhlFC=oXI>8@@NHz2Sn1^1F<`YY_eLVW~?tWuBWX;AKA{S$W1jxeaoD8o&?Ng@?EWPI~Dgg#$jZasBzBur|o+Wi^w!#ij&nXO>P68LJ}GhBUNWQp)|;
zw=l}`ncFc5C~npnj0n4=d_biYiVSL)l$_!g`x+gt))Tp$Vr&26MZgW_*?5IbNe<9W
zy2@+i`OwHJd{Cg3UOsr&maj18@BYxM@nKmuM+%#8QKI*3e*JHJILI8Qn#
z=eY#B^-*rxSJAlK_Lda<#hDW-=QL}VB{^U!Uw4rvWzs!-j(u>p*ZHZ`P@S|}h`eZ}
zp-)Nxa}MjpfCdxa2^rZ7pL<6gGM0DgKCbT$U+CK+?bVQRIiF7UY}+kS7kavvyH3-#QvD^%T&>tfF6zPXwI$18#){uX9a(<0_UmQ4+W2D7W>2z
zQY{Ns+JS|0_%drR*#ftf`alMI+_X;yJ5RjUypK*mz$bqI=3zGX8?}RH`FpLdDB2!5
zW`2RiKip_(kBjVfGd+TmlyV7CD+kAX3$Q1lE~06&M5h|T-OwXZaqX8Zbv$!DSH6bz
zn-Yw{JSzjy48=VfBC%iF9|x1U_6QF3Oc^*$0
zS^g;(RdP?C?##Z)_ROmr&;Rx<9x_w@{;6}v_20`0>;CI@u=;k^>Kzv#MgE;M$Yot(
zb1l2S*=0V>D(G9%aH$w^3u>3O9_TA)_!XH1wlxjxWJMf}8E88=xSJgx6fBS)7~12R
zW?&-b9`2&g%MM7eNy`b7Y2%>3pPR1U(;a940Wb#a)eEiow$P_oSywh`0VAegDeKOo
zY0oS9zL33TP0hax9g#lj&$C&S7bE+=&=tKJk^-uBOPjhsaO&{&-6RLW*Sor;uo@E8
zW<^INLvo1v&x-hOHut|r$v2xTl>uUc87VqL&hX14SgFI1X^TCA7x(@=GMdu3Kum(2
zaxll}VwQ;+!k!Kq^UKpR$>J2CBb6~PLtA17ROSY_T%RAix#RgJX^-Lp3)~`d=5025
zMUGjkQC2>yl0hP!j+?r6QhQmb9(%(GiZhE&=PmA$Vc~57U@PxMfMrNLUz`afzi`^M
zi0V#>6+g=E)`m$no6v>*1x04U8q&+I<~UeCFCkiXir@}Am1KODT`tlzx6}~CVxz$o
zluJgU4{J4~DKk(-(I%89DbqiQB;kBqx*;fmdZbl&=kCdac(#kI`B4|tLhZ11Qf2QB
zIG#Q*+4%6!pUG3a7Ly2h4R(T6e@_`VcKnVdP}Q&jl-+
zZ}*6`ldG>s{=ZzW_%7S+FH*(8S44E39`T|r$mD1ITfkYnqqf#cTj))*i8%5)08z>}DS3rHXKw}s
z_^@MFZ#51Va{QyS#e&6UGq9%cobtBX@wUIlT(u`H=!kYQlb&*ijFp1&&ZWb+Y9B=;
zpTBzAIQce$N$4lq5^B%cYufXs0Cf(8$3Adb}=h$;hd*m
zJ(bql?<~TswLjVZ&U2{}-CjuaeFNC*oO`V>aV@h~ka7{}m3iltJ$}9g(WnAPmFm#d
zsEKh?0rAIfEn#3^ue!at)~>XNRoOpSeR;PtSdi-jEaH=zmUneHJ&48JuQES9EYhH{S7PCLb_!b_Xvb{gnDnt*bReQ1UJR3EH&G>@^cclH97kEA5u
zN1DU`Uqm-j%?Yy~bh+;Z4J`##&l2yTzZJN_iI#5;cqxs93ukNuk3hS!~uP=QrD4FI3B0@@v#W&Y$`j6}FyYjE=?c4C(^=4pR
z%8!^U!XS}_eN!w^QO)+m*$1Q;T=W(QL{xxAO@B~=3uw~|X+7z{&D%VtVU-bw)Uopn
z?=d74#%0EO!2oVTo=aZwKjwF`Eb`)UtKQYizwx>4;+~;^!%PwH?$ukbp!Z16WwE
z3Y>p`QSW8fdDIvbzh4q%YNph7S|)-mld=s<8VX}mB*>9w$Tu1&UO6(Rw=6?ptWhal
z>jMz>C-r)Lcn$o=2g|yM#tJ>EC*k5$WN~@R|JMhPHuC@W!QCOXtN@S7F~|ci0vodh
zLs&+KIQ_})@Djm)W`Y`a5!A4Y^2|e
zi`7l5Mfr*xHSBWy4!d;FoFjBGMu+Ciuh|7TYW{#7H*X?>E!eyVl_8SV;c7+J%U=p{I3Cm4_o@4V`kgP|wvK0xOO>6GV
zEM^pMfu4c}8YHIl7`T+K@!l?$Q3*^Rfg4w`Xcu-k+N3uQ=~S>{HGQs2+c3bGDI3@h
zPs)`$uD(3<8%UISNY^6>6H@~)T&&@jmzRIrYVaYyAyGG0j!``dQggu&Ca_QI%Y*8b
zx%)#%BD3YPIMdD4n(n>e%FflO-K?XQCj9pB|F2VyfA!Xq65J^V3^f9R#M;WgxQH@Xe$A
zy0Ovy`+Yp7k{V#?=&B--caf4;v3c2%!xyX*lKks8UNJdhY}%T2^=^lq;N7p?(s9|*PNam?FjkQpvhRVFnV4t9Nd9B%pmea#_=fj%2p+m!`
zn|MDr@dkG6+0b#~GJLFzufzgzx%1#mxVtKq$ART_M2axPZEez=IsHzRV;Jh`7MF#&)7*Lz-adkJ#-%QCIbETu
zyfyp0#^biMKR>YX=G3zUy#AWZONqA3a-G*((;D9g8u+bDMqXo>Yb-UyPuF3mbx2L)CEbIQcXPF^e5PIq*o^?m
zSkD*<8qvcvTYAB5`Z<>x=C{B3+!j`;0_@<;637NZEZpLN|f#o+5fF
zB&4;F-x*y-y^Lm==EU8+ysRxz`Oeh-h8nctEU4ZtSfC=mlxVeHufJC@M>je6n*JtP
z&!cm*o?1>u`wfoif|-qO(w>F|QayVu+I0WO930EwT|xq5;dNn8k!IJE+|?|iflu9#
z=e;^`DRJ4y-UPs)ixRH4?>`yP%)cR!QF0uE!2j@{5NI<0%q{JHX8T+9fKYnFN-Jej
z4}vY=A|_H)Q$Oa-5|4c*Ddp%wc_K!G55@zIkUj&L4X4K{Q?J}j!7_={2Q18q!FmUP
z7l>j|3?;__c8Q&7mxJA}%F$3`|1=CE(&80l_(kL0k_aT_8mMn7yUv?k&*SPvwm%}8
z5`jv(v+*lb7^-G6ekE!D6$wfAFDG)Obaf;oQZOSms%~n-4qZ=pR-Nk71>MFC=1d`0
zXzH6JWT@f5KrEo+OZXxTusSGf@XoL6%>}!2@|2_lwK)ZO_mCM*8OUi~r%X5lBn5Lx
z9q}mP%m>a=FSsNQGbCJ4ifuCpb9V=vy;MBSb5o#L?hQ2yncu0VL$NjErw0sQ=7
z-M6erO?m=##j%*S;SYm1U0^_jnL~^e6{zNRf`Mo2BKcR~cJ!@(8KNRx?XtUb7~BeM
zM-5@&%{M$vzmc#iyG4F)xj!MY!-MW?st)bnKe9&*21dGfk6LIchPh*i3%qjra
za5{H};zm{g$#q9#LN1&$o@s=%-YWC9`u-s$wc)tn$Ud5@u4oPb>rh-s@d7dW@ui@NX2Eg3&<82QYG|l3*Gx*H|_^ZZb!J*HO^Ri3JRJ
zr!5JTww5h?DKgBWNqjL2!VmWYx15WQ{z#Iz=Vf6)QFJ!5EvzKiq(Rq{0lw=12)0>0
zzE)fTO{+HV7jZA9_iK=VVy^QYLJ__fI&gr^KlLkhp~Evy{jQ>^VHwjeWXxKGyY;=k
z0U~Z1NuxrbHjD~(3__&xhCmUz$?xNf?8_xY$_{mXBVXY&CMntTb`ZP1ABO+CzHx=&
zQ}?UEGR8C-&UAbFK~&-Ae}ycYM{HvYTEKmf^VoT5etP4
zU8N9F#EirGQ4pgt9u;G)3-w`7(`#{zyQ{vnHd1Rh0SiFGrHGvKcWVQ`a_D<&qs-=e
zYr~MRTHV?h`roY$sAyVo!NGVuaVS=GL$N?J+9=^v_Z~Fd^)_k7KHx}%SysX2BNgaP
zK(D09k`(8rT!{08{WzF}ChsCNbTkx#!JQ0Xi?tLQrD>|M=J(3Rp0&);RZ#nwS->ui
zFrPccp_OqbkpxJ0d%G@?Hl$a9x8ZbDU_cErjSm>)Oc!yb99a){A%jOC=wi2GzEw6T
z0pBVcTyZOUDn!SoZn)btOd-jBnTW@mIAYc(kUelDnniGnvCNr~b>Aje(pF~~OScmhsEBony
zF*6#vm^8yxpm{p??06DiS{0YgQS}`sSsRDsz()#K$160dSR74!oU$s+X0EWYwjfDa
z?i+Nh1$g=?NmZP4)G7wJZuon%pPJ2liY@b;m4&Nb
zB>pt#@16$4Bg9VJ<+&8dVNbTOur1JVOZr?j{cnynT}3GofiSXhyA;;^R@7MSS`_T6
zk7umeuc_AXQwrl9Xnu&(M6gf6)62B>2*atK>-$Bje(K*H4G#OCj>de)g#YenRF=_D
zLJ8yMko$?R7QOaAvK3wTOazwGTikO*CG)-}^C-RyMDJfj{C9lyKk77B*R4cqb!w~C
z*+CO4tS7=-j<)?Cfm?Hz*>KFI2kI$@6GvSXN-R)I_lYX}y|y`d$<9yK%bJ)ud+^Rw
z4Y~3ucdr%uOjsJ{WUjgQg}4oDv9c-RFjY(BI{K1ZjZg?l`qNO(o`v^i*5l08ebwg1
zE63`rQl=CuwZHfxK;_u&^H#mS9sHEbAQ?a9YVsPBchT3GMI!x|ft>`2{%v)1jp1py
z6-bKZXYQUr|BevSo|asodzN@I%oyu0w~uZlehp
zY2KBye5YgCH*q==>!w|3zFtBh=s-g6;P(4Si@8sIXuaMG9Z~~G2bt5L->*LMT=dIn
zkyKwhJx=NZ`yRYx8#=1PhchR5eB)z2%3M_QNi5A#yD5$4y0u*=e|}^^PYy)!daqzZ
z6_W+r3?Jptg5|L^VAgv6+cN7bS=|)J`6+ETaXu^Kr8Mm%UD4t#ntF-G@QT+!(Rx_W
zkNbjfuHjyf7|DFKJKh7(E7p_4qHQ*5lMOlZ7*`s1RO)nTxKrC}=4x?&R=TwXvwP6(
zuZ}#cV?o|_yzS$0(z-4CGHFFe)dM`S>cojJ7_6p*!^Qc-#fVDoH;-(~?7QY*rW330}di^lHso43cF#M|Ja7$P|xQkka
zlC_8WLYO`G*@gDZoC9LJ?eg#kKQ5~1?TUkTwMh&NM^En}xQ#G^Tx%wGUUn~=#0Zo{
zz)nB18IvuYr9E=HmWyOGy8FH`1;9_MSYXhjvdN6&xKWm;dIxh9wEP0IA1r=AN!)Dh?o6jZP-VLsT<_R4XA5rjP)*pZQPWPc!r;bsZ7=Mcv$DvtrtRK$Rx`?R3x_nkupAL(FPshe`lF0u+!q+~
zo-Pwfar>(}mp294MFn*+w%6&|>^f;ev?R}rD7G
z7NGpQDW?$_CW!@~30y5-nF@p_SP-
z)Q6~YrTs+o-ZKOSYE=EU{o0wsgXpupPm>E`sO|mWU!;lK^(|(KsdLxKBfoBNezESk
z>!emyjF)kJ_Z8o9beN(aQ)OUbr(20GG>n+$_mAfDrQrE6y~o2TN4AxK
z^Qj48{5g({_r%C08{BUZ1tG#cCe3Nsg$>8;5BfpVmT%w|A-h0it6KUz@ltC2qHJgmk9fi{4E|c3CUJG&lOl^W0bQdqRx*lfr}D`#33n-gl7U
zQvGd6qnkn2S6Z*f4$IQ^!z*5m{EYtRb(8R4s)A<}k`xxpps**Qi^cHR6%;J`#_JdJ
z7=b!98S8=55JSAKDJR6DqJ8aC4h~`Px?{F;w5AX4>|!PpHE()N&Rt+`pm{JeXWQ!
zVQo)5kwEWg=p4AsBjQ2=?;{Au?;}cH+
z<=vYH9eAhKD5=3a<4YN5EV^lBwzu)MJrRV}x7t#V4P3JB&r2^P4#Wv6U-S0y$
z@*Nibvdrk_a1h`=e{XNCo6XfD`}|_8Ka=07d&2&|KZxHI`IvdgfA1TK#>kROhXeED
zPS?3lE^b*-x^l0v$Ae%%L&r1h{gGkaz)^@*qt26$m(C*Udoyzspe|2OkhOXCB5&Bw
zhGgb5`)b`UrW#F22MNwA9c7^@w6NR5#hNAwCb4Zfh0kstSV|U!G5kt_U5-C=N_%s2
zAXv|BqeEBE5t_00i$iI$E!b?U`2OmU
\ 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:',
+ '',
+ '
',
+ '',
+ '',
+ '',
+ '',
+ 'Harmful links:',
+ '- [JavaScript link](javascript:alert("xss-link"))',
+ '- [Data URI link](data:text/html,)',
+ '- [Safe citation link](https://approved.example.com/safe/page)',
+ '',
+ '```html',
+ '',
+ '```'
+ ].join('\n'),
+ pages_accessed: [
+ {
+ page_id: '777',
+ title: ' Safe Title',
+ space: 'SEC',
+ url: 'https://approved.example.com/page/777',
+ accessed_at: nowIso
+ }
+ ],
+ tool_history: [
+ {
+ tool_call_id: 'b_xss_01',
+ tool: 'confluence_view',
+ parameters: { page_id: '' },
+ parameters_truncated: false,
+ started_at: nowIso,
+ completed_at: nowIso,
+ status: 'success',
+ cache_hit: false,
+ result: {
+ raw_payload: '
',
+ nested: { malicious: '' }
+ },
+ error: null,
+ result_truncated: false
+ }
+ ],
+ artifacts: [],
+ warnings: [],
+ duration_seconds: 1.0
+ };
+
+ case 'large_output': {
+ // Dynamically generate multi-megabyte markdown with sections, tables, and code blocks
+ const sectionCount = 40;
+ const mdParts = ['# Large Document Benchmark\n\nGenerated large output to test bounded section rendering and responsive UI.\n'];
+ for (let i = 1; i <= sectionCount; i++) {
+ mdParts.push(`\n## Section ${i}: Architectural Components\n`);
+ mdParts.push(`This is paragraph content for section ${i} detailing deployment topologies, container security, and protocol isolation.\n`);
+ mdParts.push('```bash\n# Simulated shell commands\necho "Running container isolation check for section ' + i + '"\nfind /work -type f -ls\n```\n');
+ mdParts.push('| Component | Status | Metric |\n|---|---|---|\n| Bridge | Active | 100% |\n| Storage | Bounded | 50 MiB |\n| Latency | Nominal | 12ms |\n');
+ // Repeat text to bulk up section size (~50 KiB per section)
+ mdParts.push('Confluence documentation analysis paragraph '.repeat(200) + '\n');
+ }
+
+ return {
+ session_id: 'mock-session-large',
+ markdown: mdParts.join('\n'),
+ pages_accessed: [
+ {
+ page_id: '99991',
+ title: 'Enterprise Architecture Overview',
+ space: 'ARCH',
+ url: 'https://approved.example.com/pages/viewpage.action?pageId=99991',
+ accessed_at: nowIso
+ }
+ ],
+ tool_history: [
+ {
+ tool_call_id: 'b_large_01',
+ tool: 'confluence_view',
+ parameters: { page_id: '99991' },
+ parameters_truncated: false,
+ started_at: nowIso,
+ completed_at: nowIso,
+ status: 'success',
+ cache_hit: false,
+ result: {
+ page_id: '99991',
+ title: 'Enterprise Architecture Overview',
+ space: 'ARCH',
+ status: 'ok'
+ },
+ error: null,
+ result_truncated: false
+ }
+ ],
+ artifacts: [
+ {
+ id: 'art-large-export',
+ name: 'full_architecture.md',
+ size_bytes: 32,
+ expires_at: expiresAtIso
+ }
+ ],
+ warnings: [],
+ duration_seconds: 5.4
+ };
+ }
+
+ case 'normal':
+ default:
+ // Shared example scenario from CONTRACTS.md Section 7
+ return {
+ session_id: 'a0f2b3c4-1234-5678-9abc-def012345678',
+ markdown: '# Deployment Guide for Service X\n\nTo deploy **Service X**, follow the steps outlined in the [Deployment Guide](https://approved.example.com/pages/viewpage.action?pageId=847291).\n\n### Key Steps:\n1. Verify container runtime prerequisites.\n2. Review the checklist exported to `checklist.md`.\n3. Execute staged rollout.\n',
+ 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_search',
+ parameters: { query: 'deploy service X', limit: 10 },
+ parameters_truncated: false,
+ started_at: nowIso,
+ completed_at: nowIso,
+ status: 'success',
+ cache_hit: false,
+ result: {
+ pages: [
+ {
+ page_id: '847291',
+ title: 'Deployment Guide',
+ space: 'OPS',
+ url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
+ snippet: 'Deployment steps for service X'
+ }
+ ],
+ pagination: { offset: 0, limit: 10, has_more: 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: false,
+ result: {
+ page_id: '847291',
+ title: 'Deployment Guide',
+ space: 'OPS',
+ url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
+ markdown: 'Deploy service X using the release checklist.',
+ truncated: false
+ },
+ error: null,
+ result_truncated: false
+ }
+ ],
+ artifacts: [
+ {
+ id: 'art-checklist-01',
+ name: 'checklist.md',
+ size_bytes: 32,
+ expires_at: expiresAtIso
+ }
+ ],
+ warnings: [],
+ duration_seconds: 3.2
+ };
+ }
+}
+
+/**
+ * Validates that credentials in mock mode are synthetic/dummy credentials.
+ */
+function validateMockCredentials(url, pat) {
+ if (!url || !pat) return false;
+ // Prevent common production token patterns
+ if (pat.startsWith('ghp_') || pat.startsWith('glpat-') || pat.startsWith('xoxb-')) {
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Creates the HTTP server.
+ */
+export function createMockServer() {
+ const server = http.createServer(async (req, res) => {
+ const urlObj = new URL(req.url, `http://${req.headers.host || `${HOST}:${DEFAULT_PORT}`}`);
+ const pathname = urlObj.pathname;
+ const method = req.method.toUpperCase();
+
+ // Attach security headers to all responses
+ Object.entries(SECURITY_HEADERS).forEach(([key, val]) => {
+ res.setHeader(key, val);
+ });
+
+ // Session cookie: cw_session
+ const cookies = parseCookies(req.headers.cookie);
+ if (!cookies.cw_session) {
+ const newSession = crypto.randomBytes(16).toString('hex');
+ res.setHeader('Set-Cookie', `cw_session=${newSession}; Path=/; HttpOnly; SameSite=Strict`);
+ }
+
+ // Origin check for mutation requests
+ if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
+ const origin = req.headers.origin;
+ if (origin) {
+ try {
+ const origUrl = new URL(origin);
+ if (!['127.0.0.1', 'localhost'].includes(origUrl.hostname)) {
+ res.writeHead(403, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: { code: 'origin_denied', message: 'Cross-origin request denied.' } }));
+ return;
+ }
+ } catch {
+ res.writeHead(403, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: { code: 'origin_denied', message: 'Malformed origin.' } }));
+ return;
+ }
+ }
+ }
+
+ // Dev Scenario API (development tooling outside user flow)
+ if (pathname === '/dev/scenario') {
+ if (method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ scenario: currentGlobalScenario, available: SCENARIOS }));
+ return;
+ }
+ if (method === 'POST') {
+ let body = '';
+ req.on('data', (chunk) => { body += chunk; });
+ req.on('end', () => {
+ try {
+ const data = JSON.parse(body);
+ if (data.scenario && SCENARIOS.includes(data.scenario)) {
+ currentGlobalScenario = data.scenario;
+ res.writeHead(200, {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ 'Set-Cookie': `mock_scenario=${data.scenario}; Path=/; SameSite=Strict`
+ });
+ res.end(JSON.stringify({ ok: true, scenario: currentGlobalScenario }));
+ } else {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Unknown scenario' } }));
+ }
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Invalid JSON' } }));
+ }
+ });
+ return;
+ }
+ }
+
+ // API Endpoint 1: POST /api/v1/auth/verify
+ if (pathname === '/api/v1/auth/verify' && method === 'POST') {
+ let body = '';
+ req.on('data', (chunk) => { body += chunk; });
+ req.on('end', () => {
+ try {
+ const data = JSON.parse(body);
+ if (!data.url || !data.pat) {
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Missing URL or PAT' } }));
+ return;
+ }
+
+ if (!validateMockCredentials(data.url, data.pat)) {
+ res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'confluence_auth_failed', message: 'Invalid mock credentials' } }));
+ return;
+ }
+
+ const scenario = getScenarioForRequest(req, urlObj);
+ if (scenario === '403_verify') {
+ res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'confluence_auth_failed', message: 'Confluence authentication failed: invalid PAT.' } }));
+ return;
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ valid: true }));
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Invalid JSON body' } }));
+ }
+ });
+ return;
+ }
+
+ // API Endpoint 2: POST /api/v1/query
+ if (pathname === '/api/v1/query' && method === 'POST') {
+ let body = '';
+ req.on('data', (chunk) => { body += chunk; });
+ req.on('end', async () => {
+ try {
+ const data = JSON.parse(body);
+ if (!data.prompt || !data.credentials || !data.credentials.url || !data.credentials.pat) {
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Missing required query parameters' } }));
+ return;
+ }
+
+ if (!validateMockCredentials(data.credentials.url, data.credentials.pat)) {
+ res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'confluence_auth_failed', message: 'Invalid mock credentials' } }));
+ return;
+ }
+
+ const scenario = getScenarioForRequest(req, urlObj);
+
+ // 409 Busy check
+ if (scenario === '409_busy' || isQueryBusy) {
+ res.writeHead(409, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'busy', message: 'A query is currently executing or prior cleanup is in progress' } }));
+ return;
+ }
+
+ // 504 Timeout check
+ if (scenario === '504_timeout') {
+ res.writeHead(504, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'query_timeout', message: 'The query deadline of 180 seconds was exceeded' } }));
+ return;
+ }
+
+ // Delayed cancellation test scenario
+ if (scenario === 'delayed_cancellation') {
+ isQueryBusy = true;
+ let aborted = false;
+
+ const cancelTimeout = setTimeout(() => {
+ isQueryBusy = false;
+ if (!aborted && !res.writableEnded) {
+ const responseData = buildScenarioResponse('normal', data.prompt);
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(responseData));
+ }
+ }, 30000); // 30s delay
+
+ req.on('close', () => {
+ if (!res.writableEnded) {
+ aborted = true;
+ clearTimeout(cancelTimeout);
+ // Simulate backend cleanup budget
+ setTimeout(() => {
+ isQueryBusy = false;
+ }, 400);
+ }
+ });
+ return;
+ }
+
+ // Normal response
+ const responseData = buildScenarioResponse(scenario, data.prompt);
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(responseData));
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Malformed JSON query body' } }));
+ }
+ });
+ return;
+ }
+
+ // API Endpoint 3: GET /api/v1/artifacts/{id}
+ if (pathname.startsWith('/api/v1/artifacts/') && method === 'GET') {
+ const scenario = getScenarioForRequest(req, urlObj);
+ const artifactId = pathname.slice('/api/v1/artifacts/'.length);
+
+ if (scenario === 'unknown_expired_download' || artifactId === 'expired' || artifactId === 'non-existent') {
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify({ error: { code: 'artifact_not_found', message: 'Artifact has expired or does not exist.' } }));
+ return;
+ }
+
+ // Exact 32 bytes from CONTRACTS.md Section 7
+ const artifactContent = '# Checklist\n\n- Deploy service X\n';
+ const buffer = Buffer.from(artifactContent, 'utf-8');
+
+ res.writeHead(200, {
+ 'Content-Type': 'application/octet-stream',
+ 'Content-Length': buffer.length,
+ 'Content-Disposition': 'attachment; filename="checklist.md"',
+ 'Cache-Control': 'no-store'
+ });
+ res.end(buffer);
+ return;
+ }
+
+ // Static File Serving
+ if (method === 'GET' || method === 'HEAD') {
+ let relativePath = pathname === '/' ? 'index.html' : pathname.slice(1);
+ const safePath = path.normalize(relativePath).replace(/^(\.\.[/\\])+/, '');
+ const filePath = path.join(FRONTEND_ROOT, safePath);
+
+ // Security check: ensure path is within FRONTEND_ROOT
+ if (!filePath.startsWith(FRONTEND_ROOT)) {
+ res.writeHead(403);
+ res.end('Forbidden');
+ return;
+ }
+
+ fs.stat(filePath, (err, stats) => {
+ if (err || !stats.isFile()) {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end('Not Found');
+ return;
+ }
+
+ const ext = path.extname(filePath).toLowerCase();
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
+
+ // For index.html in dev mode, inject a dev scenario selector bar
+ if (ext === '.html') {
+ fs.readFile(filePath, 'utf8', (readErr, htmlContent) => {
+ if (readErr) {
+ res.writeHead(500);
+ res.end('Server Error');
+ return;
+ }
+
+ const activeScenario = getScenarioForRequest(req, urlObj);
+
+ // Dev scenario selector toolbar (external CSS and JS to satisfy CSP script-src 'self' style-src 'self')
+ const devBar = `
+
+
+
+
+`;
+ const modifiedHtml = htmlContent.replace('
+
+
+
+
+
+
+
+
+
What would you like to research?
+
+
+
+
+
+
+
+
+
+
+
+ Agent researching Confluence...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Generated Artifacts
+
+
+
+
+
+
+
+
+
Pages Read
+
+
Tool Operations
+
+
+
+
+
+
+
+
+', `${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 @@
+
+
+