design(kez-chat/web): redesign foundation — tactical-terminal theme + tokens

Phase 0 of the redesign (see DESIGN.md). Establishes the visual
foundation; route restyling + IA reorg follow in subsequent commits.

Design direction (decided with a 3-agent design-team debate):
  • Audience: hackers, privacy absolutists, anti-surveillance, Meshtastic
    / off-grid, journalists in hostile environments.
  • Aesthetic: "muted tactical terminal" — Mullvad-calm restraint, not
    neon cyberpunk cosplay. Monospace as identity. Hard-ish edges.
  • Signature color: electric cyan #28C8E8 on neutral near-black #0B0C0E
    (chosen over signal-amber and phosphor-green — ages better, reads
    "serious infrastructure" without shouting). Verified-green reserved
    for proofs only.

Changes:
  • app.css: full Tailwind v4 @theme token set — elevation ramp, text
    tiers, accent + dim + contrast, semantic colors, Inter + JetBrains
    Mono via Google Fonts, tactical radius scale, accent glow, dark
    color-scheme, cyan text-selection, thin dark scrollbars, and the
    kez-cursor blink keyframe (respects prefers-reduced-motion).
  • Wordmark.svelte: `kez▌` mono wordmark with blinking cyan block
    cursor — the cursor is the brand mark.
  • Avatar.svelte: deterministic 5×5 symmetric identicon from the
    ed25519 key, cyan-arc hue. Every KEZ gets a stable face.
  • kez-icon.svg: amber key → cyan key-meets-cursor glyph; regenerated
    the full PWA icon set + apple-touch-icon from it.
  • manifest + index.html theme/background color → #0B0C0E.
  • DESIGN.md: the full system + IA plan as source of truth.

Note: existing route components still use light-theme utility classes
and will look inconsistent until restyled in the next phases — that
work lands next (shell/nav → Chats → Identity → Settings → Contacts).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-27 21:45:21 -06:00
parent 4b01c2296d
commit 60ff82b4a2
12 changed files with 386 additions and 23 deletions

167
kez-chat/web/DESIGN.md Normal file
View File

