overhaul chat structure

This commit is contained in:
Frederik Spirgi
2026-09-09 09:44:30 +02:00
parent 1ed0226b69
commit 5543deb142
16 changed files with 203 additions and 578 deletions
+17
View File
@@ -91,3 +91,20 @@ Pure chat message, retry, memory, summary, and persona-state formatting lives in
- [Privacy Policy](PRIVACY_POLICY.md)
- [Terms of Service](TERMS_OF_SERVICE.md)
## Page navigation
- `/` is the public landing page, even for signed-in visitors.
- `/login` and `/register` provide dedicated account forms. Registration starts a session automatically.
- `/app` is workspace home, with recent chats and optional starters.
- `/app/chats/new` opens an empty composer; sending the first message creates the conversation.
- `/app/chats/:id` opens a conversation. Legacy `/?chat=:id` links redirect here.
- `/app/personas` provides persona management, using the shared settings editor module.
- `/app/settings` contains account and appearance settings. `/settings` redirects here.
- `/market` allows visitors to browse public summaries and signed-in users to use the full persona market.
Protected pages redirect to sign-in with a local return destination. Public discovery only returns published persona summaries; using a persona requires an account.
All page routes render the same navigation from `src/pageLayout.js` into the `<!-- site-header -->` placeholder. `public/shell.css` owns the shared header, page spacing, and mobile navigation; `public/app/workspaceNavigation.js` owns sign-out. Page-specific headers contain only local titles and actions. The chat sidebar is reserved for conversation history. Public and home content share `public/pages.css` and `public/pages.js`. Page routes run before static assets and authenticated API routers; rendered pages use private, non-cacheable responses.
For environments that block child-process test workers, run `node --test --test-isolation=none`.
+1 -1
View File
@@ -1,4 +1,4 @@
export const requestedChatId = Number(new URLSearchParams(window.location.search).get("chat")) || null;
export const requestedChatId = Number(window.location.pathname.match(/^\/app\/chats\/(\d+)$/)?.[1] || new URLSearchParams(window.location.search).get("chat")) || null;
export const onboardingPrompts = {
ask: ["Explain this topic in simple terms.", "Compare these options and recommend one."],
-32
View File
@@ -1,9 +1,6 @@
const $ = (id) => document.getElementById(id);
const authDiv = $("auth");
const chatDiv = $("chat");
const authMsg = $("authMsg");
const logoutButton = $("logout");
const messagesDiv = $("messages");
const modelSelect = $("model");
const modelPanel = $("modelPanel");
@@ -20,18 +17,8 @@ const chatResponseLengthInput = $("chatResponseLength");
const chatSystemInstructionInput = $("chatSystemInstruction");
const saveGenerationSettingsBtn = $("saveGenerationSettings");
const chatDrawerPersonaValue = $("chatDrawerPersonaValue");
const loginForm = $("loginForm");
const registerForm = $("registerForm");
const msgInput = $("msgInput");
const sendBtn = $("send");
const loginUsernameInput = $("loginUsername");
const loginPasswordInput = $("loginPassword");
const registerUsernameInput = $("registerUsername");
const registerPasswordInput = $("registerPassword");
const registerInviteField = $("registerInviteField");
const registerInviteCodeInput = $("registerInviteCode");
const loginSubmit = $("loginSubmit");
const registerSubmit = $("registerSubmit");
const chatList = $("chatList");
const chatSearchInput = $("chatSearch");
const chatSidebar = $("chatSidebar");
@@ -54,7 +41,6 @@ const backupWorkspaceBtn = $("backupWorkspace");
const importWorkspaceBtn = $("importWorkspace");
const importWorkspaceFileInput = $("importWorkspaceFile");
const activeChatTitle = $("activeChatTitle");
const sessionUser = $("sessionUser");
const chatActivityOverlay = $("chatActivityOverlay");
const chatActivityEyebrow = $("chatActivityEyebrow");
const chatActivityTitle = $("chatActivityTitle");
@@ -110,8 +96,6 @@ const onboardingChoices = $("onboardingChoices");
const onboardingBack = $("onboardingBack");
const onboardingContinue = $("onboardingContinue");
const onboardingSkip = $("onboardingSkip");
const authScreens = document.querySelectorAll(".auth-screen");
const toggleButtons = document.querySelectorAll(".auth-toggle .toggle");
const brandNameTargets = document.querySelectorAll("[data-brand-name]");
const brandLogoTargets = document.querySelectorAll("[data-brand-logo]");
const modelSection = document.querySelector(".chat-drawer-section--model");
@@ -121,9 +105,6 @@ export {
$,
activeChatTitle,
activeUserPersonaStatus,
authDiv,
authMsg,
authScreens,
chatActivityDetail,
chatActivityEyebrow,
chatActivityOverlay,
@@ -156,11 +137,6 @@ export {
backupWorkspaceBtn,
importWorkspaceBtn,
importWorkspaceFileInput,
loginForm,
loginPasswordInput,
loginSubmit,
loginUsernameInput,
logoutButton,
messagesDiv,
modelBadgeName,
headerModelName,
@@ -206,12 +182,6 @@ export {
popupInputLabel,
popupModal,
popupTitle,
registerForm,
registerInviteCodeInput,
registerInviteField,
registerPasswordInput,
registerSubmit,
registerUsernameInput,
renameChatBtn,
roleplayCharacterSelect,
roleplayNewPersonaBtn,
@@ -230,9 +200,7 @@ export {
roleplayUserPersonaSelect,
sendBtn,
saveGenerationSettingsBtn,
sessionUser,
brandLogoTargets,
brandNameTargets,
toggleButtons,
userPersonaList
};
-431
View File
@@ -1,431 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Elsewhere</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="app/theme-bootstrap.js"></script>
<link rel="stylesheet" href="style.css?v=20260909-elsewhere"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body data-theme="fakegpt" data-theme-mode="light">
<div class="app-shell">
<section id="auth" class="card auth-card auth-card-compact">
<div class="auth-hero auth-hero-compact">
<div class="brand">
<div class="logo doorway-mark" aria-hidden="true"></div>
<div>
<p class="eyebrow">Your conversation space</p>
<h1 class="brand-wordmark" data-brand-name>elsewhere</h1>
<p class="brand-tagline">A space for every conversation.</p>
</div>
</div>
</div>
<div class="auth-panel">
<div class="auth-toggle">
<button class="toggle active" data-target="login">Login</button>
<button class="toggle" data-target="register">Register</button>
</div>
<div id="loginScreen" class="auth-screen active">
<form id="loginForm" class="auth-form">
<label class="field">
<span>Username</span>
<input id="loginUsername" placeholder="Enter your username" autocomplete="username"/>
</label>
<label class="field">
<span>Password</span>
<input id="loginPassword" type="password" placeholder="Enter your password" autocomplete="current-password"/>
</label>
<button id="loginSubmit" type="submit" class="primary-action">Sign in</button>
</form>
</div>
<div id="registerScreen" class="auth-screen">
<form id="registerForm" class="auth-form">
<label class="field">
<span>Create username</span>
<input id="registerUsername" placeholder="Choose a username" autocomplete="new-username"/>
</label>
<label class="field">
<span>Create password</span>
<input id="registerPassword" type="password" placeholder="Create a password" autocomplete="new-password"/>
</label>
<label id="registerInviteField" class="field hidden">
<span>Invite code</span>
<input id="registerInviteCode" placeholder="Enter invite code" autocomplete="off"/>
</label>
<div class="consent-container">
<input type="checkbox" id="consentCheckbox" required/>
<label for="consentCheckbox">
I agree to the
<a href="/tos.html" target="_blank">Terms of Service</a> and
<a href="/privacy.html" target="_blank">Privacy Policy</a>.
</label>
</div>
<button id="registerSubmit" type="submit" class="primary-action">Create account</button>
</form>
</div>
<p id="authMsg" class="status"></p>
</div>
</section>
<section id="chat" class="card chat-card chat-focus-card hidden" aria-label="AI chat workspace">
<div class="chat-layout chat-layout-focus">
<aside id="chatSidebar" class="chat-sidebar" aria-label="Workspace navigation">
<div class="sidebar-top">
<div class="sidebar-workspace-card">
<div>
<p class="eyebrow">Workspace</p>
<h2 class="brand-wordmark" data-brand-name>elsewhere</h2>
<p id="sessionUser" class="sidebar-account-name">Signed out</p>
</div>
<div class="sidebar-header-actions sidebar-workspace-actions">
<button id="newChat" class="primary-action" type="button">New chat</button>
</div>
</div>
<label class="field compact-field">
<span>Search</span>
<input id="chatSearch" type="search" placeholder="Search chats"/>
</label>
</div>
<div class="sidebar-middle">
<div id="chatList" class="chat-list chat-list-grouped"></div>
<div id="chatListLoading" class="chat-loading hidden" role="status" aria-live="polite">
<span class="chat-loading-dot" aria-hidden="true"></span>
<span id="chatListLoadingText">Loading chats...</span>
</div>
</div>
<div class="sidebar-footer">
<a href="/settings" class="secondary-action">Settings</a>
<a href="/market" class="secondary-action">Market</a>
</div>
</aside>
<section class="chat-main chat-main-focus" aria-label="Active conversation">
<header class="chat-header chat-header-focus">
<button id="chatSidebarToggle" class="icon-button sidebar-toggle" type="button" aria-controls="chatSidebar" aria-expanded="false" aria-label="Open chat sidebar">Menu</button>
<div class="chat-title-block">
<div class="chat-title">
<h2 id="activeChatTitle">New chat</h2>
</div>
<div class="chat-header-context" aria-label="Current chat setup">
<button id="headerModelButton" class="chat-context-item chat-context-button secondary-action" type="button" aria-controls="chatDrawer" aria-label="Change AI model"><span>Model</span><strong id="headerModelName">Loading...</strong></button>
<span class="chat-context-item"><span>Persona</span><strong id="headerPersonaName">None</strong></span>
</div>
</div>
<div class="chat-header-actions">
<div class="chat-actions-menu">
<button id="chatDrawerButton" class="secondary-action overflow-trigger" type="button" aria-expanded="false" aria-controls="chatDrawer">
<span class="overflow-trigger-label">Chat settings</span>
</button>
<div id="chatDrawer" class="chat-drawer hidden" role="dialog" aria-modal="true" aria-labelledby="chatDrawerTitle">
<div class="chat-drawer-panel">
<div class="chat-drawer-header">
<div>
<p class="eyebrow">Conversation</p>
<h2 id="chatDrawerTitle">Settings</h2>
</div>
<button id="chatDrawerClose" class="icon-button" type="button" aria-label="Close chat settings">×</button>
</div>
<div class="chat-drawer-body">
<div class="chat-drawer-summary">
<div class="chat-drawer-pills">
<span id="chatCharacterPill" class="context-pill hidden">Character: none</span>
<span id="chatUserPersonaPill" class="context-pill hidden">User persona: none</span>
</div>
</div>
<details class="chat-drawer-section chat-drawer-disclosure chat-drawer-section--model" open>
<summary class="chat-drawer-row">
<span class="chat-drawer-row-label">
<span class="eyebrow">AI model</span>
<strong>Model</strong>
</span>
<span class="chat-drawer-row-meta">
<strong id="modelBadgeName">Loading...</strong>
<span class="chat-drawer-row-hint">Change</span>
</span>
</summary>
<div id="modelPanel" class="model-panel chat-drawer-disclosure-panel" data-static-panel="true">
<div class="model-select">
<div class="model-select-header">
<label for="model">Model</label>
<span id="modelCount" class="model-count">Loading</span>
</div>
<div class="model-select-control">
<select id="model">
<option disabled selected>Loading models...</option>
</select>
<span class="model-select-chevron" aria-hidden="true"></span>
</div>
</div>
<div id="modelHelp" class="model-help" aria-live="polite">
<div class="model-help-header">
<strong id="modelHelpTitle">Choose a model</strong>
<span id="modelHelpBadge" class="model-help-badge">Waiting</span>
</div>
<p id="modelHelpSummary" class="model-help-summary">Pick a model to see what it is best suited for.</p>
</div>
<div class="generation-settings-grid">
<label class="field"><span>Temperature</span><input id="chatTemperature" type="number" min="0" max="2" step="0.1" placeholder="Default"/></label>
<label class="field"><span>Context length</span><input id="chatContextLength" type="number" min="256" max="131072" placeholder="Default"/></label>
<label class="field"><span>Response length</span><input id="chatResponseLength" type="number" min="1" max="32768" placeholder="Default"/></label>
<label class="field field-wide"><span>System instruction</span><textarea id="chatSystemInstruction" rows="4" maxlength="8000" placeholder="Optional instructions for this chat"></textarea></label>
<button id="saveGenerationSettings" type="button">Save generation settings</button>
</div>
</div>
</details>
<details class="chat-drawer-section chat-drawer-disclosure chat-drawer-section--persona">
<summary class="chat-drawer-row">
<span class="chat-drawer-row-label">
<span class="eyebrow">Your identity</span>
<strong>Persona</strong>
</span>
<span class="chat-drawer-row-meta">
<strong id="chatDrawerPersonaValue">None</strong>
<span class="chat-drawer-row-hint">Change</span>
</span>
</summary>
<div id="personaPanel" class="persona-panel chat-drawer-disclosure-panel" data-static-panel="true">
<div class="persona-section">
<div class="persona-section-header">
<div>
<h2>Your persona</h2>
</div>
<div class="persona-section-actions">
<button id="clearUserPersona" class="secondary-action" type="button">Clear</button>
<a href="/settings" class="secondary-action">Manage</a>
</div>
</div>
<p id="activeUserPersonaStatus" class="status persona-status">No default user persona set.</p>
<div id="userPersonaList" class="persona-list"></div>
</div>
</div>
</details>
<div class="chat-drawer-section">
<p class="dropdown-section-label">Chat</p>
<div class="chat-drawer-action-list">
<button id="renameChat" class="secondary-action chat-drawer-action" type="button" data-chat-action="rename">Rename chat</button>
<button id="pinChat" class="secondary-action chat-drawer-action" type="button" data-chat-action="pin">Pin</button>
<button id="archiveChat" class="secondary-action chat-drawer-action" type="button" data-chat-action="archive">Archive</button>
<button id="exportChat" class="secondary-action chat-drawer-action" type="button" data-chat-action="export">Export</button>
</div>
</div>
<details class="chat-drawer-section chat-drawer-more">
<summary class="chat-drawer-row chat-drawer-row--compact">
<span class="chat-drawer-row-label">
<strong>Tools &amp; workspace</strong>
</span>
<span class="chat-drawer-row-hint">Show</span>
</summary>
<div class="chat-drawer-action-list chat-drawer-disclosure-panel">
<button id="moveChatFolder" class="secondary-action chat-drawer-action" type="button" data-chat-action="folder">Move to folder</button>
<button id="manageChatMemory" class="secondary-action chat-drawer-action" type="button" data-chat-action="memory">Memory facts</button>
<button id="managePersonaState" class="secondary-action chat-drawer-action" type="button" data-chat-action="personaState">Roleplay state</button>
<button id="backupWorkspace" class="secondary-action chat-drawer-action" type="button">Backup workspace</button>
<button id="importWorkspace" class="secondary-action chat-drawer-action" type="button">Import workspace</button>
<input id="importWorkspaceFile" class="hidden" type="file" accept="application/json,.json"/>
<button id="logout" type="button" class="secondary-action chat-drawer-action">Logout</button>
</div>
</details>
<details class="chat-drawer-section chat-drawer-danger">
<summary class="chat-drawer-row chat-drawer-row--compact">
<span class="chat-drawer-row-label">
<strong>Clear conversation</strong>
</span>
<span class="chat-drawer-row-hint">Show</span>
</summary>
<div class="chat-drawer-action-list chat-drawer-disclosure-panel">
<button id="clearChat" class="secondary-action chat-drawer-action chat-drawer-action-danger" type="button" data-chat-action="clear">Clear chat</button>
</div>
</details>
</div>
</div>
</div>
</div>
</div>
</header>
<div id="chatActivityOverlay" class="chat-activity-overlay hidden" role="status" aria-live="polite" aria-atomic="true">
<div class="chat-activity-card">
<div class="chat-activity-mark" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<p id="chatActivityEyebrow" class="eyebrow">Please wait</p>
<h3 id="chatActivityTitle">Preparing chat</h3>
<p id="chatActivityDetail" class="subtitle">The assistant is still working.</p>
</div>
</div>
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite" aria-relevant="additions text"></div>
<div class="input-shell input-shell-focus">
<div class="input-meta">
<p class="composer-hint">Enter to send · Shift+Enter for new line</p>
</div>
<div class="input-container">
<label class="visually-hidden" for="msgInput">Message</label>
<textarea id="msgInput" rows="1" placeholder="Type your message..."></textarea>
<button id="send" type="button" aria-label="Send message"></button>
</div>
</div>
</section>
</div>
</section>
</div>
<div id="personaModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card persona-modal-card side-panel-card">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Persona editor</p>
<h3 id="personaFormTitle">Create persona</h3>
</div>
<button id="personaClose" type="button" class="icon-button">Close</button>
</div>
<form id="personaForm" class="persona-form">
<div class="persona-form-intro">
<p class="persona-helper">AI Characters shape who you talk to. User personas add context for who you are in the scene.</p>
</div>
<p id="personaFormNotice" class="status hidden"></p>
<div class="persona-form-grid">
<label class="field field-wide">
<span>Persona for</span>
<select id="personaType">
<option value="assistant">AI Character</option>
<option value="user">Your persona</option>
</select>
</label>
<label class="field">
<span>Persona name</span>
<input id="personaName" placeholder="Persona name" required/>
</label>
<label class="field">
<span>Persona pronouns</span>
<input id="personaPronouns" placeholder="e.g. she/her, they/them"/>
</label>
<label class="field field-wide">
<span>Appearance</span>
<textarea id="personaAppearance" rows="3" placeholder="Describe their look"></textarea>
</label>
<label class="field field-wide">
<span>Background</span>
<textarea id="personaBackground" rows="3" placeholder="Backstory, history, role, or origins"></textarea>
</label>
<label class="field field-wide">
<span>Details</span>
<textarea id="personaDetails" rows="4" placeholder="Personality traits, voice, goals, quirks, boundaries, style"></textarea>
</label>
<label id="personaExamplesField" class="field field-wide hidden">
<span>Example dialogues</span>
<div class="dialogue-format-card" aria-hidden="true">
<p class="dialogue-format-label">Format</p>
<pre class="dialogue-format-example">{{character}}: Hello, my name is marc
{{user}}: Hi, Marc!
{{character}}: Nice to meet you</pre>
</div>
<textarea id="personaExampleDialogues" rows="5" placeholder="Write example exchanges that define the AI's tone, phrasing, pacing, and personality."></textarea>
</label>
</div>
<div class="persona-actions">
<button id="personaSave" type="submit" class="primary-action">Save persona</button>
</div>
</form>
</div>
</div>
<div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card roleplay-starter-card side-panel-card">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Start roleplay</p>
<h3 id="roleplayStarterTitle">Choose your roleplay</h3>
</div>
<button id="roleplayStarterClose" type="button" class="icon-button">Close</button>
</div>
<div class="roleplay-starter-intro">
<p id="roleplayStarterIntroCopy" class="persona-helper">Pick a character and choose who you are before moving on to the opening scene.</p>
</div>
<p id="roleplayStarterNotice" class="status hidden"></p>
<div class="roleplay-starter-actions">
<button id="roleplayNewPersona" class="secondary-action" type="button">Create AI Character</button>
</div>
<div id="roleplayStarterStepOne" class="persona-form-grid">
<label class="field field-wide">
<span>1. Character</span>
<select id="roleplayCharacterSelect"></select>
</label>
<label class="field field-wide">
<span>2. You in the scene</span>
<select id="roleplayUserPersonaSelect"></select>
</label>
</div>
<div id="roleplayStarterStepTwo" class="roleplay-starter-page hidden">
<label class="field field-wide">
<span>Optional opening direction</span>
<textarea id="roleplayScenarioInput" rows="4" placeholder="Describe the kind of opening scene you want, such as a setting, conflict, relationship dynamic, or mood. Leave it empty and the AI will invent its own scenario."></textarea>
</label>
<p class="subtitle">Leave this empty if you want the AI Character to invent the scenario on its own. No character limit.</p>
<div id="roleplayStarterSuggestions" class="quick-prompts" aria-label="Scenario suggestions"></div>
</div>
<div class="popup-actions">
<button id="roleplayStarterCancel" type="button" class="secondary-action">Cancel</button>
<button id="roleplayStarterBack" type="button" class="secondary-action hidden">Back</button>
<button id="roleplayStarterConfirm" type="button">Continue</button>
</div>
</div>
</div>
<div id="popupModal" class="modal-overlay hidden">
<div class="modal-card popup-card" role="dialog" aria-modal="true" aria-labelledby="popupTitle" aria-describedby="popupDescription">
<div class="modal-header">
<div class="persona-modal-heading">
<p id="popupEyebrow" class="eyebrow">Action</p>
<h3 id="popupTitle">Confirm action</h3>
</div>
<button id="popupClose" type="button" class="icon-button">Close</button>
</div>
<div class="popup-content">
<p id="popupDescription" class="subtitle">Please confirm this action.</p>
<label id="popupField" class="field hidden">
<span id="popupInputLabel">Value</span>
<input id="popupInput" type="text" autocomplete="off"/>
</label>
</div>
<div class="popup-actions">
<button id="popupCancel" type="button" class="secondary-action">Cancel</button>
<button id="popupConfirm" type="button">Confirm</button>
</div>
</div>
</div>
<div id="onboardingModal" class="modal-overlay hidden">
<div class="modal-card onboarding-card">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Get started</p>
<h3 id="onboardingTitle">Where shall we start?</h3>
</div>
<button id="onboardingSkip" type="button" class="icon-button">Skip</button>
</div>
<p id="onboardingSubtitle" class="subtitle">Ask a question, explore an idea, or step into a story.</p>
<div id="onboardingChoices" class="onboarding-choices"></div>
<div class="popup-actions">
<button id="onboardingBack" type="button" class="secondary-action hidden">Back</button>
<button id="onboardingContinue" type="button">Continue</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
<script type="module" src="script.js?v=20260908-model-descriptions"></script>
</body>
</html>
+12 -14
View File
@@ -3,26 +3,24 @@
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>Elsewhere Market</title>
<title>Elsewhere Discover</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="app/theme-bootstrap.js"></script>
<link rel="stylesheet" href="style.css?v=20260909-elsewhere"/>
<script src="/app/theme-bootstrap.js"></script>
<link rel="stylesheet" href="/style.css?v=20260909-elsewhere"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/shell.css">
</head>
<body data-theme="fakegpt" data-theme-mode="light">
<div class="market-shell card market-shell-simple">
<body class="site-page market-page" data-theme="fakegpt" data-theme-mode="light">
<!-- site-header -->
<main id="main" class="market-shell page-content">
<header class="market-header market-header-simple">
<div>
<p class="eyebrow">Shared personas</p>
<h1><span class="brand-wordmark" data-brand-name>elsewhere</span> Market</h1>
</div>
<div class="market-header-actions">
<button id="refreshMarket" class="secondary-action" type="button">Refresh</button>
<a href="/settings" class="secondary-action">Settings</a>
<a href="/" class="secondary-action">Back to chat</a>
<h1>Discover</h1>
</div>
<div class="market-header-actions"><button id="refreshMarket" class="secondary-action" type="button">Refresh</button></div>
</header>
<div class="market-search">
@@ -92,7 +90,7 @@
</section>
</div>
</section>
</div>
</main>
<div id="marketPreviewModal" class="modal-overlay hidden">
<div class="modal-card market-preview-card">
@@ -239,6 +237,6 @@
</div>
</div>
</div>
<script type="module" src="market.js"></script>
</body>
<script type="module" src="/market.js"></script>
<script type="module" src="/app/workspaceNavigation.js"></script></body>
</html>
+6 -4
View File
@@ -540,7 +540,7 @@ async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPr
return;
}
if (userPersonaSelection === "create_new") {
window.location.href = "/settings?view=my-roleplay-companions&create=user";
window.location.href = "/app/personas?view=my-roleplay-companions&create=user";
return;
}
@@ -560,7 +560,7 @@ async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPr
setMarketStatus(res.error || "Unable to start chat.", "error");
return;
}
window.location.href = `/?chat=${res.chat.id}`;
window.location.href = `/app/chats/${res.chat.id}`;
}
async function savePersona() {
@@ -623,7 +623,7 @@ if (marketCreateAiCharacterBtn) {
}
if (marketCreateUserPersonaBtn) {
marketCreateUserPersonaBtn.addEventListener("click", () => {
window.location.href = "/settings?view=my-roleplay-companions&create=user";
window.location.href = "/app/personas?view=my-roleplay-companions&create=user";
});
}
marketPreviewClose.addEventListener("click", closeMarketPreview);
@@ -633,7 +633,7 @@ marketPreviewModal.addEventListener("click", (event) => {
marketPreviewUserPersonaSelect.addEventListener("change", () => {
if (!pendingMarketPersona || pendingMarketPersona.persona_type !== "assistant") return;
if (marketPreviewUserPersonaSelect.value === "create_new") {
window.location.href = "/settings?view=my-roleplay-companions&create=user";
window.location.href = "/app/personas?view=my-roleplay-companions&create=user";
return;
}
marketPreviewConfirm.classList.toggle("hidden", !marketPreviewUserPersonaSelect.value);
@@ -679,6 +679,8 @@ window.addEventListener("load", async () => {
setMarketSort("user", params.get("userSort") || "best", {reload: false});
setMarketView(params.get("view") || "ai-characters");
await loadMarketPersonas();
const selectedPersona = [...marketPersonasByType.assistant, ...marketPersonasByType.user].find(persona => persona.id === Number(params.get("persona")));
if (selectedPersona) openMarketPreview(selectedPersona);
if (params.get("create") === "assistant") {
setMarketView("ai-characters");
openPersonaForm();
+7 -2
View File
@@ -7,9 +7,13 @@
<title>Privacy Policy – DeepFake</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="legal.css" />
<script src="/app/theme-bootstrap.js"></script>
<link rel="stylesheet" href="/shell.css">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body class="legal-page">
<main class="legal-content">
<body class="site-page legal-page">
<!-- site-header -->
<main id="main" class="legal-content">
<h1>Privacy Policy</h1>
<p>Last updated: <strong>06.01.2026</strong></p>
@@ -85,5 +89,6 @@
<p>We may update this document. Changes will be posted with a “Last updated” date.</p>
</section>
</main>
<script type="module" src="/app/workspaceNavigation.js"></script>
</body>
</html>
+13 -66
View File
@@ -6,9 +6,6 @@ import {applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode,
import {
activeChatTitle,
activeUserPersonaStatus,
authDiv,
authMsg,
authScreens,
chatActivityDetail,
chatActivityEyebrow,
chatActivityOverlay,
@@ -39,11 +36,6 @@ import {
headerModelName,
headerPersonaName,
moveChatFolderBtn,
loginForm,
loginPasswordInput,
loginSubmit,
loginUsernameInput,
logoutButton,
messagesDiv,
modelBadgeName,
modelCount,
@@ -86,12 +78,6 @@ import {
popupInputLabel,
popupModal,
popupTitle,
registerForm,
registerInviteCodeInput,
registerInviteField,
registerPasswordInput,
registerSubmit,
registerUsernameInput,
renameChatBtn,
roleplayCharacterSelect,
roleplayNewPersonaBtn,
@@ -110,10 +96,8 @@ import {
roleplayUserPersonaSelect,
sendBtn,
saveGenerationSettingsBtn,
sessionUser,
brandLogoTargets,
brandNameTargets,
toggleButtons,
userPersonaList
} from "./app/dom.js";
@@ -133,8 +117,9 @@ let messageRetryState = new Map();
let availableModelsById = new Map();
function setAuthMessage(message, state = "") { authMsg.textContent = message; authMsg.className = state ? `status ${state}` : "status"; }
function setNotice(message = "", state = "") {
const notice = document.getElementById("workspaceNotice");
if (notice) { notice.textContent = state === "error" ? message : ""; notice.classList.toggle("hidden", state !== "error"); }
return {message, state};
}
function setPersonaFormNotice(message = "", state = "") {
@@ -192,7 +177,7 @@ function applyWorkspaceMode(nextMode, persist = true) {
}
function isMobileLayout() {
return window.matchMedia("(max-width: 820px)").matches;
return window.matchMedia("(max-width: 980px)").matches;
}
function closeMobileSidebar() {
@@ -852,9 +837,7 @@ function updateWorkspaceCopy() {
if (activeChatTitle) {
activeChatTitle.textContent = activeChat ? activeChat.title : "New chat";
}
if (sessionUser) {
sessionUser.textContent = currentUsername || "-";
}
renderDrawerSummary();
updateComposerPlaceholder();
}
@@ -1390,12 +1373,6 @@ async function stopGeneration() {
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
}
function showAuthScreen(target) {
authScreens.forEach((screen) => screen.classList.toggle("active", screen.id === `${target}Screen`));
toggleButtons.forEach((btn) => btn.classList.toggle("active", btn.dataset.target === target));
setAuthMessage("");
}
async function setActiveChat(id) {
if (isMobileLayout()) closeMobileSidebar();
setChatLoading(true, "Opening chat...");
@@ -1405,6 +1382,7 @@ async function setActiveChat(id) {
detail: "Fetching messages and restoring the current scene."
});
activeChatId = id;
history.replaceState(null, "", `/app/chats/${id}`);
messageRetryState = new Map();
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
const res = await get(`/chats/${id}/messages`);
@@ -1431,8 +1409,10 @@ async function loadChatSessions() {
return setNotice(res.error, "error");
}
chatSessions = res.chats || []; renderChatList();
if (location.pathname.endsWith("/new") && !activeChatId) { currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; }
if (requestedChatId && !getChatById(requestedChatId) && !activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); }
if (chatSessions.length) {
const preferredChatId = (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : activeChatId && getChatById(activeChatId) ? activeChatId : chatSessions[0].id;
const preferredChatId = activeChatId && getChatById(activeChatId) ? activeChatId : (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : chatSessions[0].id;
setChatLoading(false);
return setActiveChat(preferredChatId);
}
@@ -1804,35 +1784,15 @@ async function displayModels() {
async function checkSession() {
const res = await get("/session");
if (registerInviteField) registerInviteField.classList.toggle("hidden", !res.inviteOnlyRegistration);
if (!res.user) return false;
currentUsername = res.user; authDiv.classList.add("hidden"); chatDiv.classList.remove("hidden");
if (!res.user) { window.location.replace(`/login?next=${encodeURIComponent(location.pathname + location.search)}`); return false; }
currentUsername = res.user; chatDiv.classList.remove("hidden");
await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]);
maybeShowOnboarding();
// Starters are offered on workspace home.
const starter = new URLSearchParams(location.search).get("prompt");
if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); }
msgInput.focus(); return true;
}
async function handleAuth(endpoint, credentials) {
const {username, password} = credentials, submitBtn = endpoint === "login" ? loginSubmit : registerSubmit;
if (!username || !password) return setAuthMessage("Please enter both username and password.", "error");
submitBtn.disabled = true; setAuthMessage("Processing...");
const payload = endpoint === "register"
? {username, password, inviteCode: registerInviteCodeInput?.value.trim() || ""}
: {username, password};
const res = await post(`/${endpoint}`, payload);
submitBtn.disabled = false;
if (res.error) return setAuthMessage(res.error, "error");
if (endpoint === "login") {
currentUsername = username; authDiv.classList.add("hidden"); chatDiv.classList.remove("hidden");
await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]);
maybeShowOnboarding();
setNotice("Ready.", "success"); msgInput.focus();
} else {
setAuthMessage("Registration successful. You can now log in.", "success");
showAuthScreen("login"); loginUsernameInput.value = username; loginPasswordInput.focus();
}
}
async function sendMessage() {
if (isProcessing) return;
const message = msgInput.value.trim();
@@ -1934,19 +1894,6 @@ function bindChatDrawerActions() {
});
}
toggleButtons.forEach((btn) => btn.addEventListener("click", () => showAuthScreen(btn.dataset.target)));
loginForm.addEventListener("submit", (event) => { event.preventDefault(); void handleAuth("login", {username: loginUsernameInput.value.trim(), password: loginPasswordInput.value.trim()}); });
registerForm.addEventListener("submit", (event) => { event.preventDefault(); void handleAuth("register", {username: registerUsernameInput.value.trim(), password: registerPasswordInput.value.trim()}); });
logoutButton.addEventListener("click", async () => {
closeChatDrawer();
await post("/logout", {}); chatDiv.classList.add("hidden"); authDiv.classList.remove("hidden");
activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = [];
messageRetryState = new Map();
activeUserPersonaId = null; publishedPersonaIds = new Set(); messagesDiv.innerHTML = ""; renderChatList(); updateChatActionState();
updateComposerPlaceholder();
updateSendState();
showAuthScreen("login"); setAuthMessage("Logged out.", "success"); setNotice("Ready.");
});
sendBtn.addEventListener("click", () => { void (isProcessing ? stopGeneration() : sendMessage()); });
msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void sendMessage(); } });
msgInput.addEventListener("input", function () {
+10 -12
View File
@@ -7,21 +7,19 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="app/theme-bootstrap.js"></script>
<link rel="stylesheet" href="style.css?v=20260909-elsewhere"/>
<script src="/app/theme-bootstrap.js"></script>
<link rel="stylesheet" href="/style.css?v=20260909-elsewhere"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/shell.css">
</head>
<body data-theme="fakegpt" data-theme-mode="light">
<div class="settings-shell card">
<body class="site-page settings-page" data-theme="fakegpt" data-theme-mode="light">
<!-- site-header -->
<main id="main" class="settings-shell page-content">
<header class="market-header market-header-simple">
<div>
<p class="eyebrow">Elsewhere settings</p>
<h1>Preferences</h1>
</div>
<div class="market-header-actions">
<a href="/" class="secondary-action">Back to chat</a>
<a href="/market" class="secondary-action">Market</a>
</div>
</header>
<p id="settingsNotice" class="status">Loading settings...</p>
@@ -29,7 +27,7 @@
<section class="settings-layout">
<aside class="settings-nav" aria-label="Settings sections" role="tablist">
<button class="settings-nav-item active" type="button" role="tab" aria-selected="true" data-settings-view="personal">Account</button>
<button class="settings-nav-item" type="button" role="tab" aria-selected="false" data-settings-view="my-roleplay-companions">Personas</button>
<button class="settings-nav-item" type="button" role="tab" aria-selected="false" data-settings-view="design">Appearance</button>
<button id="diagnosticsNavItem" class="settings-nav-item hidden" type="button" role="tab" aria-selected="false" data-settings-view="diagnostics">Diagnostics</button>
</aside>
@@ -216,7 +214,7 @@
</section>
</div>
</section>
</div>
</main>
<div id="settingsPersonaModal" class="modal-overlay hidden">
<div class="modal-card persona-modal-card">
@@ -300,6 +298,6 @@
</div>
</div>
</div>
<script type="module" src="settings.js"></script>
</body>
<script type="module" src="/settings.js"></script>
<script type="module" src="/app/workspaceNavigation.js"></script></body>
</html>
+7 -2
View File
@@ -136,6 +136,7 @@ function applyThemeMode(modeKey, persist = true) {
function setSettingsView(view) {
const normalizedView = ["my-ai-characters", "my-personas"].includes(view) ? "my-roleplay-companions" : view;
if (normalizedView === "my-roleplay-companions" && location.pathname !== "/app/personas") { location.assign("/app/personas" + location.search); return; }
if (normalizedView === "diagnostics" && !isAdminUser) {
setNotice("Diagnostics are available to admin users only.", "error");
return;
@@ -309,6 +310,8 @@ async function loadProfile() {
setNotice(res.error, "error");
return false;
}
const accountName = document.querySelector(".shell-username");
if (accountName) { accountName.textContent = res.username || ""; accountName.title = res.username || ""; }
currentUsernameInput.value = res.username || "";
newUsernameInput.value = res.username || "";
isAdminUser = res.username === "admin";
@@ -575,11 +578,13 @@ window.addEventListener("load", async () => {
initializeAppearance({title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect});
workspaceModeSelect.value = localStorage.getItem("krishd-workspace-mode") || "basic";
const params = new URLSearchParams(window.location.search);
const view = params.get("view") || "personal";
const personaPage = location.pathname === "/app/personas";
const view = personaPage ? "my-roleplay-companions" : params.get("view") || "personal";
if (personaPage) { document.querySelector(".market-header .eyebrow").textContent = "Your collection"; document.querySelector("h1").textContent = "My personas"; document.title = "My personas - Elsewhere"; document.querySelector(".settings-nav").hidden = true; document.querySelector(".settings-layout").style.gridTemplateColumns = "1fr"; }
const profileOk = await loadProfile();
setSettingsView(view);
const personasOk = await loadPersonas();
if (profileOk && personasOk && view !== "diagnostics") setNotice("Settings loaded.");
if (profileOk && personasOk && view !== "diagnostics") setNotice(personaPage ? "Personas loaded." : "Settings loaded.");
const create = params.get("create");
if (create === "assistant") {
+8 -3
View File
@@ -7,9 +7,13 @@
<title>Terms of Service – DeepFake</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="legal.css" />
<script src="/app/theme-bootstrap.js"></script>
<link rel="stylesheet" href="/shell.css">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body class="legal-page">
<main class="legal-content">
<body class="site-page legal-page">
<!-- site-header -->
<main id="main" class="legal-content">
<h1>Terms of Service</h1>
<p>Last updated: <strong>06.01.2026</strong></p>
@@ -25,7 +29,7 @@
<section>
<h2>Data Processing</h2>
<p>By using the service, you consent to storing and processing your data as described in the <a href="privacy-policy.html">Privacy Policy</a>.</p>
<p>By using the service, you consent to storing and processing your data as described in the <a href="/privacy.html">Privacy Policy</a>.</p>
</section>
<section>
@@ -43,5 +47,6 @@
<p>We may modify these Terms at any time. Updates will be reflected with a new “Last updated” date.</p>
</section>
</main>
<script type="module" src="/app/workspaceNavigation.js"></script>
</body>
</html>
+5
View File
@@ -3,6 +3,7 @@ import {execFileSync} from "node:child_process";
const files = [
"server.js",
"src/app.js",
"src/pageLayout.js",
"src/config.js",
"src/db/index.js",
"src/db/repositories.js",
@@ -21,6 +22,10 @@ const files = [
"src/services/modelService.js",
"src/services/personaService.js",
"public/script.js",
"public/pages.js",
"public/app/workspaceNavigation.js",
"public/settings.js",
"public/market.js",
"public/app/api.js",
"public/app/chatDrawer.js",
"public/app/constants.js",
+6 -6
View File
@@ -257,11 +257,6 @@ export function createApp(options = {}) {
app.use(observability.middleware);
app.use(express.json({limit: "1mb"}));
app.use(express.static(config.publicDir, {
maxAge: config.staticMaxAge,
etag: config.staticEtag,
fallthrough: config.staticFallthrough
}));
app.use(session({
store: options.sessionStore || (config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600})),
secret: config.sessionSecret,
@@ -284,12 +279,17 @@ export function createApp(options = {}) {
app.locals.observability = observability;
app.locals.close = createCloseHandler(closeHandlers);
app.use(createPagesRouter(config));
app.use(express.static(config.publicDir, {
maxAge: config.staticMaxAge,
etag: config.staticEtag,
fallthrough: config.staticFallthrough
}));
app.use(createAuthRouter({repositories, authRateLimiters, config}));
app.use(createSettingsRouter({repositories}));
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
app.use(createSystemRouter({modelService, config}));
app.use(createPagesRouter(config));
app.use(notFoundHandler);
app.use(errorHandler);
+2
View File
@@ -27,6 +27,8 @@ export function createAuthRouter({repositories, authRateLimiters, config}) {
const hashed = await bcrypt.hash(password, 10);
repositories.insertUser(username, hashed);
req.session.user = username;
req.session.sessionVersion = 0;
return res.json({message: "Registration successful"});
});
+33 -5
View File
@@ -1,11 +1,39 @@
import express from "express";
import {readFile} from "node:fs/promises";
import {renderSiteHeader} from "../pageLayout.js";
export function createPagesRouter(config) {
const router = express.Router();
router.get("/market", (req, res) => res.sendFile(`${config.publicDir}/market.html`));
router.get("/settings", (req, res) => res.sendFile(`${config.publicDir}/settings.html`));
router.get("/", (req, res) => res.sendFile(`${config.publicDir}/index.html`));
const page = (name) => async (req, res, next) => {
try {
const template = await readFile(`${config.publicDir}/${name}.html`, "utf8");
const header = renderSiteHeader({user: req.session.user, pathname: req.path});
res.set("Cache-Control", "private, no-store").type("html").send(template.replace("<!-- site-header -->", () => header));
} catch (error) { next(error); }
};
const signedIn = (req, res, next) => req.session.user ? next() : res.redirect(`/login?next=${encodeURIComponent(req.originalUrl)}`);
router.get(["/", "/index.html"], (req, res, next) => {
if (/^\d+$/.test(String(req.query.chat || ""))) return res.redirect(`/app/chats/${req.query.chat}`);
return page("landing")(req, res, next);
});
router.get(["/login", "/register"], (req, res, next) => {
if (req.session.user) return res.redirect("/app");
return page("account")(req, res, next);
});
router.get("/market", (req, res, next) => page(req.session.user ? "market" : "discover")(req, res, next));
router.get("/privacy.html", page("privacy"));
router.get("/tos.html", page("tos"));
router.get("/api/discover", (req, res) => {
const personas = req.app.locals.repositories.listMarketPersonas().map(({id, name, background, creator_username, persona_type}) => ({id, name, description: String(background || "").slice(0, 240), creator_username, persona_type}));
res.json({personas});
});
router.get(["/home.html", "/account.html", "/discover.html", "/landing.html"], (req, res) => res.redirect(({"/home.html": "/app", "/account.html": "/login", "/discover.html": "/market", "/landing.html": "/"})[req.path]));
router.get("/settings", (req, res) => res.redirect(`/app/settings${req.url.slice(req.path.length)}`));
router.get("/app", signedIn, page("home"));
router.get(["/app/chats", "/app/chats/:id"], signedIn, page("chat"));
router.get(["/app/settings", "/app/personas"], signedIn, page("settings"));
router.get("/chat.html", signedIn, (req, res) => res.redirect("/app/chats"));
router.get("/settings.html", signedIn, (req, res) => res.redirect("/app/settings"));
router.get("/market.html", (req, res) => res.redirect("/market"));
return router;
}
+76
View File
@@ -988,3 +988,79 @@ test("market listing supports persona type filters, sorting, and favorite state"
await server.close();
});
test("public pages, workspace guards, and legacy chat links have distinct destinations", async () => {
const server = await startTestServer();
try {
for (const [url, marker] of [["/", "A space for every conversation."], ["/login", 'id="accountForm"'], ["/register", 'id="accountForm"'], ["/market", 'id="publicSearch"']]) {
const response = await fetch(server.baseUrl + url);
assert.equal(response.status, 200);
const html = await response.text();
assert.ok(html.includes(marker));
assert.ok(!html.includes('id="chat"'));
}
for (const url of ["/app", "/app/personas", "/app/settings", "/app/chats/42", "/app/chats/new?prompt=hello"]) {
const response = await fetch(server.baseUrl + url, {redirect: "manual"});
assert.equal(response.status, 302);
assert.equal(response.headers.get("location"), `/login?next=${encodeURIComponent(url)}`);
}
const legacy = await fetch(server.baseUrl + "/?chat=42", {redirect: "manual"});
assert.equal(legacy.headers.get("location"), "/app/chats/42");
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "newvisitor", password: "password123"}, jar});
const session = await request(server.baseUrl, "/session", {jar});
assert.equal(session.json.user, "newvisitor", "registration signs in immediately");
const home = await fetch(server.baseUrl + "/app", {headers: jar.headers()});
assert.match(await home.text(), /What would you like to do/);
const chat = await fetch(server.baseUrl + "/app/chats/new", {headers: jar.headers()});
const html = await chat.text();
assert.ok(html.includes('src="/script.js'));
assert.ok(!html.includes('id="loginForm"'));
assert.deepEqual((await request(server.baseUrl, "/chats", {jar})).json.chats, [], "home and chat pages do not create chats on GET");
await request(server.baseUrl, "/logout", {method: "POST", body: {}, jar});
assert.equal((await fetch(server.baseUrl + "/app", {headers: jar.headers(), redirect: "manual"})).status, 302);
} finally { await server.close(); }
});
test("public discovery only exposes published persona summaries", async () => {
const server = await startTestServer();
try {
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "publicmaker", password: "password123"}, jar});
const created = await request(server.baseUrl, "/personas", {method: "POST", body: {name: "Rainkeeper", background: "A guide to a rainy city", details: "Private implementation detail", personaType: "assistant"}, jar});
const id = created.json.persona.id;
assert.equal((await request(server.baseUrl, "/api/discover")).json.personas.length, 0);
await request(server.baseUrl, `/personas/${id}/publish`, {method: "POST", body: {}, jar});
const result = await request(server.baseUrl, "/api/discover");
assert.equal(result.status, 200);
assert.equal(result.json.personas[0].name, "Rainkeeper");
assert.equal(result.json.personas[0].description, "A guide to a rainy city");
assert.deepEqual(Object.keys(result.json.personas[0]).sort(), ["creator_username", "description", "id", "name", "persona_type"]);
} finally { await server.close(); }
});
test("page headers share navigation and keep session markup private", async () => {
const server = await startTestServer();
try {
const jar = new CookieJar();
const username = '<maker&friend>';
await request(server.baseUrl, "/register", {method: "POST", body: {username, password: "password123"}, jar});
let sharedNavigation;
for (const route of ["/", "/app", "/app/chats/new", "/app/personas", "/market", "/app/settings", "/privacy.html", "/tos.html"]) {
const response = await fetch(server.baseUrl + route, {headers: jar.headers()});
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "private, no-store");
const html = await response.text();
assert.equal((html.match(/class="shell-header"/g) || []).length, 1);
assert.ok(html.includes('&lt;maker&amp;friend&gt;'));
assert.ok(!html.includes(username), "account names must be escaped in header markup");
const navigation = html.match(/<nav class="shell-nav"[^>]*>(.*?)<\/nav>/s)[1].replace(/ aria-current="page"/g, "");
if (sharedNavigation) assert.equal(navigation, sharedNavigation);
sharedNavigation = navigation;
}
const guest = await fetch(server.baseUrl + "/");
const html = await guest.text();
assert.ok(!html.includes('&lt;maker&amp;friend&gt;'));
assert.ok(html.includes('href="/login"'));
} finally { await server.close(); }
});