2026-06-23 23:54:37 +00:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" >
2026-06-24 22:50:54 +00:00
< title > CTXD< / title >
2026-06-23 23:54:37 +00:00
< link rel = "preconnect" href = "https://fonts.googleapis.com" >
< link href = "https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel = "stylesheet" >
< link rel = "stylesheet" href = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" >
< script src = "https://cdn.jsdelivr.net/npm/marked/marked.min.js" > < / script >
< script src = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" > < / script >
< style >
/* ── Reset & base ─────────────────────────────────────────────── */
* , * :: before , * :: after { box-sizing : border-box ; margin : 0 ; padding : 0 ; }
: root {
--bg : #1a1a1a ;
--paper : #1e1e1e ;
--ink : #d4d4d4 ;
--ink-dim : #888 ;
--ink-bright : #e0e0e0 ;
--accent : #e5c07b ;
--accent2 : #7ec8e3 ;
--accent3 : #98c379 ;
--accent4 : #c678dd ;
--border : #2a2a2a ;
--border-light : #333 ;
--header-bg : #141414 ;
--input-bg : #222 ;
--hover : #252525 ;
--danger : #e06c75 ;
--font : 'JetBrains Mono' , 'Fira Code' , 'Cascadia Code' , 'IBM Plex Mono' , monospace ;
--line : 1.6 ;
--col-gap : 2 rem ;
--sidebar-w : 240 px ;
--touch-min : 44 px ;
}
html {
height : 100 % ;
font-size : 15 px ;
-webkit- text-size-adjust : 100 % ;
}
body {
font-family : var ( - - font ) ;
background : var ( - - bg ) ;
color : var ( - - ink ) ;
line-height : var ( - - line ) ;
height : 100 vh ;
height : 100 dvh ;
max-height : 100 dvh ;
display : flex ;
flex-direction : column ;
overflow : hidden ;
}
/* ── Masthead ──────────────────────────────────────────────────── */
. masthead {
background : var ( - - header - bg ) ;
border-bottom : 3 px double var ( - - border - light ) ;
padding : 0.75 rem 1 rem ;
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 0.5 rem ;
user-select : none ;
flex-shrink : 0 ;
min-height : var ( - - touch - min ) ;
}
. masthead h1 {
font-size : 1.1 rem ;
font-weight : 700 ;
text-transform : uppercase ;
letter-spacing : 0.12 em ;
color : var ( - - accent ) ;
white-space : nowrap ;
overflow : hidden ;
text-overflow : ellipsis ;
}
. masthead h1 span { color : var ( - - ink - dim ) ; font-weight : 400 ; }
. masthead . edition {
font-size : 0.65 rem ;
color : var ( - - ink - dim ) ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
white-space : nowrap ;
2026-06-24 22:50:54 +00:00
display : flex ;
align-items : center ;
gap : 0.5 rem ;
2026-06-23 23:54:37 +00:00
}
2026-06-24 22:50:54 +00:00
. masthead . admin-btn {
font-family : var ( - - font ) ;
font-size : 0.65 rem ;
background : none ;
border : 1 px solid var ( - - border - light ) ;
color : var ( - - accent ) ;
padding : 0.25 rem 0.5 rem ;
cursor : pointer ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
min-height : 2 rem ;
}
. masthead . admin-btn : hover { background : var ( - - hover ) ; border-color : var ( - - accent ) ; }
2026-06-23 23:54:37 +00:00
. masthead . edition . dot {
display : inline-block ;
width : 6 px ; height : 6 px ;
border-radius : 50 % ;
background : var ( - - accent3 ) ;
margin-right : 0.3 rem ;
vertical-align : middle ;
animation : pulse 2 s infinite ;
}
@ keyframes pulse { 0 % , 100 % { opacity : 1 ; } 50 % { opacity : 0.3 ; } }
/* Hamburger — visible only on mobile */
. hamburger {
display : none ;
background : none ;
border : none ;
color : var ( - - ink ) ;
font-size : 1.3 rem ;
cursor : pointer ;
padding : 0.4 rem ;
line-height : 1 ;
min-width : var ( - - touch - min ) ;
min-height : var ( - - touch - min ) ;
align-items : center ;
justify-content : center ;
}
. hamburger : hover { color : var ( - - accent ) ; }
/* ── Layout ────────────────────────────────────────────────────── */
. layout {
display : flex ;
flex : 1 ;
min-height : 0 ;
position : relative ;
}
. sidebar {
width : var ( - - sidebar - w ) ;
flex-shrink : 0 ;
background : var ( - - paper ) ;
border-right : 1 px solid var ( - - border ) ;
display : flex ;
flex-direction : column ;
overflow : hidden ;
transition : transform 0.25 s ease ;
}
. main {
flex : 1 ;
display : flex ;
flex-direction : column ;
overflow : hidden ;
min-width : 0 ;
min-height : 0 ;
}
/* ── Sidebar ───────────────────────────────────────────────────── */
. sidebar-header {
padding : 0.6 rem 0.75 rem ;
border-bottom : 1 px solid var ( - - border ) ;
font-size : 0.65 rem ;
text-transform : uppercase ;
letter-spacing : 0.12 em ;
color : var ( - - ink - dim ) ;
display : flex ;
justify-content : space-between ;
align-items : center ;
min-height : var ( - - touch - min ) ;
}
. sidebar-header . btn-new {
font-family : var ( - - font ) ;
font-size : 0.65 rem ;
background : none ;
border : 1 px solid var ( - - border - light ) ;
color : var ( - - accent ) ;
padding : 0.3 rem 0.6 rem ;
cursor : pointer ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
min-height : var ( - - touch - min ) ;
display : flex ;
align-items : center ;
}
. sidebar-header . btn-new : hover { background : var ( - - hover ) ; }
. project-list {
flex : 1 ;
overflow-y : auto ;
-webkit- overflow-scrolling : touch ;
padding : 0.25 rem 0 ;
}
. project-item {
padding : 0.6 rem 0.75 rem ;
cursor : pointer ;
border-left : 3 px solid transparent ;
display : flex ;
justify-content : space-between ;
align-items : center ;
font-size : 0.8 rem ;
min-height : var ( - - touch - min ) ;
-webkit- tap-highlight-color : transparent ;
}
. project-item : hover { background : var ( - - hover ) ; }
. project-item : active { background : var ( - - hover ) ; }
. project-item . active {
background : var ( - - hover ) ;
border-left-color : var ( - - accent ) ;
color : var ( - - accent ) ;
}
. project-item . chevron {
color : var ( - - ink - dim ) ;
width : 0.9 rem ;
flex-shrink : 0 ;
}
. project-item . version {
font-size : 0.65 rem ;
color : var ( - - ink - dim ) ;
flex-shrink : 0 ;
margin-left : 0.5 rem ;
}
. project-item . name {
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
flex : 1 ;
}
. project-files {
margin : 0.1 rem 0 0.35 rem 0 ;
border-left : 1 px solid var ( - - border ) ;
margin-left : 1.25 rem ;
}
. project-file-item {
display : flex ;
justify-content : space-between ;
align-items : center ;
gap : 0.4 rem ;
padding : 0.35 rem 0.75 rem 0.35 rem 0.65 rem ;
cursor : pointer ;
font-size : 0.72 rem ;
color : var ( - - ink - dim ) ;
min-height : 2 rem ;
border-left : 2 px solid transparent ;
-webkit- tap-highlight-color : transparent ;
}
. project-file-item : hover { background : var ( - - hover ) ; color : var ( - - ink ) ; }
. project-file-item . active {
color : var ( - - accent ) ;
border-left-color : var ( - - accent ) ;
background : rgba ( 229 , 192 , 123 , 0.08 ) ;
}
. project-file-name {
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. project-file-version {
font-size : 0.58 rem ;
color : var ( - - ink - dim ) ;
flex-shrink : 0 ;
}
. project-files-loading {
padding : 0.35 rem 0.75 rem 0.35 rem 0.65 rem ;
color : var ( - - ink - dim ) ;
font-size : 0.68 rem ;
font-style : italic ;
}
. sidebar-footer {
padding : 0.4 rem 0.75 rem ;
border-top : 1 px solid var ( - - border ) ;
font-size : 0.65 rem ;
color : var ( - - ink - dim ) ;
display : flex ;
gap : 0.25 rem ;
flex-wrap : wrap ;
}
. sidebar-footer button {
font-family : var ( - - font ) ;
font-size : 0.65 rem ;
background : none ;
border : none ;
color : var ( - - ink - dim ) ;
cursor : pointer ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
padding : 0.3 rem 0.4 rem ;
min-height : var ( - - touch - min ) ;
display : flex ;
align-items : center ;
}
. sidebar-footer button : hover { color : var ( - - accent ) ; }
. sidebar-footer button . active-tab { color : var ( - - accent ) ; }
/* ── Search bar ────────────────────────────────────────────────── */
. search-bar {
padding : 0.5 rem 0.75 rem ;
border-bottom : 1 px solid var ( - - border ) ;
display : flex ;
gap : 0.4 rem ;
align-items : center ;
flex-shrink : 0 ;
}
. search-bar input {
flex : 1 ;
min-width : 0 ;
font-family : var ( - - font ) ;
font-size : 0.8 rem ;
background : var ( - - input - bg ) ;
border : 1 px solid var ( - - border - light ) ;
color : var ( - - ink ) ;
padding : 0.4 rem 0.6 rem ;
outline : none ;
min-height : var ( - - touch - min ) ;
-webkit- appearance : none ;
border-radius : 0 ;
}
. search-bar input : focus { border-color : var ( - - accent ) ; }
. search-bar input :: placeholder { color : var ( - - ink - dim ) ; font-style : italic ; }
. search-bar . search-hint {
font-size : 0.65 rem ;
color : var ( - - ink - dim ) ;
white-space : nowrap ;
flex-shrink : 0 ;
}
/* ── Content area ──────────────────────────────────────────────── */
. content {
flex : 1 ;
min-height : 0 ;
overflow-x : hidden ;
overflow-y : auto ;
-webkit- overflow-scrolling : touch ;
overscroll-behavior : contain ;
touch-action : pan-y ;
padding : 1 rem ;
display : block ;
}
/* ── File tabs ─────────────────────────────────────────────────── */
. file-tabs {
display : flex ;
gap : 0 ;
overflow-x : auto ;
border-bottom : 1 px solid var ( - - border ) ;
margin-bottom : 0.5 rem ;
flex-shrink : 0 ;
-webkit- overflow-scrolling : touch ;
}
. file-tab {
font-family : var ( - - font ) ;
font-size : 0.7 rem ;
text-transform : uppercase ;
letter-spacing : 0.06 em ;
background : none ;
border : none ;
border-bottom : 2 px solid transparent ;
color : var ( - - ink - dim ) ;
padding : 0.5 rem 0.75 rem ;
cursor : pointer ;
white-space : nowrap ;
display : flex ;
align-items : center ;
gap : 0.3 rem ;
min-height : var ( - - touch - min ) ;
-webkit- tap-highlight-color : transparent ;
}
. file-tab : hover { color : var ( - - ink ) ; background : var ( - - hover ) ; }
. file-tab . active {
color : var ( - - accent ) ;
border-bottom-color : var ( - - accent ) ;
}
. file-tab-version {
font-size : 0.55 rem ;
color : var ( - - ink - dim ) ;
background : var ( - - input - bg ) ;
padding : 0.1 rem 0.25 rem ;
border-radius : 2 px ;
}
. file-tab . active . file-tab-version { color : var ( - - accent ) ; }
. file-tab-add {
color : var ( - - accent2 ) ;
font-size : 1 rem ;
padding : 0.5 rem 0.6 rem ;
}
/* ── Article body — single column ──────────────────────────────── */
. newspaper {
display : block ;
overflow : visible ;
padding-right : 0.25 rem ;
padding-bottom : env ( safe - area - inset - bottom , 0 ) ;
column-count : 1 ;
column-gap : 0 ;
column-rule : none ;
}
. newspaper h1 { column-span : none ; }
. newspaper h2 { column-span : none ; }
. newspaper h3 { break-inside : avoid ; }
. newspaper pre { break-inside : avoid ; }
. newspaper table { break-inside : avoid ; }
. newspaper img { max-width : 100 % ; height : auto ; }
/* ── Markdown content ──────────────────────────────────────────── */
. content h1 {
font-size : 1.3 rem ;
font-weight : 700 ;
text-transform : uppercase ;
letter-spacing : 0.05 em ;
color : var ( - - ink - bright ) ;
margin : 0 0 0.6 rem 0 ;
padding-bottom : 0.4 rem ;
border-bottom : 2 px solid var ( - - border - light ) ;
word-break : break-word ;
}
. content h2 {
font-size : 1.05 rem ;
font-weight : 600 ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
color : var ( - - accent ) ;
margin : 1.2 rem 0 0.4 rem 0 ;
word-break : break-word ;
}
. content h3 {
font-size : 0.95 rem ;
font-weight : 600 ;
color : var ( - - accent2 ) ;
margin : 0.8 rem 0 0.3 rem 0 ;
word-break : break-word ;
}
. content p { margin : 0 0 0.6 rem 0 ; word-break : break-word ; }
. content a { color : var ( - - accent2 ) ; text-decoration : underline ; text-underline-offset : 2 px ; word-break : break-word ; }
. content a : hover { color : var ( - - accent ) ; }
. content strong { color : var ( - - ink - bright ) ; }
. content em { color : var ( - - ink - dim ) ; }
. content blockquote {
border-left : 3 px solid var ( - - accent ) ;
padding : 0.4 rem 0.75 rem ;
margin : 0.6 rem 0 ;
background : var ( - - input - bg ) ;
font-style : italic ;
color : var ( - - ink - dim ) ;
}
. content ul , . content ol { margin : 0 0 0.6 rem 1.2 rem ; }
. content li { margin : 0.2 rem 0 ; word-break : break-word ; }
. content hr {
border : none ;
border-top : 1 px solid var ( - - border - light ) ;
margin : 1.2 rem 0 ;
}
. content code {
font-family : var ( - - font ) ;
font-size : 0.85 em ;
background : var ( - - input - bg ) ;
padding : 0.1 em 0.3 em ;
border : 1 px solid var ( - - border ) ;
border-radius : 2 px ;
word-break : break-word ;
}
. content pre {
background : var ( - - input - bg ) ;
border : 1 px solid var ( - - border ) ;
padding : 0.6 rem 0.75 rem ;
margin : 0.6 rem 0 ;
overflow-x : auto ;
-webkit- overflow-scrolling : touch ;
font-size : 0.75 rem ;
line-height : 1.4 ;
}
. content pre code {
background : none ;
border : none ;
padding : 0 ;
font-size : inherit ;
word-break : normal ;
white-space : pre ;
}
. content table {
width : 100 % ;
border-collapse : collapse ;
margin : 0.6 rem 0 ;
font-size : 0.8 rem ;
display : block ;
overflow-x : auto ;
-webkit- overflow-scrolling : touch ;
}
. content th , . content td {
border : 1 px solid var ( - - border ) ;
padding : 0.35 rem 0.5 rem ;
text-align : left ;
white-space : nowrap ;
}
. content th {
background : var ( - - input - bg ) ;
color : var ( - - accent ) ;
text-transform : uppercase ;
letter-spacing : 0.05 em ;
font-size : 0.7 rem ;
}
. content tr : nth-child ( even ) { background : rgba ( 255 , 255 , 255 , 0.02 ) ; }
/* ── Metadata header (gold, vault-style) ──────────────────────── */
. ctxd-header {
color : var ( - - accent ) ;
font-family : var ( - - font ) ;
font-size : 0.85 rem ;
line-height : 1.7 ;
text-transform : uppercase ;
letter-spacing : 0.03 em ;
white-space : pre-wrap ;
margin-bottom : 0.5 rem ;
}
. ctxd-header + hr { margin-top : 0 ; }
/* ── Editor ─────────────────────────────────────────────────────── */
. editor-pane { display : none ; }
. editor-pane . active {
display : block ;
min-height : 0 ;
overflow : visible ;
}
. editor-toolbar {
display : flex ;
gap : 0.4 rem ;
padding : 0.4 rem 0 ;
border-bottom : 1 px solid var ( - - border ) ;
margin-bottom : 0.6 rem ;
align-items : center ;
flex-wrap : wrap ;
flex-shrink : 0 ;
}
. editor-toolbar . meta {
font-size : 0.65 rem ;
color : var ( - - ink - dim ) ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
}
. editor-toolbar . meta strong { color : var ( - - ink - bright ) ; }
. editor-toolbar . spacer { flex : 1 ; min-width : 0.5 rem ; }
. editor-toolbar button {
font-family : var ( - - font ) ;
font-size : 0.7 rem ;
background : none ;
border : 1 px solid var ( - - border - light ) ;
color : var ( - - ink ) ;
padding : 0.3 rem 0.6 rem ;
cursor : pointer ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
min-height : var ( - - touch - min ) ;
display : flex ;
align-items : center ;
-webkit- tap-highlight-color : transparent ;
}
. editor-toolbar button : hover { background : var ( - - hover ) ; border-color : var ( - - accent ) ; color : var ( - - accent ) ; }
. editor-toolbar button . primary {
background : var ( - - accent ) ;
color : var ( - - bg ) ;
border-color : var ( - - accent ) ;
font-weight : 600 ;
}
. editor-toolbar button . primary : hover { background : #d4ae5c ; }
. editor-toolbar button . danger { color : var ( - - danger ) ; border-color : var ( - - danger ) ; }
. editor-toolbar button . danger : hover { background : var ( - - danger ) ; color : var ( - - bg ) ; }
. editor-split {
display : flex ;
flex : 1 ;
gap : 0.75 rem ;
min-height : 0 ;
}
. editor-split textarea {
flex : 1 ;
font-family : var ( - - font ) ;
font-size : 0.8 rem ;
line-height : 1.6 ;
background : var ( - - input - bg ) ;
border : 1 px solid var ( - - border - light ) ;
color : var ( - - ink ) ;
padding : 0.6 rem ;
resize : none ;
outline : none ;
min-height : 200 px ;
-webkit- appearance : none ;
border-radius : 0 ;
}
. editor-split textarea : focus { border-color : var ( - - accent ) ; }
. editor-preview {
flex : 1 ;
overflow-y : auto ;
-webkit- overflow-scrolling : touch ;
padding : 0 0.6 rem ;
border-left : 1 px solid var ( - - border ) ;
}
/* ── Search results ────────────────────────────────────────────── */
. search-results { display : none ; }
. search-results . active { display : block ; }
. search-result-item {
padding : 0.6 rem 0.75 rem ;
border-bottom : 1 px solid var ( - - border ) ;
cursor : pointer ;
min-height : var ( - - touch - min ) ;
-webkit- tap-highlight-color : transparent ;
}
. search-result-item : hover { background : var ( - - hover ) ; }
. search-result-item : active { background : var ( - - hover ) ; }
. search-result-item . src {
font-size : 0.65 rem ;
color : var ( - - ink - dim ) ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
}
. search-result-item . path { color : var ( - - accent2 ) ; font-size : 0.8 rem ; word-break : break-word ; }
. search-result-item . snippet {
font-size : 0.75 rem ;
color : var ( - - ink - dim ) ;
margin-top : 0.2 rem ;
line-height : 1.4 ;
word-break : break-word ;
}
. search-result-item . snippet em { color : var ( - - accent ) ; font-style : normal ; background : rgba ( 229 , 192 , 123 , 0.15 ) ; padding : 0 2 px ; }
/* ── Audit log ─────────────────────────────────────────────────── */
. audit-table { width : 100 % ; font-size : 0.75 rem ; display : block ; overflow-x : auto ; -webkit- overflow-scrolling : touch ; }
. audit-table th {
position : sticky ;
top : 0 ;
background : var ( - - paper ) ;
white-space : nowrap ;
}
. audit-table td { white-space : nowrap ; }
/* ── New project dialog ────────────────────────────────────────── */
. dialog-overlay {
display : none ;
position : fixed ;
inset : 0 ;
background : rgba ( 0 , 0 , 0 , 0.6 ) ;
z-index : 100 ;
align-items : center ;
justify-content : center ;
padding : 1 rem ;
}
. dialog-overlay . active { display : flex ; }
. dialog {
background : var ( - - paper ) ;
border : 1 px solid var ( - - border - light ) ;
padding : 1.25 rem ;
width : 100 % ;
max-width : 420 px ;
}
. dialog h2 {
font-size : 0.95 rem ;
text-transform : uppercase ;
letter-spacing : 0.1 em ;
color : var ( - - accent ) ;
margin-bottom : 0.75 rem ;
}
. dialog label {
display : block ;
font-size : 0.7 rem ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
color : var ( - - ink - dim ) ;
margin-bottom : 0.2 rem ;
}
2026-06-24 22:50:54 +00:00
. dialog input ,
. dialog select {
2026-06-23 23:54:37 +00:00
width : 100 % ;
font-family : var ( - - font ) ;
font-size : 0.85 rem ;
background : var ( - - input - bg ) ;
border : 1 px solid var ( - - border - light ) ;
color : var ( - - ink ) ;
padding : 0.4 rem 0.6 rem ;
margin-bottom : 0.6 rem ;
outline : none ;
min-height : var ( - - touch - min ) ;
-webkit- appearance : none ;
border-radius : 0 ;
}
2026-06-24 22:50:54 +00:00
. dialog input : focus ,
. dialog select : focus { border-color : var ( - - accent ) ; }
. admin-users {
margin : 0.75 rem 0 1 rem 0 ;
max-height : 220 px ;
overflow-y : auto ;
border : 1 px solid var ( - - border ) ;
}
. admin-user-row {
display : grid ;
grid-template-columns : 1 fr auto ;
gap : 0.35 rem 0.75 rem ;
padding : 0.55 rem 0.65 rem ;
border-bottom : 1 px solid var ( - - border ) ;
font-size : 0.75 rem ;
}
. admin-user-row : last-child { border-bottom : none ; }
. admin-user-row . user-id { color : var ( - - accent ) ; font-weight : 600 ; }
. admin-user-row . user-name { color : var ( - - ink ) ; }
. admin-user-row . user-role { color : var ( - - ink - dim ) ; text-transform : uppercase ; font-size : 0.65 rem ; }
. admin-user-row . user-date { color : var ( - - ink - dim ) ; font-size : 0.65 rem ; }
. admin-section {
font-size : 0.7 rem ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
color : var ( - - accent ) ;
margin : 1 rem 0 0.35 rem 0 ;
}
. admin-divider { border : none ; border-top : 1 px solid var ( - - border ) ; margin : 1 rem 0 ; }
/* Admin dialog: tabs + scroll */
. dialog . dialog-admin {
max-width : min ( 92 vw , 640 px ) ;
width : 100 % ;
max-height : min ( 88 vh , 600 px ) ;
display : flex ;
flex-direction : column ;
overflow : hidden ;
padding : 1 rem 1.15 rem 0.85 rem ;
}
. dialog . dialog-admin > h2 {
flex-shrink : 0 ;
margin-bottom : 0.5 rem ;
}
. admin-tabs {
display : flex ;
flex-shrink : 0 ;
gap : 0 ;
border-bottom : 1 px solid var ( - - border ) ;
margin-bottom : 0 ;
}
. admin-tab {
flex : 1 ;
font-family : var ( - - font ) ;
font-size : 0.65 rem ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
padding : 0.55 rem 0.5 rem ;
border : none ;
border-bottom : 2 px solid transparent ;
background : none ;
color : var ( - - ink - dim ) ;
cursor : pointer ;
min-height : var ( - - touch - min ) ;
}
. admin-tab : hover { color : var ( - - ink ) ; background : var ( - - hover ) ; }
. admin-tab . active {
color : var ( - - accent ) ;
border-bottom-color : var ( - - accent ) ;
font-weight : 600 ;
}
. admin-tab-panels {
flex : 1 ;
min-height : 0 ;
overflow : hidden ;
display : flex ;
flex-direction : column ;
}
. admin-tab-panel {
display : none ;
flex : 1 ;
flex-direction : column ;
min-height : 0 ;
overflow : hidden ;
padding : 0.75 rem 0 0.5 rem ;
}
. admin-tab-panel . active {
display : flex ;
}
. admin-tab-scroll {
flex : 1 ;
min-height : 0 ;
overflow-y : auto ;
overflow-x : hidden ;
-webkit- overflow-scrolling : touch ;
padding-right : 0.15 rem ;
}
# admin-panel-users . admin-subtabs ,
# admin-panel-oauth . admin-subtabs ,
# admin-panel-projects . admin-subtabs {
flex-shrink : 0 ;
}
. admin-users-subpanel ,
. admin-oauth-subpanel ,
. admin-projects-subpanel {
display : none ;
}
. admin-users-subpanel . active ,
. admin-oauth-subpanel . active ,
. admin-projects-subpanel . active {
display : block ;
}
. admin-subtabs {
display : flex ;
gap : 0.25 rem ;
margin-bottom : 0.5 rem ;
flex-shrink : 0 ;
}
. admin-subtab {
font-family : var ( - - font ) ;
font-size : 0.6 rem ;
text-transform : uppercase ;
letter-spacing : 0.06 em ;
padding : 0.35 rem 0.55 rem ;
border : 1 px solid var ( - - border ) ;
background : none ;
color : var ( - - ink - dim ) ;
cursor : pointer ;
}
. admin-subtab . active {
border-color : var ( - - accent ) ;
color : var ( - - accent ) ;
}
. user-list-table {
width : 100 % ;
border-collapse : collapse ;
font-size : 0.7 rem ;
}
. user-list-table th ,
. user-list-table td {
text-align : left ;
padding : 0.45 rem 0.4 rem ;
border-bottom : 1 px solid var ( - - border ) ;
}
. user-list-table th {
font-size : 0.6 rem ;
text-transform : uppercase ;
letter-spacing : 0.06 em ;
color : var ( - - ink - dim ) ;
}
. user-state-active { color : var ( - - accent3 ) ; }
. user-state-inactive { color : var ( - - danger ) ; }
/* OAuth client list — compact, matches user table density */
# admin-oauth-list . admin-oauth-list {
font-size : 0.65 rem ;
line-height : 1.45 ;
}
. admin-oauth-row {
display : grid ;
grid-template-columns : 1 fr auto ;
align-items : center ;
gap : 0.5 rem 0.75 rem ;
padding : 0.45 rem 0.55 rem ;
border-bottom : 1 px solid var ( - - border ) ;
}
. admin-oauth-row : last-child { border-bottom : none ; }
. admin-oauth-row . oauth-client-meta {
min-width : 0 ;
}
. admin-oauth-row . oauth-client-id {
color : var ( - - accent ) ;
font-weight : 600 ;
font-size : 0.62 rem ;
word-break : break-all ;
line-height : 1.35 ;
}
. admin-oauth-row . oauth-client-name {
color : var ( - - ink ) ;
font-size : 0.65 rem ;
margin-top : 0.15 rem ;
}
. admin-oauth-row . oauth-client-uri {
color : var ( - - ink - dim ) ;
font-size : 0.58 rem ;
margin-top : 0.2 rem ;
word-break : break-all ;
line-height : 1.35 ;
}
. dialog . dialog-admin button . oauth-revoke ,
. dialog . dialog-admin . manage-user-actions button {
font-family : var ( - - font ) ;
font-size : 0.62 rem ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
padding : 0.3 rem 0.55 rem ;
min-height : 2 rem ;
cursor : pointer ;
border : 1 px solid var ( - - border - light ) ;
background : var ( - - input - bg ) ;
color : var ( - - ink ) ;
border-radius : 0 ;
-webkit- appearance : none ;
}
. dialog . dialog-admin . manage-user-actions button : hover {
background : var ( - - hover ) ;
border-color : var ( - - accent ) ;
color : var ( - - accent ) ;
}
. dialog . dialog-admin . manage-user-actions button . primary {
background : var ( - - accent ) ;
color : var ( - - bg ) ;
border-color : var ( - - accent ) ;
}
. dialog . dialog-admin . manage-user-actions button . primary : hover {
background : #d4ae5c ;
border-color : #d4ae5c ;
color : var ( - - bg ) ;
}
. dialog . dialog-admin button . oauth-revoke {
border-color : var ( - - border - light ) ;
color : var ( - - danger ) ;
background : none ;
align-self : center ;
}
. dialog . dialog-admin button . oauth-revoke : hover {
background : var ( - - hover ) ;
border-color : var ( - - danger ) ;
color : var ( - - danger ) ;
}
. manage-user-actions {
display : flex ;
flex-wrap : wrap ;
gap : 0.35 rem ;
margin-top : 0.5 rem ;
padding-bottom : 0.75 rem ;
}
. dialog label . checkbox-row {
display : flex ;
align-items : center ;
gap : 0.5 rem ;
text-transform : none ;
letter-spacing : 0 ;
font-size : 0.75 rem ;
color : var ( - - ink ) ;
margin : 0.5 rem 0 ;
}
. dialog label . checkbox-row input { width : auto ; }
. admin-dialog-footer {
flex-shrink : 0 ;
margin-top : 0.5 rem ;
padding-top : 0.5 rem ;
border-top : 1 px solid var ( - - border ) ;
}
. dialog . dialog-admin . admin-users {
max-height : 160 px ;
}
. oauth-credentials {
margin : 0.75 rem 0 ;
padding : 0.65 rem ;
background : var ( - - input - bg ) ;
border : 1 px solid var ( - - border ) ;
font-size : 0.65 rem ;
line-height : 1.45 ;
white-space : pre-wrap ;
word-break : break-all ;
max-height : 200 px ;
overflow-y : auto ;
}
2026-06-23 23:54:37 +00:00
. dialog . actions {
display : flex ;
gap : 0.5 rem ;
justify-content : flex-end ;
margin-top : 0.5 rem ;
}
. dialog . actions button {
font-family : var ( - - font ) ;
font-size : 0.7 rem ;
padding : 0.4 rem 0.75 rem ;
cursor : pointer ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
border : 1 px solid var ( - - border - light ) ;
background : none ;
color : var ( - - ink ) ;
min-height : var ( - - touch - min ) ;
display : flex ;
align-items : center ;
}
. dialog . actions button : hover { background : var ( - - hover ) ; }
. dialog . actions button . primary {
background : var ( - - accent ) ;
color : var ( - - bg ) ;
border-color : var ( - - accent ) ;
}
/* ── Import / file upload ──────────────────────────────────────── */
. import-section { margin-bottom : 0.6 rem ; }
. file-drop {
display : flex ;
align-items : center ;
gap : 0.5 rem ;
padding : 0.5 rem ;
border : 1 px dashed var ( - - border - light ) ;
background : var ( - - input - bg ) ;
min-height : var ( - - touch - min ) ;
}
. file-btn {
font-family : var ( - - font ) ;
font-size : 0.7 rem ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
border : 1 px solid var ( - - border - light ) ;
background : none ;
color : var ( - - ink ) ;
padding : 0.3 rem 0.6 rem ;
cursor : pointer ;
min-height : var ( - - touch - min ) ;
display : flex ;
align-items : center ;
flex-shrink : 0 ;
}
. file-btn : hover { background : var ( - - hover ) ; border-color : var ( - - accent ) ; color : var ( - - accent ) ; }
. file-name {
font-size : 0.75 rem ;
color : var ( - - ink - dim ) ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. dialog-hint {
font-size : 0.7 rem ;
color : var ( - - ink - dim ) ;
margin-bottom : 0.6 rem ;
line-height : 1.5 ;
}
/* ── Toast ──────────────────────────────────────────────────────── */
. toast {
position : fixed ;
bottom : 1 rem ;
right : 1 rem ;
left : 1 rem ;
background : var ( - - paper ) ;
border : 1 px solid var ( - - border - light ) ;
padding : 0.6 rem 0.75 rem ;
font-size : 0.75 rem ;
color : var ( - - ink ) ;
z-index : 200 ;
opacity : 0 ;
transition : opacity 0.3 s ;
pointer-events : none ;
max-width : 400 px ;
margin : 0 auto ;
}
. toast . show { opacity : 1 ; }
. toast . error { border-color : var ( - - danger ) ; color : var ( - - danger ) ; }
. toast . success { border-color : var ( - - accent3 ) ; color : var ( - - accent3 ) ; }
/* ── Loading ───────────────────────────────────────────────────── */
. loading {
text-align : center ;
padding : 2 rem 1 rem ;
color : var ( - - ink - dim ) ;
font-style : italic ;
}
/* ── Scrollbar ─────────────────────────────────────────────────── */
:: -webkit-scrollbar { width : 4 px ; height : 4 px ; }
:: -webkit-scrollbar-track { background : var ( - - bg ) ; }
:: -webkit-scrollbar-thumb { background : var ( - - border - light ) ; }
:: -webkit-scrollbar-thumb : hover { background : var ( - - ink - dim ) ; }
/* ── Empty state ───────────────────────────────────────────────── */
. empty-state {
text-align : center ;
padding : 2 rem 1 rem ;
color : var ( - - ink - dim ) ;
font-style : italic ;
}
/* ── Sidebar overlay (mobile) ──────────────────────────────────── */
. sidebar-overlay {
display : none ;
position : fixed ;
inset : 0 ;
background : rgba ( 0 , 0 , 0 , 0.5 ) ;
z-index : 50 ;
}
/* ═══════════════════════════════════════════════════════════════════
RESPONSIVE — Mobile first, layered up
═══════════════════════════════════════════════════════════════════ */
/* ── Small phones (< 480px) ────────────────────────────────────── */
@ media ( max-width : 479px ) {
html { font-size : 14 px ; }
. masthead { padding : 0.5 rem 0.6 rem ; }
. masthead h1 { font-size : 0.95 rem ; letter-spacing : 0.1 em ; }
. masthead h1 span { display : none ; }
. masthead . edition { font-size : 0.6 rem ; }
. content { padding : 0.6 rem ; }
. search-bar { padding : 0.4 rem 0.5 rem ; }
. search-bar input { font-size : 0.75 rem ; }
. editor-split { flex-direction : column ; }
. editor-split textarea { min-height : 150 px ; }
. editor-preview { border-left : none ; border-top : 1 px solid var ( - - border ) ; padding : 0.6 rem 0 0 0 ; }
. editor-toolbar button { font-size : 0.65 rem ; padding : 0.25 rem 0.5 rem ; }
. newspaper { column-count : 1 ; }
. sidebar { width : 100 % ; max-width : 280 px ; }
. dialog { padding : 1 rem ; }
}
/* ── Phones & small tablets (480px – 767px) ───────────────────── */
@ media ( max-width : 767px ) {
. hamburger { display : flex ; }
. sidebar {
position : fixed ;
top : 0 ;
left : 0 ;
bottom : 0 ;
z-index : 60 ;
transform : translateX ( -100 % ) ;
width : 80 vw ;
max-width : 300 px ;
}
. sidebar . open { transform : translateX ( 0 ) ; }
. sidebar-overlay . active { display : block ; }
. newspaper { column-count : 1 ; }
. editor-split { flex-direction : column ; }
. editor-split textarea { min-height : 180 px ; }
. editor-preview { border-left : none ; border-top : 1 px solid var ( - - border ) ; padding : 0.6 rem 0 0 0 ; }
. masthead h1 span { display : none ; }
}
/* ── Tablets portrait (768px – 1023px) ─────────────────────────── */
@ media ( min-width : 768px ) and ( max-width : 1023px ) {
html { font-size : 14.5 px ; }
. sidebar { width : 200 px ; }
. content { padding : 1 rem 1.25 rem ; }
. newspaper { column-count : 1 ; column-gap : 0 ; column-rule : none ; }
. editor-split textarea { min-height : 250 px ; }
. masthead h1 { font-size : 1.05 rem ; }
}
/* ── Tablets landscape (1024px – 1279px) ────────────────────────── */
@ media ( min-width : 1024px ) and ( max-width : 1279px ) {
. sidebar { width : 220 px ; }
. content { padding : 1.25 rem 1.5 rem ; }
}
/* ── Large desktop (> 1280px) ──────────────────────────────────── */
@ media ( min-width : 1280px ) {
. content { padding : 1.5 rem 2 rem ; }
}
/* ── Touch-friendly: hide hover on touch devices ────────────────── */
@ media ( hover : none ) {
. project-item : hover { background : none ; }
. project-item : active { background : var ( - - hover ) ; }
. search-result-item : hover { background : none ; }
. search-result-item : active { background : var ( - - hover ) ; }
. editor-toolbar button : hover { background : none ; border-color : var ( - - border - light ) ; color : var ( - - ink ) ; }
. editor-toolbar button : active { background : var ( - - hover ) ; }
. sidebar-footer button : hover { color : var ( - - ink - dim ) ; }
. sidebar-footer button : active { color : var ( - - accent ) ; }
. dialog . actions button : hover { background : none ; }
. dialog . actions button : active { background : var ( - - hover ) ; }
}
/* ── Safe area insets (iPhone X+) ──────────────────────────────── */
@ supports ( padding : env ( safe-area-inset-bottom ) ) {
. sidebar { padding-bottom : env ( safe - area - inset - bottom ) ; }
. toast { bottom : calc ( 1 rem + env ( safe - area - inset - bottom ) ) ; }
}
< / style >
< / head >
< body >
<!-- ── Masthead ── -->
< header class = "masthead" >
< button class = "hamburger" id = "hamburger" aria-label = "Toggle sidebar" > ☰< / button >
2026-06-24 22:50:54 +00:00
< h1 > 📋 CTXD < span > · project context< / span > < / h1 >
2026-06-23 23:54:37 +00:00
< div class = "edition" >
2026-06-24 22:50:54 +00:00
< button class = "admin-btn" id = "admin-btn" onclick = "showAdminDialog()" style = "display:none" > admin< / button >
< button class = "admin-btn" id = "logout-btn" onclick = "logout()" style = "display:none" > logout< / button >
< span > < span class = "dot" > < / span > < span id = "status-text" > connecting…< / span > < / span >
2026-06-23 23:54:37 +00:00
< / div >
< / header >
<!-- ── Layout ── -->
< div class = "layout" >
<!-- Sidebar overlay (mobile) -->
< div class = "sidebar-overlay" id = "sidebar-overlay" > < / div >
<!-- Sidebar -->
< nav class = "sidebar" id = "sidebar" >
< div class = "sidebar-header" >
< span > projects< / span >
< button class = "btn-new" onclick = "showNewProjectDialog()" > + new< / button >
< / div >
< div class = "project-list" id = "project-list" > < / div >
< div class = "sidebar-footer" >
< button class = "active-tab" data-tab = "context" onclick = "switchTab('context')" > context< / button >
< button data-tab = "audit" onclick = "switchTab('audit')" > audit< / button >
< button data-tab = "snapshots" onclick = "switchTab('snapshots')" > snapshots< / button >
< / div >
< / nav >
<!-- Main -->
< div class = "main" >
< div class = "search-bar" >
< input type = "text" id = "search-input" placeholder = "search context…" autocomplete = "off" autocorrect = "off" autocapitalize = "off" spellcheck = "false" onkeydown = "if(event.key==='Enter')doSearch()" >
< span class = "search-hint" > ⏎< / span >
< / div >
< div class = "content" id = "content-area" >
< div class = "loading" > select a project from the sidebar< / div >
< / div >
< / div >
< / div >
<!-- ── New project dialog ── -->
< div class = "dialog-overlay" id = "new-project-dialog" >
< div class = "dialog" >
< h2 > new project< / h2 >
< label > project id< / label >
< input type = "text" id = "new-pid" placeholder = "e.g. my-project" autocomplete = "off" autocorrect = "off" autocapitalize = "off" spellcheck = "false" onkeydown = "if(event.key==='Enter')createProject()" >
< label > display name< / label >
< input type = "text" id = "new-name" placeholder = "My Project" onkeydown = "if(event.key==='Enter')createProject()" >
< label > description< / label >
< input type = "text" id = "new-desc" placeholder = "What is this project?" onkeydown = "if(event.key==='Enter')createProject()" >
< div class = "import-section" >
< label > import context file (optional)< / label >
< div class = "file-drop" id = "file-drop" >
< input type = "file" id = "import-file" accept = ".md,.txt,.cursorrules,.windsurfrules" style = "display:none" >
< button class = "file-btn" onclick = "document.getElementById('import-file').click()" > choose file< / button >
< span class = "file-name" id = "import-file-name" > no file selected< / span >
< / div >
< / div >
< div class = "actions" >
< button onclick = "hideNewProjectDialog()" > cancel< / button >
< button class = "primary" onclick = "createProject()" > create< / button >
< / div >
< / div >
< / div >
<!-- ── Import dialog (existing project) ── -->
< div class = "dialog-overlay" id = "import-dialog" >
< div class = "dialog" >
< h2 > import context< / h2 >
< p class = "dialog-hint" > Upload an AGENTS.md, CLAUDE.md, .cursorrules, or any markdown/txt file. Existing headers and YAML frontmatter will be stripped automatically.< / p >
< div class = "file-drop" id = "file-drop-import" >
< input type = "file" id = "import-file-existing" accept = ".md,.txt,.cursorrules,.windsurfrules" style = "display:none" >
< button class = "file-btn" onclick = "document.getElementById('import-file-existing').click()" > choose file< / button >
< span class = "file-name" id = "import-file-name-existing" > no file selected< / span >
< / div >
< div class = "actions" >
< button onclick = "hideImportDialog()" > cancel< / button >
< button class = "primary" onclick = "importToExisting()" > import< / button >
< / div >
< / div >
< / div >
2026-06-24 22:50:54 +00:00
<!-- ── Admin dialog ── -->
< div class = "dialog-overlay" id = "admin-dialog" >
< div class = "dialog dialog-admin" >
< h2 > admin< / h2 >
< nav class = "admin-tabs" role = "tablist" >
< button type = "button" class = "admin-tab active" role = "tab" aria-selected = "true" data-admin-tab = "oauth" onclick = "switchAdminTab('oauth')" > oauth clients< / button >
< button type = "button" class = "admin-tab" role = "tab" aria-selected = "false" data-admin-tab = "users" onclick = "switchAdminTab('users')" > users< / button >
< button type = "button" class = "admin-tab" role = "tab" aria-selected = "false" data-admin-tab = "projects" onclick = "switchAdminTab('projects')" > projects< / button >
< / nav >
< div class = "admin-tab-panels" >
< div id = "admin-panel-oauth" class = "admin-tab-panel active" role = "tabpanel" >
< nav class = "admin-subtabs" role = "tablist" >
< button type = "button" class = "admin-subtab active" data-oauth-subtab = "list" onclick = "switchOAuthSubTab('list')" > client list< / button >
< button type = "button" class = "admin-subtab" data-oauth-subtab = "create" onclick = "switchOAuthSubTab('create')" > create client< / button >
< / nav >
< div class = "admin-tab-scroll" >
< div id = "admin-oauth-sub-list" class = "admin-oauth-subpanel active" >
< div class = "admin-users admin-oauth-list" id = "admin-oauth-list" style = "max-height:none;border:none;overflow:visible;" >
< div class = "loading" > loading oauth clients…< / div >
< / div >
< / div >
< div id = "admin-oauth-sub-create" class = "admin-oauth-subpanel" >
< p class = "dialog-hint" > Register OAuth clients for any MCP or OAuth consumer. Public connector URL: < code > /readonly/sse< / code > . Many clients auto-register via < code > POST /oauth/register< / code > ; use this form when you need explicit < code > client_id< / code > / < code > client_secret< / code > (secret shown once).< / p >
< label > client name< / label >
< input type = "text" id = "admin-oauth-name" value = "MCP connector" autocomplete = "off" >
< label > redirect uri< / label >
< input type = "url" id = "admin-oauth-redirect" placeholder = "https://your-app.example/oauth/callback" autocomplete = "off" >
< p class = "dialog-hint" style = "margin-top:0.25rem" > Use the callback URL your OAuth client documents.< / p >
< div class = "actions" style = "justify-content: flex-start; margin: 0.5rem 0;" >
< button class = "primary" type = "button" onclick = "createOAuthClient()" > generate client id / secret< / button >
< / div >
< pre id = "admin-oauth-result" class = "oauth-credentials" style = "display:none" > < / pre >
< / div >
< / div >
< / div >
< div id = "admin-panel-users" class = "admin-tab-panel" role = "tabpanel" >
< nav class = "admin-subtabs" role = "tablist" >
< button type = "button" class = "admin-subtab active" data-users-subtab = "list" onclick = "switchUsersSubTab('list')" > user list< / button >
< button type = "button" class = "admin-subtab" data-users-subtab = "manage" onclick = "switchUsersSubTab('manage')" > manage users< / button >
< / nav >
< div class = "admin-tab-scroll" >
< div id = "admin-users-sub-list" class = "admin-users-subpanel active" >
< div class = "admin-users" id = "admin-users-list" style = "max-height:none;border:none;overflow:visible;" >
< div class = "loading" > loading users…< / div >
< / div >
< / div >
< div id = "admin-users-sub-manage" class = "admin-users-subpanel" >
< p class = "dialog-hint" > Create a user or select one to edit. Inactive users cannot sign in.< / p >
< label > select user< / label >
< select id = "admin-manage-select" onchange = "onManageUserSelect()" >
< option value = "" > — new user —< / option >
< / select >
< label > user id< / label >
< input type = "text" id = "admin-user-id" placeholder = "e.g. joshua" autocomplete = "off" autocorrect = "off" autocapitalize = "off" spellcheck = "false" >
< label > display name< / label >
< input type = "text" id = "admin-display-name" placeholder = "Joshua" >
< label > role< / label >
< select id = "admin-role" >
< option value = "contributor" > contributor< / option >
< option value = "admin" > admin< / option >
< option value = "service" > service< / option >
< / select >
< label class = "checkbox-row" > < input type = "checkbox" id = "admin-user-active" checked > active (can sign in)< / label >
< label > password< / label >
< input type = "password" id = "admin-password" placeholder = "required for new users; leave blank to keep unchanged" autocomplete = "new-password" >
< div class = "manage-user-actions" >
< button class = "primary" type = "button" onclick = "saveManagedUser()" > save user< / button >
< button type = "button" onclick = "setManagedUserActive(false)" > inactivate< / button >
< button type = "button" onclick = "setManagedUserActive(true)" > activate< / button >
< button type = "button" onclick = "deleteManagedUser()" > delete< / button >
< button type = "button" onclick = "clearManageUserForm()" > clear< / button >
< / div >
< / div >
< / div >
< / div >
< div id = "admin-panel-projects" class = "admin-tab-panel" role = "tabpanel" >
< nav class = "admin-subtabs" role = "tablist" >
< button type = "button" class = "admin-subtab active" data-projects-subtab = "list" onclick = "switchProjectsSubTab('list')" > manage projects< / button >
< / nav >
< div class = "admin-tab-scroll" >
< div id = "admin-projects-sub-list" class = "admin-projects-subpanel active" >
< p class = "dialog-hint" > Delete a project by typing its name to confirm. This removes all context files, snapshots, and audit references (cascaded).< / p >
< div class = "admin-users" id = "admin-projects-list" style = "max-height:none;border:none;overflow:visible;" >
< div class = "loading" > loading projects…< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< div class = "admin-dialog-footer actions" >
< button type = "button" onclick = "hideAdminDialog()" > close< / button >
< / div >
< / div >
< / div >
2026-06-23 23:54:37 +00:00
<!-- ── Auth dialog ── -->
< div class = "dialog-overlay" id = "auth-dialog" >
< div class = "dialog" >
2026-06-24 22:50:54 +00:00
< h2 > sign in< / h2 >
< p class = "dialog-hint" > Sign in with your CTXD user id and password.< / p >
< label > user id< / label >
< input type = "text" id = "auth-user-id" placeholder = "e.g. joshua" autocomplete = "username" autocorrect = "off" autocapitalize = "off" spellcheck = "false" onkeydown = "if(event.key==='Enter')submitLogin()" >
< label > password< / label >
< input type = "password" id = "auth-password-input" placeholder = "password" autocomplete = "current-password" onkeydown = "if(event.key==='Enter')submitLogin()" >
2026-06-23 23:54:37 +00:00
< div class = "actions" >
2026-06-24 22:50:54 +00:00
< button class = "primary" onclick = "submitLogin()" > sign in< / button >
2026-06-23 23:54:37 +00:00
< / div >
< / div >
< / div >
<!-- ── Toast ── -->
< div class = "toast" id = "toast" > < / div >
< script >
// ── State ────────────────────────────────────────────────────────
const state = {
projects : [ ] ,
currentProject : null ,
currentFile : null ,
currentFileContent : '' ,
currentTab : 'context' ,
currentVersion : 0 ,
fileVersion : 0 ,
files : [ ] ,
filesByProject : { } ,
expandedProjects : new Set ( ) ,
2026-06-24 22:50:54 +00:00
users : [ ] ,
currentUser : null ,
2026-06-23 23:54:37 +00:00
searchResults : null ,
} ;
// ── API helpers ──────────────────────────────────────────────────
const API = window . location . origin ;
2026-06-24 22:50:54 +00:00
function getSessionToken ( ) {
// Check localStorage first (set by login JS)
let token = localStorage . getItem ( 'ctxd_session_token' ) || '' ;
if ( ! token ) {
// Fall back to cookie (set by server on login)
const cookies = document . cookie . split ( ';' ) ;
for ( const c of cookies ) {
const trimmed = c . trim ( ) ;
if ( trimmed . startsWith ( 'ctxd_session=' ) ) {
token = trimmed . substring ( 'ctxd_session=' . length ) ;
if ( token ) localStorage . setItem ( 'ctxd_session_token' , token ) ;
break ;
}
}
}
return token ;
2026-06-23 23:54:37 +00:00
}
2026-06-24 22:50:54 +00:00
function setSessionToken ( token ) {
localStorage . setItem ( 'ctxd_session_token' , token ) ;
2026-06-23 23:54:37 +00:00
}
2026-06-24 22:50:54 +00:00
function clearSession ( ) {
localStorage . removeItem ( 'ctxd_session_token' ) ;
2026-06-23 23:54:37 +00:00
localStorage . removeItem ( 'ctxd_api_key' ) ;
2026-06-24 22:50:54 +00:00
state . currentUser = null ;
updateAuthChrome ( ) ;
}
function authHeaders ( ) {
const token = getSessionToken ( ) ;
return token ? { Authorization : ` Bearer ${ token } ` } : { } ;
}
function writeActor ( ) {
return ( state . currentUser && state . currentUser . user _id ) ? state . currentUser . user _id : 'admin' ;
2026-06-23 23:54:37 +00:00
}
async function api ( method , path , body ) {
2026-06-24 22:50:54 +00:00
const opts = { method , headers : { ... authHeaders ( ) } } ;
2026-06-23 23:54:37 +00:00
if ( body ) {
opts . headers [ 'Content-Type' ] = 'application/json' ;
opts . body = JSON . stringify ( body ) ;
}
const res = await fetch ( API + path , opts ) ;
if ( res . status === 401 ) {
2026-06-24 22:50:54 +00:00
clearSession ( ) ;
2026-06-23 23:54:37 +00:00
showAuthDialog ( ) ;
throw new Error ( 'unauthorized' ) ;
}
if ( ! res . ok ) {
const text = await res . text ( ) ;
throw new Error ( ` ${ res . status } : ${ text . slice ( 0 , 200 ) } ` ) ;
}
return res . json ( ) ;
}
// ── Sidebar toggle (mobile) ─────────────────────────────────────
function openSidebar ( ) {
document . getElementById ( 'sidebar' ) . classList . add ( 'open' ) ;
document . getElementById ( 'sidebar-overlay' ) . classList . add ( 'active' ) ;
}
function closeSidebar ( ) {
document . getElementById ( 'sidebar' ) . classList . remove ( 'open' ) ;
document . getElementById ( 'sidebar-overlay' ) . classList . remove ( 'active' ) ;
}
document . getElementById ( 'hamburger' ) . addEventListener ( 'click' , openSidebar ) ;
document . getElementById ( 'sidebar-overlay' ) . addEventListener ( 'click' , closeSidebar ) ;
// ── Toast ────────────────────────────────────────────────────────
let toastTimer ;
// ── Auth dialog ──────────────────────────────────────────────────
function showAuthDialog ( ) {
document . getElementById ( 'auth-dialog' ) . classList . add ( 'active' ) ;
2026-06-24 22:50:54 +00:00
setTimeout ( ( ) => document . getElementById ( 'auth-user-id' ) . focus ( ) , 100 ) ;
2026-06-23 23:54:37 +00:00
}
function hideAuthDialog ( ) {
document . getElementById ( 'auth-dialog' ) . classList . remove ( 'active' ) ;
}
2026-06-24 22:50:54 +00:00
function updateAuthChrome ( ) {
const isAdmin = state . currentUser && state . currentUser . role === 'admin' ;
document . getElementById ( 'admin-btn' ) . style . display = isAdmin ? '' : 'none' ;
document . getElementById ( 'logout-btn' ) . style . display = state . currentUser ? '' : 'none' ;
const status = document . getElementById ( 'status-text' ) ;
if ( state . currentUser ) {
status . textContent = 'signed in · ' + ( state . currentUser . display _name || state . currentUser . user _id ) ;
}
}
async function submitLogin ( ) {
const user _id = document . getElementById ( 'auth-user-id' ) . value . trim ( ) ;
const password = document . getElementById ( 'auth-password-input' ) . value ;
if ( ! user _id || ! password ) return ;
2026-06-23 23:54:37 +00:00
try {
2026-06-24 22:50:54 +00:00
const res = await fetch ( API + '/auth/login' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { user _id , password } ) ,
} ) ;
if ( ! res . ok ) {
let detail = '' ;
try { detail = ( await res . json ( ) ) . error || '' ; } catch ( _ ) { }
if ( res . status === 401 ) {
showToast ( detail === 'invalid credentials' ? 'invalid user id or password' : ( 'login failed: ' + ( detail || res . status ) ) , 'error' ) ;
} else if ( res . status === 403 && detail === 'account inactive' ) {
showToast ( 'account inactive — contact an admin' , 'error' ) ;
} else {
showToast ( ` login failed ( ${ res . status } ): ${ detail || res . statusText } ` , 'error' ) ;
}
return ;
}
const data = await res . json ( ) ;
setSessionToken ( data . token ) ;
state . currentUser = data ;
2026-06-23 23:54:37 +00:00
hideAuthDialog ( ) ;
2026-06-24 22:50:54 +00:00
updateAuthChrome ( ) ;
showToast ( 'signed in' , 'success' ) ;
2026-06-23 23:54:37 +00:00
await loadProjects ( ) ;
} catch ( e ) {
2026-06-24 22:50:54 +00:00
showToast ( 'login failed: ' + e . message , 'error' ) ;
2026-06-23 23:54:37 +00:00
}
}
2026-06-24 22:50:54 +00:00
async function logout ( ) {
try { await api ( 'POST' , '/auth/logout' ) ; } catch ( e ) { /* ignore */ }
clearSession ( ) ;
document . getElementById ( 'project-list' ) . innerHTML = '' ;
document . getElementById ( 'content-area' ) . innerHTML = '<div class="loading">sign in to continue</div>' ;
showAuthDialog ( ) ;
}
2026-06-23 23:54:37 +00:00
function showToast ( msg , type ) {
const el = document . getElementById ( 'toast' ) ;
el . textContent = msg ;
el . className = 'toast show' + ( type ? ' ' + type : '' ) ;
clearTimeout ( toastTimer ) ;
toastTimer = setTimeout ( ( ) => el . classList . remove ( 'show' ) , 3000 ) ;
}
// ── Status ──────────────────────────────────────────────────────
async function checkStatus ( ) {
try {
2026-06-24 22:50:54 +00:00
const data = await fetch ( API + '/status' ) . then ( r => r . json ( ) ) ;
if ( ! state . currentUser ) {
document . getElementById ( 'status-text' ) . textContent = 'connected · ' + data . db . split ( '/' ) . pop ( ) ;
}
2026-06-23 23:54:37 +00:00
} catch ( e ) {
document . getElementById ( 'status-text' ) . textContent = 'disconnected' ;
}
}
2026-06-24 22:50:54 +00:00
// ── Admin ───────────────────────────────────────────────────────
function switchAdminTab ( tab ) {
document . querySelectorAll ( '.admin-tab' ) . forEach ( t => {
const on = t . dataset . adminTab === tab ;
t . classList . toggle ( 'active' , on ) ;
t . setAttribute ( 'aria-selected' , on ? 'true' : 'false' ) ;
} ) ;
document . querySelectorAll ( '.admin-tab-panel' ) . forEach ( p => {
p . classList . toggle ( 'active' , p . id === ` admin-panel- ${ tab } ` ) ;
} ) ;
}
function switchUsersSubTab ( sub ) {
const panel = document . getElementById ( 'admin-panel-users' ) ;
panel . querySelectorAll ( '[data-users-subtab]' ) . forEach ( t => {
t . classList . toggle ( 'active' , t . dataset . usersSubtab === sub ) ;
} ) ;
document . getElementById ( 'admin-users-sub-list' ) . classList . toggle ( 'active' , sub === 'list' ) ;
document . getElementById ( 'admin-users-sub-manage' ) . classList . toggle ( 'active' , sub === 'manage' ) ;
}
function switchOAuthSubTab ( sub ) {
const panel = document . getElementById ( 'admin-panel-oauth' ) ;
panel . querySelectorAll ( '[data-oauth-subtab]' ) . forEach ( t => {
t . classList . toggle ( 'active' , t . dataset . oauthSubtab === sub ) ;
} ) ;
document . getElementById ( 'admin-oauth-sub-list' ) . classList . toggle ( 'active' , sub === 'list' ) ;
document . getElementById ( 'admin-oauth-sub-create' ) . classList . toggle ( 'active' , sub === 'create' ) ;
}
function switchProjectsSubTab ( sub ) {
const panel = document . getElementById ( 'admin-panel-projects' ) ;
panel . querySelectorAll ( '[data-projects-subtab]' ) . forEach ( t => {
t . classList . toggle ( 'active' , t . dataset . projectsSubtab === sub ) ;
} ) ;
document . getElementById ( 'admin-projects-sub-list' ) . classList . toggle ( 'active' , sub === 'list' ) ;
}
function clearManageUserForm ( ) {
document . getElementById ( 'admin-manage-select' ) . value = '' ;
document . getElementById ( 'admin-user-id' ) . value = '' ;
document . getElementById ( 'admin-user-id' ) . readOnly = false ;
document . getElementById ( 'admin-display-name' ) . value = '' ;
document . getElementById ( 'admin-role' ) . value = 'contributor' ;
document . getElementById ( 'admin-user-active' ) . checked = true ;
document . getElementById ( 'admin-password' ) . value = '' ;
}
function fillManageUserForm ( u ) {
document . getElementById ( 'admin-user-id' ) . value = u . user _id || '' ;
document . getElementById ( 'admin-user-id' ) . readOnly = true ;
document . getElementById ( 'admin-display-name' ) . value = u . display _name || '' ;
document . getElementById ( 'admin-role' ) . value = u . role || 'contributor' ;
document . getElementById ( 'admin-user-active' ) . checked = u . active !== false ;
document . getElementById ( 'admin-password' ) . value = '' ;
}
function onManageUserSelect ( ) {
const uid = document . getElementById ( 'admin-manage-select' ) . value ;
if ( ! uid ) {
clearManageUserForm ( ) ;
return ;
}
const u = state . users . find ( x => x . user _id === uid ) ;
if ( u ) fillManageUserForm ( u ) ;
}
function syncManageUserSelect ( ) {
const sel = document . getElementById ( 'admin-manage-select' ) ;
const cur = sel . value ;
sel . innerHTML = '<option value="">— new user —</option>' +
state . users . map ( u => ` <option value=" ${ escapeHtml ( u . user _id ) } "> ${ escapeHtml ( u . user _id ) } · ${ escapeHtml ( u . display _name || '' ) } </option> ` ) . join ( '' ) ;
if ( cur && state . users . some ( u => u . user _id === cur ) ) sel . value = cur ;
}
async function showAdminDialog ( ) {
document . getElementById ( 'admin-dialog' ) . classList . add ( 'active' ) ;
clearManageUserForm ( ) ;
document . getElementById ( 'admin-oauth-result' ) . style . display = 'none' ;
document . getElementById ( 'admin-oauth-result' ) . textContent = '' ;
switchAdminTab ( 'oauth' ) ;
switchOAuthSubTab ( 'list' ) ;
switchUsersSubTab ( 'list' ) ;
switchProjectsSubTab ( 'list' ) ;
await Promise . all ( [ loadUsers ( ) , loadOAuthClients ( ) , loadAdminProjects ( ) ] ) ;
}
function hideAdminDialog ( ) {
document . getElementById ( 'admin-dialog' ) . classList . remove ( 'active' ) ;
}
async function loadUsers ( ) {
const el = document . getElementById ( 'admin-users-list' ) ;
el . innerHTML = '<div class="loading">loading users…</div>' ;
try {
state . users = await api ( 'GET' , '/users' ) ;
renderUserList ( ) ;
syncManageUserSelect ( ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) {
el . innerHTML = ` <div class="empty-state">failed to load users: ${ escapeHtml ( e . message ) } </div> ` ;
}
}
}
function renderUserList ( ) {
const el = document . getElementById ( 'admin-users-list' ) ;
if ( ! state . users . length ) {
el . innerHTML = '<div class="empty-state">no users</div>' ;
return ;
}
el . innerHTML = ` <table class="user-list-table"><thead><tr><th>user id</th><th>name</th><th>role</th><th>state</th></tr></thead><tbody> ${
state . users . map ( u => {
const active = u . active !== false ;
return ` <tr>
<td class="user-id"> ${ escapeHtml ( u . user _id || '' ) } </td>
<td> ${ escapeHtml ( u . display _name || '' ) } </td>
<td> ${ escapeHtml ( u . role || '' ) } </td>
<td class=" ${ active ? 'user-state-active' : 'user-state-inactive' } "> ${ active ? 'active' : 'inactive' } </td>
</tr> ` ;
} ).join('')
}</tbody></table> ` ;
}
async function saveManagedUser ( ) {
const selectUid = document . getElementById ( 'admin-manage-select' ) . value ;
const userId = document . getElementById ( 'admin-user-id' ) . value . trim ( ) ;
const displayName = document . getElementById ( 'admin-display-name' ) . value . trim ( ) ;
const role = document . getElementById ( 'admin-role' ) . value ;
const active = document . getElementById ( 'admin-user-active' ) . checked ;
const password = document . getElementById ( 'admin-password' ) . value ;
if ( ! userId || ! displayName ) {
showToast ( 'user id and display name required' , 'error' ) ;
return ;
}
const isEdit = Boolean ( selectUid ) ;
try {
if ( isEdit ) {
const body = { display _name : displayName , role , active } ;
if ( password . trim ( ) ) body . password = password . trim ( ) ;
await api ( 'PATCH' , ` /users/ ${ encodeURIComponent ( userId ) } ` , body ) ;
showToast ( ` user updated · ${ userId } ` , 'success' ) ;
} else {
if ( ! password . trim ( ) ) {
showToast ( 'password required for new users' , 'error' ) ;
return ;
}
await api ( 'POST' , '/users' , { user _id : userId , display _name : displayName , role , password : password . trim ( ) } ) ;
if ( ! active ) await api ( 'PATCH' , ` /users/ ${ encodeURIComponent ( userId ) } ` , { active : false } ) ;
showToast ( ` user created · ${ userId } ` , 'success' ) ;
clearManageUserForm ( ) ;
}
await loadUsers ( ) ;
if ( isEdit ) {
document . getElementById ( 'admin-manage-select' ) . value = userId ;
const u = state . users . find ( x => x . user _id === userId ) ;
if ( u ) fillManageUserForm ( u ) ;
}
} catch ( e ) {
if ( e . message !== 'unauthorized' ) showToast ( 'save user failed: ' + e . message , 'error' ) ;
}
}
async function setManagedUserActive ( active ) {
const userId = document . getElementById ( 'admin-manage-select' ) . value || document . getElementById ( 'admin-user-id' ) . value . trim ( ) ;
if ( ! userId ) {
showToast ( 'select a user first' , 'error' ) ;
return ;
}
try {
await api ( 'PATCH' , ` /users/ ${ encodeURIComponent ( userId ) } ` , { active } ) ;
document . getElementById ( 'admin-user-active' ) . checked = active ;
showToast ( active ? ` activated · ${ userId } ` : ` inactivated · ${ userId } ` , 'success' ) ;
await loadUsers ( ) ;
document . getElementById ( 'admin-manage-select' ) . value = userId ;
const u = state . users . find ( x => x . user _id === userId ) ;
if ( u ) fillManageUserForm ( u ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) showToast ( 'update failed: ' + e . message , 'error' ) ;
}
}
async function deleteManagedUser ( ) {
const userId = document . getElementById ( 'admin-manage-select' ) . value || document . getElementById ( 'admin-user-id' ) . value . trim ( ) ;
if ( ! userId ) {
showToast ( 'select a user first' , 'error' ) ;
return ;
}
if ( ! confirm ( ` Delete user " ${ userId } "? If delete fails, inactivate instead. ` ) ) return ;
try {
await api ( 'DELETE' , ` /users/ ${ encodeURIComponent ( userId ) } ` ) ;
showToast ( ` user deleted · ${ userId } ` , 'success' ) ;
clearManageUserForm ( ) ;
await loadUsers ( ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) showToast ( 'delete failed: ' + e . message , 'error' ) ;
}
}
async function loadOAuthClients ( ) {
const el = document . getElementById ( 'admin-oauth-list' ) ;
el . innerHTML = '<div class="loading">loading oauth clients…</div>' ;
try {
const clients = await api ( 'GET' , '/oauth/clients' ) ;
if ( ! clients . length ) {
el . innerHTML = '<div class="empty-state">no oauth clients yet</div>' ;
return ;
}
el . innerHTML = clients . map ( c => {
const cid = c . client _id || '' ;
return `
<div class="admin-oauth-row">
<div class="oauth-client-meta">
<div class="oauth-client-id"> ${ escapeHtml ( cid ) } </div>
<div class="oauth-client-name"> ${ escapeHtml ( c . client _name || '' ) } </div>
<div class="oauth-client-uri"> ${ escapeHtml ( ( c . redirect _uris || [ ] ) . join ( ', ' ) ) } </div>
</div>
<button type="button" class="oauth-revoke" data-client-id=" ${ escapeHtml ( cid ) } " onclick="revokeOAuthClient(this.dataset.clientId)">revoke</button>
</div> ` ;
} ) . join ( '' ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) {
el . innerHTML = ` <div class="empty-state"> ${ escapeHtml ( e . message ) } </div> ` ;
}
}
}
async function revokeOAuthClient ( clientId ) {
if ( ! clientId ) return ;
if ( ! confirm ( ` Revoke OAuth client " ${ clientId } "? \n \n Pending auth codes and access/refresh tokens for this client are invalidated. ` ) ) return ;
try {
await api ( 'DELETE' , ` /oauth/clients/ ${ encodeURIComponent ( clientId ) } ` ) ;
showToast ( ` oauth client revoked · ${ clientId } ` , 'success' ) ;
await loadOAuthClients ( ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) showToast ( 'revoke failed: ' + e . message , 'error' ) ;
}
}
async function createOAuthClient ( ) {
const name = document . getElementById ( 'admin-oauth-name' ) . value . trim ( ) || 'MCP connector' ;
const redirect = document . getElementById ( 'admin-oauth-redirect' ) . value . trim ( ) ;
if ( ! redirect ) {
showToast ( 'redirect uri required' , 'error' ) ;
return ;
}
try {
const client = await api ( 'POST' , '/oauth/clients' , { client _name : name , redirect _uris : [ redirect ] } ) ;
const pre = document . getElementById ( 'admin-oauth-result' ) ;
pre . style . display = 'block' ;
pre . textContent = [
'Save these now — client_secret is not shown again.' ,
'' ,
` connector_url: ${ client . connector _url || '' } ` ,
` authorization_server: ${ client . authorization _server || '' } ` ,
` client_id: ${ client . client _id || '' } ` ,
` client_secret: ${ client . client _secret || '' } ` ,
` redirect_uris: ${ JSON . stringify ( client . redirect _uris || [ ] ) } ` ,
] . join ( '\n' ) ;
showToast ( 'oauth client created — copy secret from admin panel' , 'success' ) ;
switchOAuthSubTab ( 'create' ) ;
await loadOAuthClients ( ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) showToast ( 'oauth client failed: ' + e . message , 'error' ) ;
}
}
// ── Admin Projects ──────────────────────────────────────────────
async function loadAdminProjects ( ) {
const el = document . getElementById ( 'admin-projects-list' ) ;
el . innerHTML = '<div class="loading">loading projects…</div>' ;
try {
const projects = await api ( 'GET' , '/projects' ) ;
if ( ! projects . length ) {
el . innerHTML = '<div class="empty-state">no projects</div>' ;
return ;
}
el . innerHTML = projects . map ( p => {
const pid = p . project _id || '' ;
const name = p . display _name || '' ;
const ver = p . shared _version || 0 ;
return `
<div class="admin-oauth-row">
<div class="oauth-client-meta">
<div class="oauth-client-id"> ${ escapeHtml ( pid ) } </div>
<div class="oauth-client-name"> ${ escapeHtml ( name ) } · v ${ ver } </div>
</div>
<button type="button" class="oauth-revoke" data-project-id=" ${ escapeHtml ( pid ) } " onclick="promptDeleteProject(this.dataset.projectId)">remove</button>
</div> ` ;
} ) . join ( '' ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) {
el . innerHTML = ` <div class="empty-state">failed to load: ${ escapeHtml ( e . message ) } </div> ` ;
}
}
}
function promptDeleteProject ( projectId ) {
if ( ! projectId ) return ;
// Build a typed-confirm modal
const existing = document . getElementById ( 'project-delete-modal' ) ;
if ( existing ) existing . remove ( ) ;
const modal = document . createElement ( 'div' ) ;
modal . id = 'project-delete-modal' ;
modal . className = 'dialog-overlay active' ;
modal . innerHTML = `
<div class="dialog" style="max-width:400px">
<h2 style="color:var(--danger)">delete project</h2>
<p class="dialog-hint">This will permanently delete <strong style="color:var(--accent)"> ${ escapeHtml ( projectId ) } </strong> and all its context files, snapshots, and audit references.</p>
<p class="dialog-hint" style="margin-top:0.5rem">Type the project name to confirm:</p>
<input type="text" id="project-delete-confirm-input" placeholder=" ${ escapeHtml ( projectId ) } " autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" onkeydown="if(event.key==='Enter')confirmDeleteProject(' ${ escapeHtml ( projectId ) . replace ( /'/g , "\\'" ) } ')">
<div class="actions" style="margin-top:1rem">
<button onclick="document.getElementById('project-delete-modal').remove()">cancel</button>
<button class="primary" id="project-delete-confirm-btn" style="background:var(--danger);border-color:var(--danger);color:var(--paper)" onclick="confirmDeleteProject(' ${ escapeHtml ( projectId ) . replace ( /'/g , "\\'" ) } ')">delete project</button>
</div>
</div> ` ;
document . body . appendChild ( modal ) ;
setTimeout ( ( ) => document . getElementById ( 'project-delete-confirm-input' ) ? . focus ( ) , 50 ) ;
// Disable the confirm button until the typed name matches
const input = modal . querySelector ( '#project-delete-confirm-input' ) ;
const btn = modal . querySelector ( '#project-delete-confirm-btn' ) ;
btn . disabled = true ;
btn . style . opacity = '0.4' ;
input . addEventListener ( 'input' , ( ) => {
const match = input . value . trim ( ) === projectId ;
btn . disabled = ! match ;
btn . style . opacity = match ? '1' : '0.4' ;
} ) ;
}
async function confirmDeleteProject ( projectId ) {
const input = document . getElementById ( 'project-delete-confirm-input' ) ;
if ( ! input || input . value . trim ( ) !== projectId ) {
showToast ( 'project name does not match' , 'error' ) ;
return ;
}
try {
await api ( 'DELETE' , ` /projects/ ${ encodeURIComponent ( projectId ) } ` ) ;
showToast ( ` project deleted · ${ projectId } ` , 'success' ) ;
document . getElementById ( 'project-delete-modal' ) ? . remove ( ) ;
await loadAdminProjects ( ) ;
await loadProjects ( ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) showToast ( 'delete failed: ' + e . message , 'error' ) ;
}
}
2026-06-23 23:54:37 +00:00
// ── Projects ─────────────────────────────────────────────────────
async function loadProjects ( ) {
try {
state . projects = await api ( 'GET' , '/projects' ) ;
renderProjectList ( ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) {
showToast ( 'Failed to load projects: ' + e . message , 'error' ) ;
}
}
}
function renderProjectList ( ) {
const el = document . getElementById ( 'project-list' ) ;
el . innerHTML = state . projects . map ( p => {
const pid = p . project _id ;
const expanded = state . expandedProjects . has ( pid ) ;
const files = state . filesByProject [ pid ] || [ ] ;
const isActiveProject = state . currentProject === pid ;
const fileListHtml = expanded ? `
<div class="project-files">
${ files . length ? files . map ( f => `
<div class="project-file-item ${ isActiveProject && state . currentFile === f . file _path ? ' active' : '' } "
onclick="selectSidebarFile(' ${ pid } ', ' ${ f . file _path } ')">
<span class="project-file-name"> ${ f . file _path . replace ( /\.MD$/i , '' ) } </span>
<span class="project-file-version">v ${ f . version } </span>
</div>
` ) . join ( '' ) : '<div class="project-files-loading">loading files…</div>' }
</div>
` : '' ;
return `
<div class="project-block">
<div class="project-item ${ isActiveProject ? ' active' : '' } "
onclick="toggleProject(' ${ pid } ')">
<span class="chevron"> ${ expanded ? '▾' : '▸' } </span>
<span class="name"> ${ p . display _name || pid } </span>
<span class="version">v ${ p . shared _version } </span>
</div>
${ fileListHtml }
</div>
` ;
} ) . join ( '' ) ;
}
async function ensureProjectFiles ( pid , force = false ) {
if ( ! force && state . filesByProject [ pid ] ) return state . filesByProject [ pid ] ;
let files = await api ( 'GET' , ` /projects/ ${ pid } /files ` ) ;
if ( files . length === 0 ) {
await api ( 'POST' , ` /projects/ ${ pid } /migrate-files ` ) ;
files = await api ( 'GET' , ` /projects/ ${ pid } /files ` ) ;
}
state . filesByProject [ pid ] = files ;
if ( state . currentProject === pid ) state . files = files ;
return files ;
}
// ── Select project ──────────────────────────────────────────────
async function toggleProject ( pid ) {
const wasExpanded = state . expandedProjects . has ( pid ) ;
if ( wasExpanded ) {
state . expandedProjects . delete ( pid ) ;
renderProjectList ( ) ;
return ;
}
await openProject ( pid , { closeAfterSelect : false } ) ;
}
async function selectProject ( pid ) {
await openProject ( pid , { closeAfterSelect : true } ) ;
}
async function openProject ( pid , { closeAfterSelect = false } = { } ) {
state . currentProject = pid ;
state . searchResults = null ;
state . currentFile = null ;
state . fileVersion = 0 ;
state . files = [ ] ;
state . expandedProjects . add ( pid ) ;
document . getElementById ( 'search-input' ) . value = '' ;
switchTab ( 'context' , { render : false } ) ;
renderProjectList ( ) ;
const area = document . getElementById ( 'content-area' ) ;
area . innerHTML = '<div class="loading">loading…</div>' ;
try {
const files = await ensureProjectFiles ( pid ) ;
renderProjectList ( ) ;
if ( files . length > 0 ) {
await selectProjectFile ( pid , files [ 0 ] . file _path , { closeAfterSelect } ) ;
} else {
area . innerHTML = '<div class="empty-state">no files — create one to get started</div>' ;
}
} catch ( e ) {
if ( e . message !== 'unauthorized' ) {
area . innerHTML = ` <div class="empty-state">error: ${ e . message } </div> ` ;
}
}
}
// ── File management ──────────────────────────────────────────────
async function loadProjectFiles ( pid ) {
await openProject ( pid , { closeAfterSelect : false } ) ;
}
async function selectSidebarFile ( pid , filePath ) {
await selectProjectFile ( pid , filePath , { closeAfterSelect : true } ) ;
}
async function selectFile ( filePath ) {
await selectProjectFile ( state . currentProject , filePath , { closeAfterSelect : false } ) ;
}
async function selectProjectFile ( pid , filePath , { closeAfterSelect = false } = { } ) {
state . currentProject = pid ;
state . currentFile = filePath ;
state . fileVersion = 0 ;
state . searchResults = null ;
state . expandedProjects . add ( pid ) ;
document . getElementById ( 'search-input' ) . value = '' ;
switchTab ( 'context' , { render : false } ) ;
try {
const files = await ensureProjectFiles ( pid ) ;
state . files = files ;
renderProjectList ( ) ;
renderFileTabs ( ) ;
await loadFile ( pid , filePath ) ;
if ( closeAfterSelect ) closeSidebar ( ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) {
document . getElementById ( 'content-area' ) . innerHTML = ` <div class="empty-state">error: ${ e . message } </div> ` ;
}
}
}
function renderFileTabs ( ) {
const tabsEl = document . getElementById ( 'file-tabs' ) ;
if ( ! tabsEl || ! state . files . length ) return ;
tabsEl . innerHTML = state . files . map ( f => `
<button class="file-tab ${ state . currentFile === f . file _path ? ' active' : '' } "
onclick="selectFile(' ${ f . file _path } ')">
${ f . file _path . replace ( '.MD' , '' ) . replace ( '.md' , '' ) }
<span class="file-tab-version">v ${ f . version } </span>
</button>
` ) . join ( '' ) + `
<button class="file-tab file-tab-add" onclick="showNewFileDialog()" title="add file">+</button>
` ;
}
async function loadFile ( pid , filePath ) {
const area = document . getElementById ( 'content-area' ) ;
area . innerHTML = '<div class="loading">loading…</div>' ;
try {
const data = await api ( 'GET' , ` /projects/ ${ pid } /files/ ${ encodeURIComponent ( filePath ) } ` ) ;
state . fileVersion = data . version ;
state . currentFileContent = data . content ;
renderContext ( data . content , data . version , filePath ) ;
} catch ( e ) {
if ( e . message !== 'unauthorized' ) {
area . innerHTML = ` <div class="empty-state">error: ${ e . message } </div> ` ;
}
}
}
// ── Markdown custom renderer ─────────────────────────────────────
// The metadata header (TYPE: PROJECT CONTEXT ...) should render in gold.
// Strip it before markdown rendering, render the rest, then re-insert
// it styled as a <div class=ctxd-header>.
const ctxdHeaderRegex = /^(TYPE: PROJECT CONTEXT[\s\S]*?(?:\n--\n___|\n---))/ ;
function extractHeader ( content ) {
const m = content . match ( ctxdHeaderRegex ) ;
return m ? { header : m [ 1 ] , rest : content . slice ( m [ 0 ] . length ) } : null ;
}
// ── Render context ──────────────────────────────────────────────
function renderContext ( content , version , filePath ) {
const area = document . getElementById ( 'content-area' ) ;
const displayName = filePath ? filePath . replace ( /\.MD$/i , '' ) : 'context' ;
// Extract the metadata header so markdown doesn't mangle it
const extracted = extractHeader ( content ) ;
let headerHtml = '' ;
let body = content ;
if ( extracted ) {
headerHtml = ` <div class="ctxd-header"> ${ escapeHtml ( extracted . header ) } </div> ` ;
body = extracted . rest ;
}
const html = marked . parse ( body , { breaks : true , gfm : true } ) ;
area . innerHTML = `
<div class="file-tabs" id="file-tabs"></div>
<div class="editor-pane active">
<div class="editor-toolbar">
<span class="meta"> ${ state . currentProject } / ${ displayName } · <strong>v ${ version } </strong></span>
<span class="spacer"></span>
<button onclick="toggleEdit()">edit</button>
<button onclick="showImportDialog()">import</button>
<button onclick="syncProject()">sync</button>
</div>
<div class="newspaper" id="context-view"> ${ headerHtml } ${ html } </div>
</div>
<div class="editor-pane" id="editor-pane">
<div class="editor-toolbar">
<span class="meta">editing · ${ displayName } · <strong>v ${ version } </strong></span>
<span class="spacer"></span>
<button onclick="toggleEdit()">cancel</button>
<button class="primary" onclick="saveFileContent()">save</button>
</div>
<div class="editor-split">
<textarea id="editor-textarea"> ${ escapeHtml ( content ) } </textarea>
<div class="editor-preview" id="editor-preview"></div>
</div>
</div>
` ;
// Render file tabs
renderFileTabs ( ) ;
// Highlight code blocks
document . querySelectorAll ( '#context-view pre code' ) . forEach ( block => {
hljs . highlightElement ( block ) ;
} ) ;
// Live preview
const ta = document . getElementById ( 'editor-textarea' ) ;
if ( ta ) {
ta . addEventListener ( 'input' , updatePreview ) ;
}
}
function updatePreview ( ) {
const ta = document . getElementById ( 'editor-textarea' ) ;
const preview = document . getElementById ( 'editor-preview' ) ;
if ( ta && preview ) {
preview . innerHTML = marked . parse ( ta . value , { breaks : true , gfm : true } ) ;
}
}
let editing = false ;
function toggleEdit ( ) {
editing = ! editing ;
document . querySelectorAll ( '.editor-pane' ) . forEach ( el => el . classList . toggle ( 'active' ) ) ;
if ( editing ) updatePreview ( ) ;
}
async function saveFileContent ( ) {
const ta = document . getElementById ( 'editor-textarea' ) ;
if ( ! ta ) return ;
const content = ta . value ;
try {
const result = await api ( 'PUT' , ` /projects/ ${ state . currentProject } /files/ ${ encodeURIComponent ( state . currentFile ) } ` , {
content ,
2026-06-24 22:50:54 +00:00
updated _by : writeActor ( ) ,
2026-06-23 23:54:37 +00:00
base _version : state . fileVersion ,
} ) ;
if ( result . ok ) {
showToast ( ` saved · v ${ result . new _version } ` , 'success' ) ;
state . fileVersion = result . new _version ;
editing = false ;
// Reload the file to show the updated content with fresh header
await loadFile ( state . currentProject , state . currentFile ) ;
// Update file version in tabs/sidebar cache
const fileEntry = state . files . find ( f => f . file _path === state . currentFile ) ;
if ( fileEntry ) fileEntry . version = result . new _version ;
if ( state . filesByProject [ state . currentProject ] ) {
const cachedEntry = state . filesByProject [ state . currentProject ] . find ( f => f . file _path === state . currentFile ) ;
if ( cachedEntry ) cachedEntry . version = result . new _version ;
}
renderFileTabs ( ) ;
renderProjectList ( ) ;
} else if ( result . error === 'conflict' ) {
showToast ( ` conflict: base v ${ state . fileVersion } but current is v ${ result . current _version } — re-read and merge ` , 'error' ) ;
} else {
showToast ( 'save failed: ' + ( result . error || 'unknown' ) , 'error' ) ;
}
} catch ( e ) {
showToast ( 'save error: ' + e . message , 'error' ) ;
}
}
// ── New file dialog ─────────────────────────────────────────────
function showNewFileDialog ( ) {
const name = prompt ( 'File name (e.g. ARCHITECTURE.md):' ) ;
if ( ! name ) return ;
// Normalize
let fname = name . toUpperCase ( ) ;
if ( ! fname . endsWith ( '.MD' ) ) fname += '.MD' ;
createNewFile ( fname ) ;
}
async function createNewFile ( filePath ) {
try {
const result = await api ( 'POST' , ` /projects/ ${ state . currentProject } /files ` , {
file _path : filePath ,
content : '' ,
2026-06-24 22:50:54 +00:00
updated _by : writeActor ( ) ,
2026-06-23 23:54:37 +00:00
} ) ;
if ( result . ok ) {
showToast ( ` created ${ filePath } ` , 'success' ) ;
// Reload file list and select the new file
const files = await ensureProjectFiles ( state . currentProject , true ) ;
state . files = files ;
renderProjectList ( ) ;
selectFile ( filePath ) ;
} else {
showToast ( 'create failed: ' + ( result . error || 'unknown' ) , 'error' ) ;
}
} catch ( e ) {
showToast ( 'create failed: ' + e . message , 'error' ) ;
}
}
async function syncProject ( ) {
try {
const result = await api ( 'POST' , ` /projects/ ${ state . currentProject } /sync ` ) ;
if ( result . ok ) {
showToast ( ` synced to ${ result . path } ` , 'success' ) ;
} else {
showToast ( 'sync: ' + ( result . error || 'failed' ) , 'error' ) ;
}
} catch ( e ) {
showToast ( 'sync error: ' + e . message , 'error' ) ;
}
}
// ── Search ──────────────────────────────────────────────────────
async function doSearch ( ) {
const q = document . getElementById ( 'search-input' ) . value . trim ( ) ;
if ( ! q ) return ;
const area = document . getElementById ( 'content-area' ) ;
area . innerHTML = '<div class="loading">searching…</div>' ;
try {
const results = await api ( 'GET' , ` /search?q= ${ encodeURIComponent ( q ) } ` ) ;
state . searchResults = results ;
renderSearchResults ( results , q ) ;
} catch ( e ) {
area . innerHTML = ` <div class="empty-state">search error: ${ e . message } </div> ` ;
}
}
function renderSearchResults ( results , query ) {
const area = document . getElementById ( 'content-area' ) ;
if ( results . length === 0 ) {
area . innerHTML = ` <div class="empty-state">no results for " ${ query } "</div> ` ;
return ;
}
area . innerHTML = `
<div style="margin-bottom:0.75rem;font-size:0.7rem;color:var(--ink-dim);text-transform:uppercase;letter-spacing:0.08em;">
${ results . length } result ${ results . length !== 1 ? 's' : '' } for " ${ query } "
</div>
${ results . map ( r => {
const snippet = ( r . content || '' ) . slice ( 0 , 300 ) ;
return `
<div class="search-result-item" onclick="selectProjectFile(' ${ r . project _id } ', ' ${ r . file _path || 'CONTEXT.MD' } ')">
<div class="src"> ${ r . source _type || '?' } </div>
<div class="path"> ${ r . project _id } / ${ r . file _path || '' } </div>
<div class="snippet"> ${ escapeHtml ( snippet ) } </div>
</div>
` ;
} ).join('')}
` ;
}
// ── Tab switching ────────────────────────────────────────────────
function switchTab ( tab , options = { } ) {
const shouldRender = options . render !== false ;
state . currentTab = tab ;
document . querySelectorAll ( '.sidebar-footer button' ) . forEach ( b => {
b . classList . toggle ( 'active-tab' , b . dataset . tab === tab ) ;
} ) ;
if ( ! shouldRender ) return ;
const area = document . getElementById ( 'content-area' ) ;
if ( tab === 'context' ) {
if ( state . currentProject && state . currentFile ) {
loadFile ( state . currentProject , state . currentFile ) ;
} else if ( state . currentProject ) {
loadProjectFiles ( state . currentProject ) ;
} else {
area . innerHTML = '<div class="loading">select a project from the sidebar</div>' ;
}
} else if ( tab === 'audit' ) {
loadAudit ( ) ;
} else if ( tab === 'snapshots' ) {
loadSnapshots ( ) ;
}
}
async function loadAudit ( ) {
const area = document . getElementById ( 'content-area' ) ;
area . innerHTML = '<div class="loading">loading audit log…</div>' ;
try {
const entries = await api ( 'GET' , '/audit?limit=50' ) ;
area . innerHTML = `
<h1>audit log</h1>
<table class="audit-table">
<thead><tr>
<th>time</th><th>user</th><th>agent</th><th>op</th><th>project</th><th>summary</th>
</tr></thead>
<tbody>
${ entries . map ( e => `
<tr>
<td style="white-space:nowrap;font-size:0.7rem"> ${ ( e . created _at || '' ) . slice ( 0 , 19 ) } </td>
<td> ${ e . user _id || '' } </td>
<td style="font-size:0.7rem"> ${ e . agent _id || '' } </td>
<td><span style="color:var(--accent)"> ${ e . operation || '' } </span></td>
<td style="font-size:0.7rem"> ${ e . project _id || '' } </td>
<td> ${ escapeHtml ( ( e . summary || '' ) . slice ( 0 , 80 ) ) } </td>
</tr>
` ) . join ( '' ) }
</tbody>
</table>
` ;
} catch ( e ) {
area . innerHTML = ` <div class="empty-state">error: ${ e . message } </div> ` ;
}
}
async function loadSnapshots ( ) {
const area = document . getElementById ( 'content-area' ) ;
if ( ! state . currentProject ) {
area . innerHTML = '<div class="loading">select a project to view snapshots</div>' ;
return ;
}
area . innerHTML = '<div class="loading">loading snapshots…</div>' ;
try {
const snaps = await api ( 'GET' , ` /projects/ ${ state . currentProject } /snapshots ` ) ;
area . innerHTML = `
<h1>snapshots · ${ state . currentProject } </h1>
${ snaps . length === 0 ? '<div class="empty-state">no snapshots yet</div>' : `
<table class="audit-table">
<thead><tr><th>time</th><th>from</th><th>to</th><th>size</th><th>hash</th></tr></thead>
<tbody>
${ snaps . map ( s => `
<tr>
<td style="white-space:nowrap;font-size:0.7rem"> ${ ( s . created _at || '' ) . slice ( 0 , 19 ) } </td>
<td>v ${ s . version _from } </td>
<td>v ${ s . version _to } </td>
<td> ${ s . size _bytes || 0 } b</td>
<td style="font-size:0.65rem;color:var(--ink-dim)"> ${ ( s . content _hash || '' ) . slice ( 0 , 12 ) } </td>
</tr>
` ) . join ( '' ) }
</tbody>
</table>
` }
` ;
} catch ( e ) {
area . innerHTML = ` <div class="empty-state">error: ${ e . message } </div> ` ;
}
}
// ── New project dialog ──────────────────────────────────────────
function showNewProjectDialog ( ) {
document . getElementById ( 'new-project-dialog' ) . classList . add ( 'active' ) ;
document . getElementById ( 'new-pid' ) . value = '' ;
document . getElementById ( 'new-name' ) . value = '' ;
document . getElementById ( 'new-desc' ) . value = '' ;
document . getElementById ( 'import-file-name' ) . textContent = 'no file selected' ;
document . getElementById ( 'import-file' ) . value = '' ;
setTimeout ( ( ) => document . getElementById ( 'new-pid' ) . focus ( ) , 100 ) ;
}
function hideNewProjectDialog ( ) {
document . getElementById ( 'new-project-dialog' ) . classList . remove ( 'active' ) ;
}
// ── File upload helpers ─────────────────────────────────────────
let pendingImportFile = null ;
document . getElementById ( 'import-file' ) . addEventListener ( 'change' , ( e ) => {
const file = e . target . files [ 0 ] ;
if ( file ) {
document . getElementById ( 'import-file-name' ) . textContent = file . name ;
pendingImportFile = file ;
}
} ) ;
document . getElementById ( 'import-file-existing' ) . addEventListener ( 'change' , ( e ) => {
const file = e . target . files [ 0 ] ;
if ( file ) {
document . getElementById ( 'import-file-name-existing' ) . textContent = file . name ;
}
} ) ;
function readFileAsText ( file ) {
return new Promise ( ( resolve , reject ) => {
const reader = new FileReader ( ) ;
reader . onload = ( ) => resolve ( reader . result ) ;
reader . onerror = ( ) => reject ( new Error ( 'Failed to read file' ) ) ;
reader . readAsText ( file ) ;
} ) ;
}
async function createProject ( ) {
const pid = document . getElementById ( 'new-pid' ) . value . trim ( ) ;
const name = document . getElementById ( 'new-name' ) . value . trim ( ) || pid ;
const desc = document . getElementById ( 'new-desc' ) . value . trim ( ) ;
if ( ! pid ) { showToast ( 'project id is required' , 'error' ) ; return ; }
try {
await api ( 'POST' , '/projects' , { project _id : pid , display _name : name , description : desc } ) ;
// If a file was selected, import it as the project context
if ( pendingImportFile ) {
const content = await readFileAsText ( pendingImportFile ) ;
await api ( 'POST' , ` /projects/ ${ pid } /import ` , {
content ,
2026-06-24 22:50:54 +00:00
updated _by : writeActor ( ) ,
2026-06-23 23:54:37 +00:00
base _version : 0 ,
} ) ;
showToast ( ` created ${ pid } · imported ${ pendingImportFile . name } ` , 'success' ) ;
pendingImportFile = null ;
} else {
showToast ( ` created ${ pid } ` , 'success' ) ;
}
hideNewProjectDialog ( ) ;
await loadProjects ( ) ;
selectProject ( pid ) ;
} catch ( e ) {
showToast ( 'create failed: ' + e . message , 'error' ) ;
}
}
// ── Import dialog (existing project) ────────────────────────────
function showImportDialog ( ) {
document . getElementById ( 'import-dialog' ) . classList . add ( 'active' ) ;
document . getElementById ( 'import-file-name-existing' ) . textContent = 'no file selected' ;
document . getElementById ( 'import-file-existing' ) . value = '' ;
}
function hideImportDialog ( ) {
document . getElementById ( 'import-dialog' ) . classList . remove ( 'active' ) ;
}
async function importToExisting ( ) {
const fileInput = document . getElementById ( 'import-file-existing' ) ;
const file = fileInput . files [ 0 ] ;
if ( ! file ) { showToast ( 'select a file first' , 'error' ) ; return ; }
if ( ! state . currentProject ) { showToast ( 'no project selected' , 'error' ) ; return ; }
try {
const content = await readFileAsText ( file ) ;
const result = await api ( 'POST' , ` /projects/ ${ state . currentProject } /import ` , {
content ,
2026-06-24 22:50:54 +00:00
updated _by : writeActor ( ) ,
2026-06-23 23:54:37 +00:00
base _version : state . currentVersion ,
} ) ;
if ( result . ok ) {
showToast ( ` imported ${ file . name } · v ${ result . new _version } ` , 'success' ) ;
hideImportDialog ( ) ;
await ensureProjectFiles ( state . currentProject , true ) ;
await selectFile ( state . currentFile || 'CONTEXT.MD' ) ;
} else if ( result . error === 'conflict' ) {
showToast ( 'version conflict — reload and try again' , 'error' ) ;
} else {
showToast ( 'import failed: ' + ( result . error || 'unknown' ) , 'error' ) ;
}
} catch ( e ) {
showToast ( 'import failed: ' + e . message , 'error' ) ;
}
}
// ── Utilities ───────────────────────────────────────────────────
function escapeHtml ( str ) {
const div = document . createElement ( 'div' ) ;
div . textContent = str ;
return div . innerHTML ;
}
// ── Init ─────────────────────────────────────────────────────────
2026-06-24 22:50:54 +00:00
async function bootstrap ( ) {
await checkStatus ( ) ;
if ( ! getSessionToken ( ) ) {
showAuthDialog ( ) ;
return ;
}
try {
state . currentUser = await api ( 'GET' , '/auth/me' ) ;
updateAuthChrome ( ) ;
await loadProjects ( ) ;
} catch ( e ) {
clearSession ( ) ;
showAuthDialog ( ) ;
}
}
bootstrap ( ) ;
2026-06-23 23:54:37 +00:00
< / script >
< / body >
< / html >