@ -0,0 +1,167 @@
# KEZ Design System
> Source of truth for the kez-chat redesign (`redesign-kez-theme` branch).
> Goal: take the chat app out of the prototype phase into a real,
> WhatsApp/Discord-caliber messenger with an iconic KEZ identity.
## Who we're designing for
Hackers, infosec people, privacy absolutists, anti-surveillance / sovereignty
folks, Meshtastic & off-grid comms operators, journalists/activists in hostile
environments — the Signal / Briar / Tails / Mullvad / Monero crowd. They trust
**verifiability over promises**, have a finely-tuned bullshit detector, and
bounce instantly from anything that smells like VC surveillance-ware.
**Positioning:** _KEZ is the sovereign identity layer + encrypted comms for
people who assume the network is hostile._
## Aesthetic direction
**Muted tactical terminal — restraint, not neon cosplay.** Mullvad's calm
authority. Monospace as identity. The first 3 seconds should feel like opening
an operational tool, not a brochure. Hard-ish edges, visible structure, a single
cold accent. No gradients-blobs, no mascots, no "delightful."
### Hard DO-NOTs
- No rounded-blob/gradient SaaS look, no mascots, no illustrations of laughing people.
- No "military-grade / bank-level" marketing adjectives. Show, don't boast.
- No surveillance tells: no third-party analytics, no social login, no email-required signup.
- No stock photography.
## Color palette (dark-first; light theme = v2, out of scope)
Tailwind v4 `@theme` tokens, in `src/app.css`.
| Token | Hex | Use |
|---|---|---|
| `--color-bg` | `#0B0C0E` | app background (neutral near-black) |
| `--color-surface` | `#16181C` | cards, conversation list, sidebars |
| `--color-elevated` | `#1E2127` | modals, menus, input wells |
| `--color-border` | `#2A2E35` | hairlines, dividers |
| `--color-text` | `#E8EAED` | primary text (neutral off-white) |
| `--color-text-secondary` | `#9BA3AD` | secondary |
| `--color-text-muted` | `#5C636D` | timestamps, meta |
| `--color-text-disabled` | `#3A4049` | disabled |
| `--color-accent` | `#28C8E8` | **the KEZ color** — electric cyan |
| `--color-accent-dim` | `#1B9DBC` | hover/pressed, accent borders |
| `--color-accent-contrast` | `#04131A` | text on accent fills |
| `--color-verified` | `#4ADE80` | proof verified (distinct from accent) |
| `--color-danger` | `#FF5C6C` | destructive, failed |
| `--color-warning` | `#FFB13D` | needs-attention |
| `--color-bubble-recv` | `#1B1F25` | received message bubble fill |
Accent is used surgically: send bubbles, focus rings, active nav, the wordmark
cursor, links, live/streaming indicators. Greys carry the weight. **Verified
green is for proofs only** — never as a general accent, so a verification badge
never camouflages into accent UI.
## Typography
- **UI/body:** `Inter``--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`
- **Monospace:** `JetBrains Mono``--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace`. Used for keys, hashes, handles, the wordmark.
Loaded via Google Fonts.
| Role | Size / weight / line-height | Notes |
|---|---|---|
| Wordmark | 22px / 700 / 1.1 mono | `-0.02em`, + cyan block cursor |
| Section header | 12px / 600 / 1.3 sans | uppercase, `+0.08em`, secondary color |
| Body / message | 15px / 450 / 1.45 sans | |
| Conversation name | 15px / 600 / 1.3 sans | |
| Timestamp/meta | 12px / 500 / 1.2 | muted |
| Mono key display | 13px / 500 / 1.5 mono | `+0.01em` |
## Spacing / radius / shadow
- **Spacing** (4px base): 4, 8, 12, 16, 20, 24, 32, 48.
- **Radius — tactical, mid-soft:** `--radius-sm: 4px` (chips/badges), `--radius-md: 8px` (inputs/buttons), `--radius-lg: 12px` (bubbles/cards), `--radius-xl: 16px` (modals). Fully-round reads consumer-soft; sharp reads unfinished; 812 says "engineered."
- **Shadows = terminal glow, not ambient drop shadows.** Surfaces use `1px solid --color-border` hairlines. Modals: `0 8px 24px -8px rgba(0,0,0,0.6)`. Accent focus/glow: `0 0 0 1px #28C8E833, 0 0 16px -2px #28C8E866`.
## Signature components
- **Message bubbles** — radius `lg`, padding `8px 12px`, max-width ~78%.
- Sent: `--color-accent` fill, `--color-accent-contrast` text, `border-bottom-right-radius: 4px` tail.
- Received: `--color-bubble-recv` fill, `--color-text`, `1px solid --color-border`, `border-bottom-left-radius: 4px`.
- **Conversation row** — 64px tall, 40px avatar (`radius-md`), name 600, preview secondary, time muted. Active = left 2px accent bar + elevated bg. Unread = accent dot + name 700.
- **Buttons** — height 40px, `radius-md`, 600. Primary: accent fill / contrast text / dim hover / 0.98 active / glow focus. Secondary: transparent, `1px solid border`, hover elevated bg + accent-dim border.
- **Inputs** — elevated bg, `1px solid border`, `radius-md`, focus → accent border + `0 0 0 3px #28C8E822` ring. Key inputs use mono.
- **Handle/identity chip** — inline mono, `radius-sm`, `2px 8px`, bg `#28C8E814`, `1px solid #28C8E833`, accent text; leading `@`/`0x` muted.
- **Verified proof badge**`radius-sm`, bg `#4ADE8014`, border `#4ADE8040`, `--color-verified` text, mono 11/600, leading ✓.
- **Avatars** — deterministic identicon generated from the ed25519 key, so every KEZ has a stable face. Eliminates the biggest "prototype" tell.
## Motion
Fast (120200ms), `cubic-bezier(0.2,0.8,0.2,1)`, on state change only, respect
`prefers-reduced-motion`.
- Received message: slide-up 6px + fade.
- Sent message: spring `scale .96→1` + brief accent-glow pulse decaying ~600ms.
- Thread push/back slide on mobile.
- Tasteful terminal flourish: a single cyan block-cursor blink on the empty
compose field + after the wordmark. No content scanlines.
## Wordmark / icon
- **Wordmark:** `kez` lowercase, JetBrains Mono 700, `-0.02em`, primary text,
followed by a blinking cyan block cursor `▌`. The cursor is the brand mark.
- **Icon:** evolve the amber key → cyan. Recolor `public/kez-icon.svg` stroke
`#fbbf24``#28C8E8` on `#0B0C0E`; reshape so the key reads as a
key-meets-cursor glyph. Drop the literal 🔑 emoji everywhere. Regenerate PWA
icon set. Manifest `theme_color` + `background_color``#0B0C0E`.
## Information architecture (the big structural change)
**Land logged-in users on Chats, not a Dashboard.** The "dashboard" as a
destination is killed; its contents redistribute.
### Navigation — 4 destinations
| Destination | Purpose |
|---|---|
| **Chats** | conversation list + threads (the home) |
| **Contacts** | known KEZs + verification status + start-new-chat |
| **Identity** | your KEZ + claims/proofs (the superpower surface) |
| **Settings** | security, backup, notifications, account, about |
- **Mobile (PWA):** fixed bottom tab bar, 4 tabs, unread badge on Chats.
Thread view pushes full-screen with a back chevron.
- **Desktop:** slim left icon rail (4 destinations) + secondary list column +
main content pane. Replaces the current top nav bar entirely.
### Feature → new home
| Existing | New home |
|---|---|
| Landing/Create/Restore/Unlock | unauthenticated flow (pre-nav), restyled |
| Messages (list+thread+compose, SSE, emoji, unread, notifications) | **Chats** (default surface) |
| Start chat by handle | **Contacts → New chat** (preview card before opening) |
| Claims list | **Identity → My proofs** (grouped verified/failed/pending) |
| AddClaim | **Identity → Add proof** |
| Identity display (handle@server, ed25519 key, registry) | **Identity** header card (avatar, copyable KEZ, fingerprint, QR) |
| Seed/key backup | **Settings → Security → Recovery phrase** (re-auth gated) |
| Biometric/passkey | **Settings → Security → App lock** |
| Notifications perm + test | **Settings → Notifications** |
| Build SHA / source link | **Settings → About** |
### New-conversation flow (the KEZ moment)
+ FAB on Chats (mobile) / "New chat" in Contacts column (desktop) →
"Enter a KEZ" (`handle@server`, paste/QR) → `lookup`**preview card**:
avatar, handle, key fingerprint, and **inline verified proofs** (✓ github:you,
✓ dns:yourdomain) → "Message". Verification is always one tap from a thread via
the contact-detail header. You see who someone is before you trust them.
### Polish signals to ship
1. Identicon avatars everywhere (from ed25519 key).
2. Message status ticks (sent / SSE-delivered) + day separators.
3. Real empty + skeleton-loading states.
4. Verification shield badge system (green verified / neutral none / amber failed), consistent across Chats, Contacts, Identity.
5. Native push/back + send transitions; smooth auto-scroll (already shipped).
## Implementation phases
0. **Foundation**`app.css` Tailwind v4 `@theme` tokens, Google Fonts, recolor icon + regenerate PWA assets, manifest colors.
1. **Shell + nav** — bottom tab bar / left rail, router lands on `/chats`, wordmark component.
2. **Chats** — restyle list + thread + bubbles, identicon avatars, empty/skeleton states. Keep SSE/emoji/notifications/auto-scroll.
3. **Identity** — identity card + proofs (migrate Claims/AddClaim).
4. **Settings** — Account / Security / Notifications / About (migrate Dashboard remainder).
5. **Contacts** — list + new-chat preview card with proofs.
6. **Auth flow restyle** — Landing/Create/Restore/Unlock to the new theme.
All existing functionality is preserved; only its placement and presentation change.

