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) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-18 09:18:21 +01:00
parent 46cad0ec7c
commit 4892af55e8
7 changed files with 216 additions and 61 deletions

View File

@@ -2,3 +2,8 @@ VITE_CENTER_API_BASE=http://localhost:7001/api
VITE_GUILD_API_BASE=http://localhost:7002/api VITE_GUILD_API_BASE=http://localhost:7002/api
VITE_GUILD_SOCKET_BASE=http://localhost:7002/realtime VITE_GUILD_SOCKET_BASE=http://localhost:7002/realtime
VITE_API_KEY=change-me-api-key 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=

View File

@@ -8,7 +8,13 @@
<link rel="icon" type="image/png" sizes="256x256" href="/favicon.png?v=3" /> <link rel="icon" type="image/png" sizes="256x256" href="/favicon.png?v=3" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=3" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=3" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#080a0d" />
<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=Major+Mono+Display&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap"
rel="stylesheet"
/>
<title>Fabric</title> <title>Fabric</title>
</head> </head>
<body> <body>

View File

@@ -1,28 +1,32 @@
:root { :root {
--rail: #18191c; /* Cryptic lab terminal: deep "blueprint" ink surfaces, monospace
--side: #1f2125; everything, restrained motion. Accent stays Fabric purple. */
--main: #26282d; --ink: #080a0d;
--elevated: #1a1b1e; --rail: #0b0e12;
--input: #1a1b1e; --side: #0f141b;
--hover: rgba(255, 255, 255, 0.04); --main: #0a0d12;
--active: rgba(168, 85, 247, 0.16); --elevated: #11161d;
--input: #0b0e12;
--hover: rgba(168, 85, 247, 0.1);
--active: rgba(168, 85, 247, 0.18);
--text: #dbdee1; --text: #c7d0dc;
--text-muted: #949ba4; --text-muted: #6f7d92;
--text-faint: #6d7178; --text-faint: #4f5b6b;
--text-h: #f2f3f5; --text-h: #e9f0fa;
--border: #303237; --border: #1d2733;
--accent: #a855f7; --accent: #a855f7;
--accent-strong: #9333ea; --accent-strong: #9333ea;
--accent-soft: rgba(168, 85, 247, 0.14); --accent-soft: rgba(168, 85, 247, 0.16);
--danger: #f23f42; --danger: #ff5a52;
--online: #23a55a; --online: #23a55a;
--radius: 10px; --radius: 12px;
--radius-sm: 7px; --radius-sm: 8px;
--sans: 'Inter', system-ui, 'Segoe UI', Roboto, sans-serif; --display: 'Major Mono Display', ui-monospace, monospace;
--mono: ui-monospace, 'SF Mono', Consolas, monospace; --sans: 'IBM Plex Mono', ui-monospace, 'SF Mono', Consolas, monospace;
--mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Consolas, monospace;
color-scheme: dark; color-scheme: dark;
font-synthesis: none; font-synthesis: none;
@@ -198,6 +202,19 @@ button {
padding: 32px; padding: 32px;
box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.6); 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 { .login-card h1 {
font-size: 24px; font-size: 24px;
margin-bottom: 4px; margin-bottom: 4px;
@@ -415,6 +432,20 @@ button {
.chan-btn.xt-custom { .chan-btn.xt-custom {
--xt: #c084fc; --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 { .chan-tag {
margin-left: auto; margin-left: auto;
font-size: 10px; font-size: 10px;

21
src/lib/brand.ts Normal file
View File

@@ -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<string, string | undefined>
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)
}
}

View File

@@ -4,6 +4,9 @@ import { BrowserRouter, HashRouter } from 'react-router-dom'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { AuthProvider } from './auth/AuthContext' import { AuthProvider } from './auth/AuthContext'
import { applyBrand } from './lib/brand'
applyBrand()
// The packaged desktop app loads the bundled SPA over file://, where the // The packaged desktop app loads the bundled SPA over file://, where the
// History API has no server to back path routing — use HashRouter there. // History API has no server to back path routing — use HashRouter there.

View File

@@ -9,6 +9,7 @@ import {
} from 'react' } from 'react'
import { useAuth } from '../auth/auth-context' import { useAuth } from '../auth/auth-context'
import { renderMarkdown } from '../lib/markdown' import { renderMarkdown } from '../lib/markdown'
import { APP_NAME } from '../lib/brand'
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client' import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
type Attachment = { url: string; name?: string; mimeType?: string } 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' }) 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<string, string> = {
general: 'General', work: 'Work', report: 'Report',
discuss: 'Discussion', triage: 'Triage', custom: 'Custom', dm: 'Direct Messages',
}
type XType = (typeof X_TYPES)[number] type XType = (typeof X_TYPES)[number]
type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean; isPublic?: boolean; closed?: boolean } 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') setError('Triage channels require an on-duty user')
return return
} }
if (newChannelXType === 'dm' && selectedMemberIds.length !== 1) {
setError('A DM needs exactly one other participant')
return
}
setError('') setError('')
try { try {
const payload = { const payload = {
name: newChannelName.trim(), name: newChannelName.trim(),
guildId: effectiveGuildId, guildId: effectiveGuildId,
xType: newChannelXType, xType: newChannelXType,
isPublic: newChannelPublic, isPublic: newChannelXType === 'dm' ? false : newChannelPublic,
memberUserIds: selectedMemberIds, memberUserIds: selectedMemberIds,
onDuty: newChannelXType === 'triage' ? newChannelOnDuty : undefined, onDuty: newChannelXType === 'triage' ? newChannelOnDuty : undefined,
listeners: newChannelXType === 'custom' ? newChannelListeners : [], 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) { async function joinChannel(c: GuildChannel) {
if (!guild || !guildToken) return if (!guild || !guildToken) return
try { try {
@@ -700,6 +738,15 @@ export default function ChatPage() {
{ kind: 'item', label: 'Copy user ID', onClick: () => copyText(m.userId) }, { kind: 'item', label: 'Copy user ID', onClick: () => copyText(m.userId) },
{ kind: 'item', label: 'Copy name', onClick: () => copyText(label) }, { 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)) if (inChannel && rotationChannel && !bypassMemberIds.includes(m.userId))
items.push( items.push(
{ kind: 'sep' }, { kind: 'sep' },
@@ -920,7 +967,7 @@ export default function ChatPage() {
} }
return ( return (
<div className="dc-shell" onContextMenu={(e) => openCtx(e, 'Fabric', blankMenu('root'))}> <div className="dc-shell" onContextMenu={(e) => openCtx(e, APP_NAME, blankMenu('root'))}>
<nav className="dc-rail" onContextMenu={(e) => openCtx(e, 'Guilds', blankMenu('guilds'))}> <nav className="dc-rail" onContextMenu={(e) => openCtx(e, 'Guilds', blankMenu('guilds'))}>
{guilds.map((g) => ( {guilds.map((g) => (
<button <button
@@ -952,7 +999,14 @@ export default function ChatPage() {
</button> </button>
</div> </div>
{channels.length ? ( {channels.length ? (
channels.map((c) => ( XTYPE_ORDER.filter((xt) =>
channels.some((c) => (c.xType ?? 'general') === xt),
).map((xt) => (
<div key={xt} className="dc-chan-group">
<div className="dc-group-label">{XTYPE_LABEL[xt] ?? xt}</div>
{channels
.filter((c) => (c.xType ?? 'general') === xt)
.map((c) => (
<button <button
key={c.id} key={c.id}
className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`} className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`}
@@ -961,8 +1015,9 @@ export default function ChatPage() {
> >
<span className="hash">#</span> <span className="hash">#</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
{c.xType ? <span className="chan-tag">{c.xType}</span> : null}
</button> </button>
))}
</div>
)) ))
) : ( ) : (
<div className="dc-empty">{guild ? 'No channels yet' : 'Pick a guild from the left'}</div> <div className="dc-empty">{guild ? 'No channels yet' : 'Pick a guild from the left'}</div>
@@ -1373,6 +1428,32 @@ export default function ChatPage() {
</div> </div>
) : null} ) : null}
{newChannelXType === 'dm' ? (
<>
<p className="muted" style={{ marginTop: 10, fontSize: 13 }}>
Direct message private 1:1, not unique. Pick exactly one user:
</p>
<div className="modal-list">
{members
.filter((m) => m.userId !== session?.user.id)
.map((m) => (
<label key={m.userId} className="check-row">
<input
type="radio"
name="dm-target"
checked={selectedMemberIds[0] === m.userId}
onChange={() => setSelectedMemberIds([m.userId])}
/>
<span>{m.name || m.email}</span>
</label>
))}
{members.filter((m) => m.userId !== session?.user.id).length === 0 ? (
<div className="dc-empty">No other members in this guild.</div>
) : null}
</div>
</>
) : (
<>
<label className="check-row" style={{ marginTop: 10 }}> <label className="check-row" style={{ marginTop: 10 }}>
<input type="checkbox" checked={newChannelPublic} onChange={(e) => setNewChannelPublic(e.target.checked)} /> <input type="checkbox" checked={newChannelPublic} onChange={(e) => setNewChannelPublic(e.target.checked)} />
<span>Public visible to all guild members</span> <span>Public visible to all guild members</span>
@@ -1400,6 +1481,8 @@ export default function ChatPage() {
<div className="dc-empty">No other members in this guild.</div> <div className="dc-empty">No other members in this guild.</div>
) : null} ) : null}
</div> </div>
</>
)}
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" onClick={createChannel}>Create</button> <button className="btn" onClick={createChannel}>Create</button>
<button className="btn btn-secondary" onClick={() => setShowCreateChannelModal(false)}>Cancel</button> <button className="btn btn-secondary" onClick={() => setShowCreateChannelModal(false)}>Cancel</button>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/auth-context' import { useAuth } from '../auth/auth-context'
import { APP_NAME, LOGO_URL } from '../lib/brand'
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -25,9 +26,14 @@ export default function LoginPage() {
return ( return (
<div className="login-screen"> <div className="login-screen">
<div className="login-card"> <div className="login-card">
{LOGO_URL ? (
<img className="brand-logo" src={LOGO_URL} alt={APP_NAME} />
) : (
<div className="brand-wordmark">{APP_NAME}</div>
)}
<h1>Welcome back</h1> <h1>Welcome back</h1>
<p className="login-sub"> <p className="login-sub">
{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}`}
</p> </p>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div className="field"> <div className="field">