diff --git a/README.md b/README.md
index 8ff1f00..a17d005 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,7 @@ Add your production Vercel URL to the authorized redirect URIs in Google Cloud C
- **File inputs** — attach text/code files; contents are inlined as XML-tagged blocks
- **Web search** — per-request toggle; uses each provider's native search tool
- **System prompt** — configurable, persisted server-side per user
+- **Girl Mode** — optional pink, sparkly UI theme persisted per user
- **Encrypted chat history** — saved chats are AES-GCM encrypted client-side; key is stored server-side so it's shared across devices
- **Shared chats** — generate a shareable read-only URL with OG image preview; authenticated users can fork the chat to continue it
- **Google OAuth** — restricted to a configurable allowlist of emails
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
index f4c8aac..a21a0bb 100644
--- a/app/api/settings/route.ts
+++ b/app/api/settings/route.ts
@@ -16,7 +16,7 @@ export async function GET(req: Request) {
}
const result = await query(
- 'SELECT system_prompt, save_history, key_jwk FROM user_settings WHERE email = $1',
+ 'SELECT system_prompt, save_history, key_jwk, girl_mode FROM user_settings WHERE email = $1',
[session.user.email],
);
const row = result.rows[0];
@@ -26,11 +26,13 @@ export async function GET(req: Request) {
durationMs: Date.now() - start,
hasKey: !!row?.key_jwk,
saveHistory: row?.save_history ?? false,
+ girlMode: row?.girl_mode ?? false,
newUser: !row,
}, 'settings.get');
return jsonResponse({
systemPrompt: row?.system_prompt ?? '',
saveHistory: row?.save_history ?? false,
+ girlMode: row?.girl_mode ?? false,
keyJwk: row?.key_jwk ?? null,
}, {}, { requestId, cacheControl: PRIVATE_NO_STORE });
} catch (error) {
@@ -63,18 +65,20 @@ export async function PUT(req: Request) {
}
await query(
- `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk) VALUES ($1, $2, $3, $4)
+ `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk, girl_mode) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (email) DO UPDATE SET
- system_prompt = EXCLUDED.system_prompt,
- save_history = EXCLUDED.save_history,
- key_jwk = COALESCE(EXCLUDED.key_jwk, user_settings.key_jwk)`,
- [session.user.email, parsed.value.systemPrompt, parsed.value.saveHistory, parsed.value.keyJwk],
+ system_prompt = EXCLUDED.system_prompt,
+ save_history = EXCLUDED.save_history,
+ key_jwk = COALESCE(EXCLUDED.key_jwk, user_settings.key_jwk),
+ girl_mode = EXCLUDED.girl_mode`,
+ [session.user.email, parsed.value.systemPrompt, parsed.value.saveHistory, parsed.value.keyJwk, parsed.value.girlMode],
);
logger.info({
requestId,
user: session.user.email,
durationMs: Date.now() - start,
saveHistory: parsed.value.saveHistory,
+ girlMode: parsed.value.girlMode,
hasKey: !!parsed.value.keyJwk,
}, 'settings.put');
return new Response(null, {
diff --git a/app/error.tsx b/app/error.tsx
index 9b17aab..50a8f4c 100644
--- a/app/error.tsx
+++ b/app/error.tsx
@@ -23,11 +23,11 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
}, [error]);
return (
-
-
GIPPIDY
+
+ GIPPIDY
something went wrong
- {error.digest && ref: {error.digest}}
-
+ {error.digest && ref: {error.digest}}
+
);
}
diff --git a/app/globals.css b/app/globals.css
index f5468aa..d9f6ee7 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -2,11 +2,74 @@
:root {
--bg: #0c0c0c;
+ --body-bg: var(--bg);
--fg: #c8c8c8;
+ --assistant-fg: #e0e0e0;
--green: #33ff33;
--blue: #5599ff;
--dim: #555;
--border: #2a2a2a;
+ --surface: #080808;
+ --surface-alt: #0a0a0a;
+ --surface-muted: #111;
+ --surface-deep: #060606;
+ --surface-highlight: #091109;
+ --strong: #fff;
+ --em: #bbb;
+ --link: var(--blue);
+ --placeholder: #4b4b4b;
+ --message-user-border: rgba(85, 153, 255, 0.6);
+ --message-user-bg: rgba(85, 153, 255, 0.03);
+ --message-user-label: rgba(85, 153, 255, 0.85);
+ --message-assistant-border: rgba(51, 255, 51, 0.55);
+ --message-assistant-bg: rgba(51, 255, 51, 0.02);
+ --message-assistant-label: rgba(51, 255, 51, 0.85);
+ --divider-soft: rgba(255, 255, 255, 0.05);
+ --code-bg: #1a1a1a;
+ --code-border: #2a2a2a;
+ --share-border: #383838;
+ --panel-shadow: none;
+ --app-shadow: none;
+ --accent-glow-soft: rgba(51, 255, 51, 0.08);
+ --accent-glow-strong: rgba(51, 255, 51, 0.18);
+ --sparkle-opacity: 0;
+ --sparkle-aura-opacity: 0;
+}
+
+:root[data-girl-mode='true'] {
+ --bg: #17081f;
+ --body-bg: radial-gradient(circle at top, #5c2075 0%, #2b0a3b 40%, #110415 100%);
+ --fg: #ffeaf6;
+ --assistant-fg: #fff6fc;
+ --green: #ff77c8;
+ --blue: #b69bff;
+ --dim: #d8a8ca;
+ --border: rgba(255, 193, 231, 0.32);
+ --surface: rgba(70, 25, 92, 0.72);
+ --surface-alt: rgba(88, 34, 114, 0.72);
+ --surface-muted: rgba(105, 40, 131, 0.76);
+ --surface-deep: rgba(45, 15, 58, 0.9);
+ --surface-highlight: rgba(255, 255, 255, 0.08);
+ --strong: #fffaff;
+ --em: #ffd7f0;
+ --link: #ffd1ec;
+ --placeholder: rgba(255, 234, 246, 0.45);
+ --message-user-border: rgba(182, 155, 255, 0.95);
+ --message-user-bg: rgba(182, 155, 255, 0.12);
+ --message-user-label: rgba(182, 155, 255, 0.98);
+ --message-assistant-border: rgba(255, 119, 200, 0.95);
+ --message-assistant-bg: rgba(255, 119, 200, 0.11);
+ --message-assistant-label: rgba(255, 119, 200, 0.98);
+ --divider-soft: rgba(255, 255, 255, 0.12);
+ --code-bg: rgba(255, 255, 255, 0.08);
+ --code-border: rgba(255, 255, 255, 0.12);
+ --share-border: rgba(255, 209, 236, 0.42);
+ --panel-shadow: 0 0 26px rgba(255, 119, 200, 0.12);
+ --app-shadow: 0 0 40px rgba(255, 119, 200, 0.14);
+ --accent-glow-soft: rgba(255, 119, 200, 0.18);
+ --accent-glow-strong: rgba(255, 119, 200, 0.28);
+ --sparkle-opacity: 0.9;
+ --sparkle-aura-opacity: 1;
}
html, body {
@@ -16,13 +79,61 @@ html, body {
}
body {
- background: var(--bg);
+ position: relative;
+ isolation: isolate;
+ background: var(--body-bg);
color: var(--fg);
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
line-height: 1.6;
}
+body::before,
+body::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+}
+
+body::before {
+ opacity: var(--sparkle-opacity);
+ background:
+ radial-gradient(circle at 10% 18%, rgba(255, 255, 255, 0.95) 0 1px, transparent 1.8px),
+ radial-gradient(circle at 24% 72%, rgba(255, 255, 255, 0.9) 0 1px, transparent 1.9px),
+ radial-gradient(circle at 38% 32%, rgba(255, 210, 244, 0.85) 0 1.2px, transparent 2.1px),
+ radial-gradient(circle at 52% 12%, rgba(255, 255, 255, 0.92) 0 1px, transparent 1.8px),
+ radial-gradient(circle at 68% 58%, rgba(255, 255, 255, 0.9) 0 1px, transparent 1.9px),
+ radial-gradient(circle at 82% 24%, rgba(255, 215, 241, 0.88) 0 1.1px, transparent 2px),
+ radial-gradient(circle at 90% 80%, rgba(255, 255, 255, 0.95) 0 1px, transparent 1.9px),
+ radial-gradient(circle at 16% 90%, rgba(255, 220, 244, 0.85) 0 1.1px, transparent 2px);
+ mix-blend-mode: screen;
+ animation: sparkle-twinkle 8s ease-in-out infinite alternate;
+}
+
+body::after {
+ opacity: var(--sparkle-aura-opacity);
+ background:
+ radial-gradient(circle at 20% 14%, rgba(255, 119, 200, 0.22), transparent 30%),
+ radial-gradient(circle at 82% 12%, rgba(182, 155, 255, 0.22), transparent 26%),
+ radial-gradient(circle at 28% 78%, rgba(255, 202, 232, 0.18), transparent 24%),
+ radial-gradient(circle at 74% 72%, rgba(255, 119, 200, 0.14), transparent 28%);
+ animation: sparkle-drift 18s ease-in-out infinite;
+}
+
+@keyframes sparkle-twinkle {
+ 0% { transform: translateY(0); filter: brightness(0.95); }
+ 50% { filter: brightness(1.2); }
+ 100% { transform: translateY(-8px); filter: brightness(1.05); }
+}
+
+@keyframes sparkle-drift {
+ 0% { transform: translate3d(0, 0, 0); }
+ 50% { transform: translate3d(0, -12px, 0); }
+ 100% { transform: translate3d(0, 0, 0); }
+}
+
.app {
display: flex;
flex-direction: column;
@@ -32,6 +143,9 @@ body {
margin: 0 auto;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
+ position: relative;
+ z-index: 1;
+ box-shadow: var(--app-shadow);
}
/* ── header ── */
@@ -41,7 +155,7 @@ header {
gap: 10px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
- background: #080808;
+ background: var(--surface);
flex-shrink: 0;
}
@@ -52,6 +166,7 @@ header {
letter-spacing: 3px;
margin-right: 4px;
text-decoration: none;
+ text-shadow: 0 0 12px var(--accent-glow-soft);
}
header select,
@@ -78,7 +193,7 @@ header button:focus {
}
header select option {
- background: #111;
+ background: var(--surface-muted);
}
.header-spacer { flex: 1; }
@@ -143,13 +258,14 @@ header select option {
/* ── settings panel ── */
.settings {
- background: #080808;
+ background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
+ box-shadow: var(--panel-shadow);
}
.settings-row {
@@ -178,6 +294,23 @@ header select option {
padding: 4px 8px;
}
+.settings-row button {
+ background: transparent;
+ color: var(--fg);
+ border: 1px solid var(--border);
+ font-family: inherit;
+ font-size: 12px;
+ padding: 4px 8px;
+ cursor: pointer;
+}
+
+.settings-row button:hover {
+ border-color: var(--dim);
+ color: var(--green);
+}
+
+.settings-row select:focus,
+.settings-row button:focus,
.settings-row input:focus,
.settings-row textarea:focus {
outline: none;
@@ -189,6 +322,26 @@ header select option {
min-height: 60px;
}
+.settings-row label.settings-toggle {
+ min-width: 0;
+ padding-top: 0;
+ color: var(--fg);
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+}
+
+.settings-toggle input {
+ accent-color: var(--green);
+}
+
+.settings-toggle-copy {
+ font-size: 12px;
+ color: var(--dim);
+}
+
/* ── history list (rendered inside .messages) ── */
.history-search {
padding: 8px 4px;
@@ -221,7 +374,7 @@ header select option {
font-size: 13px;
}
-.history-item:hover { background: #111; }
+.history-item:hover { background: var(--surface-muted); }
.history-date {
color: var(--dim);
@@ -270,7 +423,7 @@ header select option {
bottom: 12px;
left: 50%;
transform: translateX(-50%);
- background: #111;
+ background: var(--surface-muted);
color: var(--green);
border: 1px solid var(--green);
font-family: inherit;
@@ -301,18 +454,19 @@ header select option {
.message-shell {
width: 100%;
border: 1px solid var(--border);
- background: #0a0a0a;
+ background: var(--surface-alt);
padding: 10px 12px 12px;
+ box-shadow: var(--panel-shadow);
}
.message.user .message-shell {
- border-left: 2px solid rgba(85, 153, 255, 0.6);
- background: rgba(85, 153, 255, 0.03);
+ border-left: 2px solid var(--message-user-border);
+ background: var(--message-user-bg);
}
.message.assistant .message-shell {
- border-left: 2px solid rgba(51, 255, 51, 0.55);
- background: rgba(51, 255, 51, 0.02);
+ border-left: 2px solid var(--message-assistant-border);
+ background: var(--message-assistant-bg);
}
.message-head {
@@ -323,7 +477,7 @@ header select option {
gap: 12px;
margin-bottom: 10px;
padding-bottom: 6px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ border-bottom: 1px solid var(--divider-soft);
}
.message-label {
@@ -332,8 +486,8 @@ header select option {
letter-spacing: 0.08em;
}
-.message.user .message-label { color: rgba(85, 153, 255, 0.85); }
-.message.assistant .message-label { color: rgba(51, 255, 51, 0.85); }
+.message.user .message-label { color: var(--message-user-label); }
+.message.assistant .message-label { color: var(--message-assistant-label); }
.message-body {
display: flex;
@@ -452,7 +606,7 @@ header select option {
}
.message.user .content { color: var(--fg); }
-.message.assistant .content { color: #e0e0e0; }
+.message.assistant .content { color: var(--assistant-fg); }
.cursor {
animation: blink 1s step-end infinite;
@@ -486,10 +640,10 @@ header select option {
flex-direction: column;
padding: 12px 16px 14px;
border-top: 1px solid var(--green);
- background: #060606;
+ background: var(--surface-deep);
flex-shrink: 0;
gap: 8px;
- box-shadow: inset 0 1px 0 rgba(51, 255, 51, 0.08);
+ box-shadow: inset 0 1px 0 var(--accent-glow-soft);
}
.pending-attachments {
@@ -503,7 +657,7 @@ header select option {
display: inline-flex;
align-items: center;
gap: 4px;
- background: #111;
+ background: var(--surface-muted);
border: 1px solid var(--border);
padding: 2px 8px;
font-size: 12px;
@@ -546,7 +700,7 @@ header select option {
position: absolute;
top: 0;
right: 0;
- background: #000;
+ background: var(--surface-deep);
color: var(--fg);
border: none;
font-size: 14px;
@@ -560,13 +714,14 @@ header select option {
align-items: flex-start;
gap: 8px;
border: 1px solid var(--border);
- background: #0a0a0a;
+ background: var(--surface-alt);
padding: 10px 12px;
+ box-shadow: var(--panel-shadow);
}
.input-row:focus-within {
border-color: var(--green);
- box-shadow: inset 0 0 0 1px rgba(51, 255, 51, 0.1);
+ box-shadow: inset 0 0 0 1px var(--accent-glow-soft), var(--panel-shadow);
}
.input-prompt {
@@ -574,7 +729,7 @@ header select option {
font-weight: bold;
padding-top: 5px;
flex-shrink: 0;
- text-shadow: 0 0 8px rgba(51, 255, 51, 0.18);
+ text-shadow: 0 0 8px var(--accent-glow-strong);
}
.input-row textarea {
@@ -593,11 +748,11 @@ header select option {
}
.input-row textarea:focus { outline: none; }
-.input-row textarea::placeholder { color: #4b4b4b; }
+.input-row textarea::placeholder { color: var(--placeholder); }
.input-row textarea:disabled { opacity: 0.4; }
.input-row button {
- background: #091109;
+ background: var(--surface-highlight);
color: var(--green);
border: 1px solid var(--green);
font-family: inherit;
@@ -624,7 +779,7 @@ header select option {
}
.input-row > button:last-child:not(:disabled) {
- box-shadow: 0 0 10px rgba(51, 255, 51, 0.18);
+ box-shadow: 0 0 10px var(--accent-glow-strong);
}
/* On desktop: tools flow inline with the rest of the input row */
@@ -707,21 +862,21 @@ header select option {
}
.content a {
- color: var(--blue);
+ color: var(--link);
text-decoration: none;
}
.content a:hover { text-decoration: underline; }
-.content strong { color: #fff; font-weight: bold; }
-.content em { font-style: italic; color: #bbb; }
+.content strong { color: var(--strong); font-weight: bold; }
+.content em { font-style: italic; color: var(--em); }
.content code {
- background: #1a1a1a;
- color: #e0e0e0;
+ background: var(--code-bg);
+ color: var(--assistant-fg);
padding: 1px 5px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
- border: 1px solid #2a2a2a;
+ border: 1px solid var(--code-border);
}
.code-block {
@@ -733,7 +888,7 @@ header select option {
position: absolute;
top: 4px;
right: 4px;
- background: #111;
+ background: var(--surface-muted);
color: var(--dim);
border: 1px solid var(--border);
font-family: inherit;
@@ -750,7 +905,7 @@ header select option {
.content pre {
margin: 0;
- border: 1px solid #2a2a2a;
+ border: 1px solid var(--code-border);
border-radius: 0;
overflow-x: auto;
max-width: 100%;
@@ -775,11 +930,11 @@ header select option {
padding: 4px 10px;
text-align: left;
}
-.content th { color: var(--green); background: #111; }
+.content th { color: var(--green); background: var(--surface-muted); }
/* ── share view ── */
.share-view {
- border-color: #383838;
+ border-color: var(--share-border);
}
.share-banner {
@@ -789,7 +944,7 @@ header select option {
letter-spacing: 2px;
color: var(--dim);
border-bottom: 1px solid var(--border);
- background: #080808;
+ background: var(--surface);
flex-shrink: 0;
}
@@ -800,12 +955,28 @@ header select option {
justify-content: center;
align-items: center;
height: 100vh;
+ position: relative;
+ z-index: 1;
+ padding: 24px;
}
.login-box {
border: 1px solid var(--border);
+ background: var(--surface);
padding: 40px 48px;
text-align: center;
+ box-shadow: var(--panel-shadow);
+}
+
+.login-logo {
+ font-size: 20px;
+ margin-bottom: 24px;
+}
+
+.login-note {
+ color: var(--dim);
+ font-size: 12px;
+ text-align: center;
}
.login-box button {
@@ -822,3 +993,47 @@ header select option {
background: var(--green);
color: var(--bg);
}
+
+/* ── status pages ── */
+
+.status-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ gap: 16px;
+ padding: 24px;
+ color: var(--dim);
+ text-align: center;
+ position: relative;
+ z-index: 1;
+}
+
+.status-logo {
+ color: var(--green);
+ font-weight: bold;
+ letter-spacing: 3px;
+ text-shadow: 0 0 12px var(--accent-glow-soft);
+}
+
+.status-meta {
+ font-size: 12px;
+}
+
+.status-action {
+ display: inline-block;
+ background: transparent;
+ color: var(--fg);
+ border: 1px solid var(--border);
+ font-family: inherit;
+ font-size: 13px;
+ padding: 4px 12px;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.status-action:hover {
+ background: var(--green);
+ color: var(--bg);
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 6e83526..740c804 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -2,6 +2,15 @@ import type { Metadata, Viewport } from 'next';
import './globals.css';
import 'highlight.js/styles/atom-one-dark.min.css';
+const GIRL_MODE_STORAGE_KEY = 'gippidy-girl-mode';
+const GIRL_MODE_BOOTSTRAP = `
+try {
+ if (localStorage.getItem('${GIRL_MODE_STORAGE_KEY}') === '1') {
+ document.documentElement.setAttribute('data-girl-mode', 'true');
+ }
+} catch {}
+`;
+
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL ?? 'https://www.gippidy.chat'),
title: 'GIPPIDY',
@@ -16,8 +25,11 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
- {children}
+
+
+
+ {children}
+
);
}
diff --git a/app/login/page.tsx b/app/login/page.tsx
index 77ab3fc..bf88f79 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -4,7 +4,7 @@ export default function LoginPage() {
return (
-
GIPPIDY
+
GIPPIDY
{googleAuthConfigured ? (
) : (
-
+
Google OAuth is not configured.
)}
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 3413664..79573d4 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -2,10 +2,10 @@ import Link from 'next/link';
export default function NotFound() {
return (
-
-
GIPPIDY
+
+ GIPPIDY
404 — not found
- [HOME]
+ [HOME]
);
}
diff --git a/app/page.tsx b/app/page.tsx
index d6816ca..889919f 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -14,6 +14,8 @@ type PendingPdf = Pdf;
const MODEL_KEY = 'gippidy-model';
const KEY_WARNED = 'gippidy-key-warned';
+const GIRL_MODE_KEY = 'gippidy-girl-mode';
+const GIRL_MODE_ATTR = 'data-girl-mode';
const STREAM_MARKDOWN_INTERVAL_MS = 120;
type HistoryItem = { id: string; title: string; updatedAt: string; messages: Message[]; model: string; systemPrompt: string };
@@ -30,6 +32,11 @@ function stripMessageHtml(messages: Message[]): Array
> {
return messages.map(({ html: _html, ...message }) => message);
}
+function setGirlModeDom(enabled: boolean) {
+ if (enabled) document.documentElement.setAttribute(GIRL_MODE_ATTR, 'true');
+ else document.documentElement.removeAttribute(GIRL_MODE_ATTR);
+}
+
function parseStreamError(status: number, body: string): string {
if (status === 429) return '[RATE LIMITED] Wait a moment and try again.';
if (status === 401 || status === 403) return '[AUTH ERROR] API key issue — contact the admin.';
@@ -98,6 +105,7 @@ export default function Home() {
const [webSearchPhase, setWebSearchPhase] = useState<'off' | 'searching' | 'generating'>('off');
const webSearchPhaseRef = useRef<'off' | 'searching' | 'generating'>('off');
const [saveHistory, setSaveHistory] = useState(false);
+ const [girlMode, setGirlMode] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [historyItems, setHistoryItems] = useState([]);
const [historyLoading, setHistoryLoading] = useState(false);
@@ -127,6 +135,7 @@ export default function Home() {
const keyReadyRef = useRef>(new Promise(resolve => { keyResolveRef.current = resolve; }));
const systemPromptRef = useRef(systemPrompt);
const saveHistoryRef = useRef(saveHistory);
+ const girlModeRef = useRef(girlMode);
useEffect(() => {
systemPromptRef.current = systemPrompt;
@@ -136,6 +145,10 @@ export default function Home() {
saveHistoryRef.current = saveHistory;
}, [saveHistory]);
+ useEffect(() => {
+ girlModeRef.current = girlMode;
+ }, [girlMode]);
+
useEffect(() => () => {
if (saveSettingsTimer.current) clearTimeout(saveSettingsTimer.current);
if (streamingHtmlTimerRef.current) clearTimeout(streamingHtmlTimerRef.current);
@@ -196,11 +209,19 @@ export default function Home() {
}).catch(() => {});
};
- const persistSettings = (overrides: { systemPrompt?: string; saveHistory?: boolean; keyJwk?: string | null }, immediate = false) => {
+ const applyGirlMode = (enabled: boolean) => {
+ girlModeRef.current = enabled;
+ setGirlMode(enabled);
+ localStorage.setItem(GIRL_MODE_KEY, enabled ? '1' : '0');
+ setGirlModeDom(enabled);
+ };
+
+ const persistSettings = (overrides: { systemPrompt?: string; saveHistory?: boolean; girlMode?: boolean; keyJwk?: string | null }, immediate = false) => {
const run = () => {
const body = JSON.stringify({
systemPrompt: overrides.systemPrompt ?? systemPromptRef.current,
saveHistory: overrides.saveHistory ?? saveHistoryRef.current,
+ girlMode: overrides.girlMode ?? girlModeRef.current,
...(overrides.keyJwk !== undefined ? { keyJwk: overrides.keyJwk } : {}),
});
fetch('/api/settings', {
@@ -234,6 +255,8 @@ export default function Home() {
useEffect(() => {
const saved = localStorage.getItem(MODEL_KEY);
if (saved) setModel(saved);
+ const savedGirlMode = localStorage.getItem(GIRL_MODE_KEY);
+ if (savedGirlMode === '1' || savedGirlMode === '0') applyGirlMode(savedGirlMode === '1');
const ac = new AbortController();
fetch('/api/settings', { signal: ac.signal })
@@ -241,16 +264,18 @@ export default function Home() {
if (!r.ok) throw new Error(`settings_get_${r.status}`);
return r.json();
})
- .then(async ({ systemPrompt, saveHistory: sh, keyJwk }) => {
+ .then(async ({ systemPrompt, saveHistory: sh, girlMode: gm, keyJwk }) => {
setSystemPrompt(systemPrompt ?? '');
setSaveHistory(Boolean(sh));
+ const girlModeEnabled = Boolean(gm);
+ applyGirlMode(girlModeEnabled);
// Load or create the encryption key (shared across all deployments via DB)
const { key, jwk } = await getOrCreateKey(keyJwk ?? null);
cryptoKeyRef.current = key;
keyResolveRef.current?.();
if (jwk) {
// New key (or migrated from localStorage) — save to server so all deployments use it
- persistSettings({ systemPrompt: systemPrompt ?? '', saveHistory: sh ?? false, keyJwk: jwk }, true);
+ persistSettings({ systemPrompt: systemPrompt ?? '', saveHistory: sh ?? false, girlMode: girlModeEnabled, keyJwk: jwk }, true);
}
})
.catch(e => {
@@ -360,6 +385,11 @@ export default function Home() {
persistSettings({ saveHistory: val }, true);
};
+ const handleToggleGirlMode = (val: boolean) => {
+ applyGirlMode(val);
+ persistSettings({ girlMode: val }, true);
+ };
+
const loadHistory = async () => {
setHistoryLoading(true);
setHistoryItems([]);
@@ -771,9 +801,16 @@ export default function Home() {
-
+
+ Girl Mode
+
+ handleToggleGirlMode(e.target.checked)} />
+ {girlMode ? 'on — pretty + sparkly' : 'off'}
{messages.length > 0 && !streaming && (
diff --git a/lib/validation.ts b/lib/validation.ts
index 9bfecab..2691ef6 100644
--- a/lib/validation.ts
+++ b/lib/validation.ts
@@ -160,6 +160,7 @@ export function validateShareRequest(input: unknown): ValidationResult<{
export function validateSettingsRequest(input: unknown): ValidationResult<{
systemPrompt: string;
saveHistory: boolean;
+ girlMode: boolean;
keyJwk: string | null;
}> {
if (!isPlainObject(input)) return fail('invalid request body');
@@ -168,6 +169,7 @@ export function validateSettingsRequest(input: unknown): ValidationResult<{
return fail('systemPrompt too large', 413);
}
if (input.saveHistory !== undefined && typeof input.saveHistory !== 'boolean') return fail('invalid saveHistory');
+ if (input.girlMode !== undefined && typeof input.girlMode !== 'boolean') return fail('invalid girlMode');
if (input.keyJwk !== undefined && input.keyJwk !== null && typeof input.keyJwk !== 'string') return fail('invalid keyJwk');
if (typeof input.keyJwk === 'string') {
if (input.keyJwk.length > LIMITS.maxJwkChars) return fail('keyJwk too large', 413);
@@ -182,6 +184,7 @@ export function validateSettingsRequest(input: unknown): ValidationResult<{
return ok({
systemPrompt: typeof input.systemPrompt === 'string' ? input.systemPrompt : '',
saveHistory: input.saveHistory ?? false,
+ girlMode: input.girlMode ?? false,
keyJwk: typeof input.keyJwk === 'string' ? input.keyJwk : null,
});
}
diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs
index 69fad13..4a304a0 100644
--- a/scripts/migrate.mjs
+++ b/scripts/migrate.mjs
@@ -18,7 +18,9 @@ await pool.query(`
CREATE TABLE IF NOT EXISTS user_settings (
email TEXT PRIMARY KEY,
system_prompt TEXT NOT NULL DEFAULT '',
- save_history BOOLEAN NOT NULL DEFAULT FALSE
+ save_history BOOLEAN NOT NULL DEFAULT FALSE,
+ key_jwk TEXT,
+ girl_mode BOOLEAN NOT NULL DEFAULT FALSE
)
`);
await pool.query(`
@@ -27,6 +29,9 @@ await pool.query(`
await pool.query(`
ALTER TABLE user_settings ADD COLUMN IF NOT EXISTS key_jwk TEXT
`);
+await pool.query(`
+ ALTER TABLE user_settings ADD COLUMN IF NOT EXISTS girl_mode BOOLEAN NOT NULL DEFAULT FALSE
+`);
console.log('✓ user_settings table ready');
await pool.query(`
diff --git a/tests/unit.test.ts b/tests/unit.test.ts
index 4704a33..aa44424 100644
--- a/tests/unit.test.ts
+++ b/tests/unit.test.ts
@@ -262,6 +262,20 @@ test('parseStreamError: unknown error includes status code', () => {
assert.ok(parseStreamError(400, 'bad request').includes('400'));
});
+// ── settings validation ────────────────────────────────────────────────────────
+
+test('validateSettingsRequest: validates and defaults girlMode', () => {
+ const source = readFileSync(join(import.meta.dirname, '../lib/validation.ts'), 'utf8');
+ assert.ok(
+ source.includes("if (input.girlMode !== undefined && typeof input.girlMode !== 'boolean') return fail('invalid girlMode');"),
+ 'validateSettingsRequest should reject non-boolean girlMode values',
+ );
+ assert.ok(
+ source.includes('girlMode: input.girlMode ?? false,'),
+ 'validateSettingsRequest should persist girlMode and default it to false',
+ );
+});
+
// ── SSE chunk parsers ─────────────────────────────────────────────────────────
test('parseOpenAIChunk: extracts delta content', () => {
@@ -504,3 +518,9 @@ test('input-prompt uses padding-top (not padding-bottom) to align with top of te
assert.ok(!rule.includes('padding-bottom'), '.input-prompt must not use padding-bottom');
});
+
+test('globals.css defines a girl mode theme with sparkles', () => {
+ const css = readFileSync(join(import.meta.dirname, '../app/globals.css'), 'utf8');
+ assert.ok(css.includes(":root[data-girl-mode='true']"), 'girl mode theme selector missing');
+ assert.ok(css.includes('--sparkle-opacity'), 'girl mode sparkle variables missing');
+});