View File

@ -22,7 +22,7 @@
<!-- Web App Manifest (generated by vite-plugin-pwa) + Android theme color -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#111827" />
<meta name="theme-color" content="#0b0c0e" />
</head>
<body>
<div id="app"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 718 B

View File

@ -1,17 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Solid background. 20% padding around the glyph leaves the safe
area for Android "maskable" icons (radial crops, squircle, etc.). -->
<rect width="512" height="512" rx="96" ry="96" fill="#111827"/>
<!-- A simple key shape: round bow + rectangular shaft + two teeth.
Drawn at the center; bounding box ~280px wide, well inside the 80%
safe zone (~410px diameter). -->
<g transform="translate(106 156)" fill="none" stroke="#fbbf24" stroke-width="28" stroke-linecap="round" stroke-linejoin="round">
<!-- Neutral near-black bg with 20% maskable safe-zone padding. -->
<rect width="512" height="512" rx="96" ry="96" fill="#0B0C0E"/>
<!-- Key-as-cursor glyph in KEZ cyan: round bow + shaft that terminates
in a block cursor instead of teeth — key meets terminal prompt. -->
<g transform="translate(96 156)" fill="none" stroke="#28C8E8" stroke-width="30" stroke-linecap="round" stroke-linejoin="round">
<!-- Bow (ring) -->
<circle cx="80" cy="100" r="64"/>
<!-- Shaft (rounded line from the bow to the right edge) -->
<line x1="144" y1="100" x2="300" y2="100"/>
<!-- Two teeth on the bottom of the shaft -->
<line x1="240" y1="100" x2="240" y2="140"/>
<line x1="280" y1="100" x2="280" y2="156"/>
<circle cx="84" cy="100" r="66"/>
<!-- Shaft -->
<line x1="150" y1="100" x2="286" y2="100"/>
</g>
<!-- Block cursor terminator (filled), aligned to the shaft end. -->
<rect x="286" y="74" width="34" height="52" rx="4" fill="#28C8E8"/>
</svg>

