From 4892af55e8013998194272f88e0c45badcee7a6b Mon Sep 17 00:00:00 2001 From: hzhang Date: Mon, 18 May 2026 09:18:21 +0100 Subject: [PATCH] feat(frontend): terminal restyle + dm + grouping + brand override - Restyle: ink 'blueprint' surfaces + IBM Plex Mono / Major Mono Display, hairline borders, restrained motion, 12px radii. Fabric purple accent kept. Layout unchanged. - Brand override (build-time VITE_ only): VITE_APP_NAME / VITE_LOGO_URL / VITE_FAVICON_URL via src/lib/brand.ts (applyBrand in main.tsx); login wordmark/logo + title + favicon. .env.example documented. - dm x-type: create modal participant becomes single-select (radio) for dm and forces private; member right-click menu gains 'Create new DM channel' (non-unique, fresh channel each time). - Channels sidebar grouped by x-type with group headers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 5 ++ index.html | 8 +- src/index.css | 67 +++++++++++----- src/lib/brand.ts | 21 +++++ src/main.tsx | 3 + src/pages/ChatPage.tsx | 165 ++++++++++++++++++++++++++++++---------- src/pages/LoginPage.tsx | 8 +- 7 files changed, 216 insertions(+), 61 deletions(-) create mode 100644 src/lib/brand.ts diff --git a/.env.example b/.env.example index 0fff91a..e3029ca 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,8 @@ VITE_CENTER_API_BASE=http://localhost:7001/api VITE_GUILD_API_BASE=http://localhost:7002/api VITE_GUILD_SOCKET_BASE=http://localhost:7002/realtime VITE_API_KEY=change-me-api-key + +# Brand overrides (build-time only; baked at build). Leave empty for Fabric defaults. +VITE_APP_NAME=Fabric +VITE_LOGO_URL= +VITE_FAVICON_URL= diff --git a/index.html b/index.html index 47f9284..668a7c0 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,13 @@ - + + + + Fabric diff --git a/src/index.css b/src/index.css index d23e314..2f5c897 100644 --- a/src/index.css +++ b/src/index.css @@ -1,28 +1,32 @@ :root { - --rail: #18191c; - --side: #1f2125; - --main: #26282d; - --elevated: #1a1b1e; - --input: #1a1b1e; - --hover: rgba(255, 255, 255, 0.04); - --active: rgba(168, 85, 247, 0.16); + /* Cryptic lab terminal: deep "blueprint" ink surfaces, monospace + everything, restrained motion. Accent stays Fabric purple. */ + --ink: #080a0d; + --rail: #0b0e12; + --side: #0f141b; + --main: #0a0d12; + --elevated: #11161d; + --input: #0b0e12; + --hover: rgba(168, 85, 247, 0.1); + --active: rgba(168, 85, 247, 0.18); - --text: #dbdee1; - --text-muted: #949ba4; - --text-faint: #6d7178; - --text-h: #f2f3f5; + --text: #c7d0dc; + --text-muted: #6f7d92; + --text-faint: #4f5b6b; + --text-h: #e9f0fa; - --border: #303237; + --border: #1d2733; --accent: #a855f7; --accent-strong: #9333ea; - --accent-soft: rgba(168, 85, 247, 0.14); - --danger: #f23f42; + --accent-soft: rgba(168, 85, 247, 0.16); + --danger: #ff5a52; --online: #23a55a; - --radius: 10px; - --radius-sm: 7px; - --sans: 'Inter', system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, 'SF Mono', Consolas, monospace; + --radius: 12px; + --radius-sm: 8px; + --display: 'Major Mono Display', ui-monospace, monospace; + --sans: 'IBM Plex Mono', ui-monospace, 'SF Mono', Consolas, monospace; + --mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Consolas, monospace; color-scheme: dark; font-synthesis: none; @@ -198,6 +202,19 @@ button { padding: 32px; box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.6); } +.brand-wordmark { + font-family: var(--display); + text-transform: lowercase; + font-size: 30px; + color: var(--accent); + text-shadow: 0 0 32px rgba(168, 85, 247, 0.4); + margin-bottom: 18px; +} +.brand-logo { + max-height: 44px; + width: auto; + margin-bottom: 18px; +} .login-card h1 { font-size: 24px; margin-bottom: 4px; @@ -415,6 +432,20 @@ button { .chan-btn.xt-custom { --xt: #c084fc; } +.chan-btn.xt-dm { + --xt: #a855f7; +} +/* x-type group headers in the channel sidebar */ +.dc-chan-group { + margin-bottom: 6px; +} +.dc-group-label { + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--text-faint); + padding: 8px 8px 4px; +} .chan-tag { margin-left: auto; font-size: 10px; diff --git a/src/lib/brand.ts b/src/lib/brand.ts new file mode 100644 index 0000000..7bde068 --- /dev/null +++ b/src/lib/brand.ts @@ -0,0 +1,21 @@ +// Build-time brand overrides. Values are baked from VITE_ env at build +// (not runtime-configurable by design). Empty/unset → Fabric defaults. +const env = import.meta.env as unknown as Record + +export const APP_NAME = (env.VITE_APP_NAME ?? '').trim() || 'Fabric' +export const LOGO_URL = (env.VITE_LOGO_URL ?? '').trim() +const FAVICON_URL = (env.VITE_FAVICON_URL ?? '').trim() + +// Apply the document title + favicon override once at startup. +export function applyBrand(): void { + document.title = APP_NAME + if (FAVICON_URL) { + document + .querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"]') + .forEach((n) => n.remove()) + const link = document.createElement('link') + link.rel = 'icon' + link.href = FAVICON_URL + document.head.appendChild(link) + } +} diff --git a/src/main.tsx b/src/main.tsx index 9ca2f6e..ef350f7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,9 @@ import { BrowserRouter, HashRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' import { AuthProvider } from './auth/AuthContext' +import { applyBrand } from './lib/brand' + +applyBrand() // The packaged desktop app loads the bundled SPA over file://, where the // History API has no server to back path routing — use HashRouter there. diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index a893626..4d1029a 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -9,6 +9,7 @@ import { } from 'react' import { useAuth } from '../auth/auth-context' import { renderMarkdown } from '../lib/markdown' +import { APP_NAME } from '../lib/brand' import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client' type Attachment = { url: string; name?: string; mimeType?: string } @@ -67,7 +68,13 @@ function timeOf(iso?: string): string { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } -const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom'] as const +const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'] as const +// display order + labels for the x-type-grouped channel sidebar +const XTYPE_ORDER = ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'] as const +const XTYPE_LABEL: Record = { + general: 'General', work: 'Work', report: 'Report', + discuss: 'Discussion', triage: 'Triage', custom: 'Custom', dm: 'Direct Messages', +} type XType = (typeof X_TYPES)[number] type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean; isPublic?: boolean; closed?: boolean } @@ -412,13 +419,17 @@ export default function ChatPage() { setError('Triage channels require an on-duty user') return } + if (newChannelXType === 'dm' && selectedMemberIds.length !== 1) { + setError('A DM needs exactly one other participant') + return + } setError('') try { const payload = { name: newChannelName.trim(), guildId: effectiveGuildId, xType: newChannelXType, - isPublic: newChannelPublic, + isPublic: newChannelXType === 'dm' ? false : newChannelPublic, memberUserIds: selectedMemberIds, onDuty: newChannelXType === 'triage' ? newChannelOnDuty : undefined, listeners: newChannelXType === 'custom' ? newChannelListeners : [], @@ -444,6 +455,33 @@ export default function ChatPage() { } } + // DM channels are not unique: this always creates a fresh 1:1 dm channel + // between the current user and `m` (private; no rotation/wakeup). + async function createDmChannel(m: { userId: string; name?: string; email?: string }) { + if (!guild || !guildToken) return + const effectiveGuildId = guildDbId || selectedGuildId + if (!effectiveGuildId) { + setError('Cannot create DM: no guild selected') + return + } + const label = m.name || m.email || m.userId.slice(0, 8) + setError('') + try { + const res = await guildApi().post('/channels', { + name: `DM · ${label}`, + guildId: effectiveGuildId, + xType: 'dm', + isPublic: false, + memberUserIds: [m.userId], + }) + const createdId = res.data?.id as string | undefined + await loadChannels() + if (createdId) setSelectedChannelId(createdId) + } catch { + setError('Failed to create DM channel') + } + } + async function joinChannel(c: GuildChannel) { if (!guild || !guildToken) return try { @@ -700,6 +738,15 @@ export default function ChatPage() { { kind: 'item', label: 'Copy user ID', onClick: () => copyText(m.userId) }, { kind: 'item', label: 'Copy name', onClick: () => copyText(label) }, ] + if (!isSelf) + items.push( + { kind: 'sep' }, + { + kind: 'item', + label: 'Create new DM channel', + onClick: () => void createDmChannel(m), + }, + ) if (inChannel && rotationChannel && !bypassMemberIds.includes(m.userId)) items.push( { kind: 'sep' }, @@ -920,7 +967,7 @@ export default function ChatPage() { } return ( -
openCtx(e, 'Fabric', blankMenu('root'))}> +
openCtx(e, APP_NAME, blankMenu('root'))}>
{channels.length ? ( - channels.map((c) => ( - + XTYPE_ORDER.filter((xt) => + channels.some((c) => (c.xType ?? 'general') === xt), + ).map((xt) => ( +
+
{XTYPE_LABEL[xt] ?? xt}
+ {channels + .filter((c) => (c.xType ?? 'general') === xt) + .map((c) => ( + + ))} +
)) ) : (
{guild ? 'No channels yet' : 'Pick a guild from the left'}
@@ -1373,33 +1428,61 @@ export default function ChatPage() {
) : null} - -

- {newChannelPublic - ? "You're added automatically; every guild member can see it." - : "You're added automatically. Pick who else to include:"} -

-
- {members - .filter((m) => m.userId !== session?.user.id) - .map((m) => ( - - ))} - {members.filter((m) => m.userId !== session?.user.id).length === 0 ? ( -
No other members in this guild.
- ) : null} -
+ {newChannelXType === 'dm' ? ( + <> +

+ Direct message — private 1:1, not unique. Pick exactly one user: +

+
+ {members + .filter((m) => m.userId !== session?.user.id) + .map((m) => ( + + ))} + {members.filter((m) => m.userId !== session?.user.id).length === 0 ? ( +
No other members in this guild.
+ ) : null} +
+ + ) : ( + <> + +

+ {newChannelPublic + ? "You're added automatically; every guild member can see it." + : "You're added automatically. Pick who else to include:"} +

+
+ {members + .filter((m) => m.userId !== session?.user.id) + .map((m) => ( + + ))} + {members.filter((m) => m.userId !== session?.user.id).length === 0 ? ( +
No other members in this guild.
+ ) : null} +
+ + )}
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index f0054e4..e77013b 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { FormEvent } from 'react' import { useNavigate } from 'react-router-dom' import { useAuth } from '../auth/auth-context' +import { APP_NAME, LOGO_URL } from '../lib/brand' export default function LoginPage() { const navigate = useNavigate() @@ -25,9 +26,14 @@ export default function LoginPage() { return (
+ {LOGO_URL ? ( + {APP_NAME} + ) : ( +
{APP_NAME}
+ )}

Welcome back

- {isAuthed ? `Signed in as ${session?.user.email}` : 'Sign in to continue to Fabric'} + {isAuthed ? `Signed in as ${session?.user.email}` : `Sign in to continue to ${APP_NAME}`}