Before

Width:  |  Height:  |  Size: 926 B

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 408 B

View File

@ -1,17 +1,125 @@
@import "tailwindcss";
/* Base typography reset on top of Tailwind v4's preflight. */
/* Fonts: Inter (UI) + JetBrains Mono (keys/handles/wordmark). */
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=JetBrains+Mono:wght@500;600;700&display=swap");
/*
KEZ design tokens see DESIGN.md. Dark-first, "muted tactical terminal."
Tailwind v4 turns each --color-* into bg-*/text-*/border-* utilities,
each --font-* into font-*, each --radius-* into rounded-*.
*/
@theme {
/* Elevation ramp (neutral near-black) */
--color-bg: #0b0c0e;
--color-surface: #16181c;
--color-elevated: #1e2127;
--color-border: #2a2e35;
/* Text tiers */
--color-text: #e8eaed;
--color-text-secondary: #9ba3ad;
--color-text-muted: #5c636d;
--color-text-disabled: #3a4049;
/* The KEZ color — electric cyan, used surgically */
--color-accent: #28c8e8;
--color-accent-dim: #1b9dbc;
--color-accent-contrast: #04131a;
/* Semantic */
--color-verified: #4ade80; /* proofs only */
--color-danger: #ff5c6c;
--color-warning: #ffb13d;
/* Chat */
--color-bubble-recv: #1b1f25;
/* Type */
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
/* Radius — tactical, mid-soft */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Accent glow for focus/send + modal elevation */
--shadow-glow: 0 0 0 1px #28c8e833, 0 0 16px -2px #28c8e866;
--shadow-elev: 0 8px 24px -8px rgba(0, 0, 0, 0.6);
}
/* ─── Base ─────────────────────────────────────────────────────────────── */
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
Roboto, sans-serif;
color: #1f2937;
background: #f9fafb;
font-family: var(--font-sans);
color: var(--color-text);
background: var(--color-bg);
color-scheme: dark;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
}
code, kbd, pre {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
code,
kbd,
pre,
samp {
font-family: var(--font-mono);
}
/* Cyan text-selection — reinforces the accent. */
::selection {
background: #28c8e840;
color: var(--color-text);
}
/* Scrollbars: thin, dark, unobtrusive. */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
background-clip: content-box;
}
/* Blinking block cursor for the wordmark + empty-compose flourish. */
@keyframes kez-blink {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
.kez-cursor {
display: inline-block;
width: 0.5em;
height: 1em;
margin-left: 0.08em;
vertical-align: text-bottom;
background: var(--color-accent);
animation: kez-blink 1.1s steps(1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.kez-cursor {
animation: none;
}
}

View File

@ -0,0 +1,73 @@
<script lang="ts">
// Deterministic identicon avatar derived from a KEZ identity (the
// ed25519 primary, or any stable string). Same identity → same glyph,
// forever, on every device. A 5×5 vertically-symmetric grid (GitHub
// style) rendered in a cyan-family hue picked from the hash, on a dark
// tile. Gives every KEZ a stable "face" in lists/headers/previews —
// the biggest single fix for the prototype look.
interface Props {
/** Stable seed — usually the ed25519 primary "ed25519:<hex>". */
seed: string;
/** Rendered size in px. */
size?: number;
/** Optional ring (e.g. for the active/own avatar). */
ring?: boolean;
}
let { seed, size = 40, ring = false }: Props = $props();
// Cheap, stable 32-bit FNV-1a hash — no crypto needed, just spreading.
function hash(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
const h = $derived(hash(seed || "kez"));
// Hue restricted to the cool cyan→teal→blue arc so avatars stay on-brand
// (160210°), with controlled saturation/lightness for legibility on dark.
const hue = $derived(160 + (h % 50));
const fg = $derived(`hsl(${hue} 70% 62%)`);
const tile = $derived(`hsl(${hue} 28% 14%)`);
// Build a 5×5 grid; mirror columns 0↔4, 1↔3 for symmetry. Cell on/off
// from successive bits of the hash (re-mixed per cell so it's not too
// sparse/dense).
const cells = $derived.by(() => {
const out: boolean[] = [];
for (let i = 0; i < 15; i++) {
// 15 unique cells (3 columns × 5 rows), mirrored to 25.
const bit = (Math.imul(h ^ (i * 0x9e3779b1), 0x85ebca6b) >>> 28) & 1;
out.push(bit === 1);
}
return out;
});
function isOn(col: number, row: number): boolean {
const c = col < 3 ? col : 4 - col; // mirror
return cells[c * 5 + row];
}
</script>
<svg
width={size}
height={size}
viewBox="0 0 5 5"
class="shrink-0"
style="border-radius: {Math.max(4, size * 0.2)}px; {ring ? `box-shadow: 0 0 0 2px var(--color-accent);` : ''}"
role="img"
aria-label="identity avatar"
>
<rect width="5" height="5" fill={tile} />
{#each [0, 1, 2, 3, 4] as col}
{#each [0, 1, 2, 3, 4] as row}
{#if isOn(col, row)}
<rect x={col} y={row} width="1" height="1" fill={fg} />
{/if}
{/each}
{/each}
</svg>

View File

@ -0,0 +1,18 @@
<script lang="ts">
// The KEZ wordmark: `kez` in mono + a blinking cyan block cursor.
// The cursor IS the brand mark — "you're at a live, secure prompt."
interface Props {
/** Font size in px for the wordmark text. Cursor scales with it. */
size?: number;
/** Hide the blinking cursor (e.g. in dense contexts). */
cursor?: boolean;
}
let { size = 22, cursor = true }: Props = $props();
</script>
<span
class="font-mono font-bold tracking-tight text-text select-none inline-flex items-baseline"
style="font-size: {size}px; letter-spacing: -0.02em;"
>
kez{#if cursor}<span class="kez-cursor" style="height: {size * 0.9}px; width: {size * 0.45}px;"></span>{/if}
</span>

View File

@ -50,8 +50,8 @@ export default defineConfig({
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#111827",
theme_color: "#111827",
background_color: "#0b0c0e",
theme_color: "#0b0c0e",
categories: ["social", "communication"],
icons: [
{ src: "pwa-64x64.png", sizes: "64x64", type: "image/png" },