Compare commits
2 Commits
4b01c2296d
...
40ebd63ed7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ebd63ed7 | ||
|
|
60ff82b4a2 |
167
kez-chat/web/DESIGN.md
Normal 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; 8–12 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 (120–200ms), `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.
|
||||
@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 718 B |
@ -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 |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 853 B |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 408 B |
@ -4,14 +4,16 @@
|
||||
import { hasStoredIdentity } from "./lib/identity-store.js";
|
||||
import { session } from "./lib/store.svelte.js";
|
||||
import { inboxService } from "./lib/inbox-service.svelte.js";
|
||||
import Wordmark from "./lib/Wordmark.svelte";
|
||||
|
||||
import Landing from "./routes/Landing.svelte";
|
||||
import CreateAccount from "./routes/CreateAccount.svelte";
|
||||
import Restore from "./routes/Restore.svelte";
|
||||
import Unlock from "./routes/Unlock.svelte";
|
||||
import Dashboard from "./routes/Dashboard.svelte";
|
||||
import Identity from "./routes/Identity.svelte";
|
||||
import Claims from "./routes/Claims.svelte";
|
||||
import AddClaim from "./routes/AddClaim.svelte";
|
||||
import Settings from "./routes/Settings.svelte";
|
||||
import Messages from "./routes/Messages.svelte";
|
||||
|
||||
const routes = {
|
||||
@ -19,83 +21,109 @@
|
||||
"/create": CreateAccount,
|
||||
"/restore": Restore,
|
||||
"/unlock": Unlock,
|
||||
"/dashboard": Dashboard,
|
||||
"/chats": Messages,
|
||||
"/identity": Identity,
|
||||
"/claims": Claims,
|
||||
"/claims/add": AddClaim,
|
||||
"/messages": Messages,
|
||||
"/settings": Settings,
|
||||
};
|
||||
|
||||
// First-load: if there's a stored identity but session is locked,
|
||||
// bounce to /unlock. If no stored identity and on a protected page,
|
||||
// bounce to /.
|
||||
// App routes show the nav chrome; everything else (auth flow) is full-bleed.
|
||||
const APP_ROUTES = ["/chats", "/identity", "/claims", "/claims/add", "/settings"];
|
||||
const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location));
|
||||
|
||||
onMount(async () => {
|
||||
const stored = await hasStoredIdentity();
|
||||
const protectedRoutes = ["/dashboard", "/claims", "/claims/add", "/messages"];
|
||||
if (!stored && protectedRoutes.includes($location)) {
|
||||
// Redirect legacy paths.
|
||||
if ($location === "/dashboard") return push(session.unlocked ? "/identity" : "/unlock");
|
||||
if ($location === "/messages") return push(session.unlocked ? "/chats" : "/unlock");
|
||||
if (!stored && APP_ROUTES.includes($location)) {
|
||||
push("/");
|
||||
} else if (stored && !session.unlocked && protectedRoutes.includes($location)) {
|
||||
} else if (stored && !session.unlocked && APP_ROUTES.includes($location)) {
|
||||
push("/unlock");
|
||||
}
|
||||
});
|
||||
|
||||
// Nav destinations — Chats / Identity / Settings.
|
||||
const nav = [
|
||||
{ path: "/chats", label: "Chats", badge: true },
|
||||
{ path: "/identity", label: "Identity", badge: false },
|
||||
{ path: "/settings", label: "Settings", badge: false },
|
||||
];
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === "/identity") return $location === "/identity" || $location.startsWith("/claims");
|
||||
return $location === path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="border-b border-gray-200 bg-white">
|
||||
<div class="max-w-3xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="#/" class="text-lg font-semibold text-gray-900 no-underline">
|
||||
🔑 kez-chat
|
||||
</a>
|
||||
{#if session.unlocked}
|
||||
<nav class="flex items-center gap-4 text-sm">
|
||||
<a href="#/dashboard" class="text-gray-700 hover:text-gray-900">Dashboard</a>
|
||||
<a href="#/messages" class="text-gray-700 hover:text-gray-900 inline-flex items-center gap-1.5">
|
||||
Messages
|
||||
{#if inboxService.unreadCount > 0 && $location !== "/messages"}
|
||||
<span
|
||||
class="inline-flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-semibold bg-red-600 text-white rounded-full"
|
||||
aria-label="{inboxService.unreadCount} unread"
|
||||
>
|
||||
{inboxService.unreadCount > 9 ? "9+" : inboxService.unreadCount}
|
||||
</span>
|
||||
{#if showNav}
|
||||
<div class="flex h-dvh bg-bg text-text">
|
||||
<!-- Desktop: left icon rail -->
|
||||
<nav class="hidden sm:flex flex-col items-center w-16 shrink-0 border-r border-border bg-surface py-4 gap-2">
|
||||
<a href="#/chats" class="mb-4 w-9 h-9 flex items-center justify-center" aria-label="kez home">
|
||||
<img src="/kez-icon.svg" alt="kez" class="w-9 h-9" />
|
||||
</a>
|
||||
{#each nav as item}
|
||||
{@const active = isActive(item.path)}
|
||||
<a
|
||||
href={`#${item.path}`}
|
||||
class={`relative flex items-center justify-center w-11 h-11 rounded-lg transition-colors ${active ? "bg-elevated text-accent" : "text-text-secondary hover:bg-elevated hover:text-text"}`}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
>
|
||||
{#if active}<span class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-6 rounded-r bg-accent"></span>{/if}
|
||||
{#if item.path === "/chats"}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
|
||||
{:else if item.path === "/identity"}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
{:else}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
{/if}
|
||||
{#if item.badge && inboxService.unreadCount > 0 && $location !== "/chats"}
|
||||
<span class="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 flex items-center justify-center text-[10px] font-bold bg-accent text-accent-contrast rounded-full">{inboxService.unreadCount > 9 ? "9+" : inboxService.unreadCount}</span>
|
||||
{/if}
|
||||
</a>
|
||||
<a href="#/claims" class="text-gray-700 hover:text-gray-900">Claims</a>
|
||||
<span class="text-gray-400">|</span>
|
||||
<span class="text-gray-500">{session.unlocked.handle}@{session.unlocked.server}</span>
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-900 underline"
|
||||
onclick={() => { session.lock(); push("/unlock"); }}
|
||||
>
|
||||
Lock
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<main class={`mx-auto px-6 py-8 ${$location === "/messages" ? "max-w-6xl" : "max-w-3xl"}`}>
|
||||
<Router {routes} />
|
||||
</main>
|
||||
<!-- Content -->
|
||||
<main class="flex-1 min-w-0 overflow-y-auto pb-16 sm:pb-0">
|
||||
<div class="h-full">
|
||||
<Router {routes} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-gray-200 bg-white mt-16">
|
||||
<div class="max-w-3xl mx-auto px-6 py-4 text-xs text-gray-500 flex items-center gap-2 flex-wrap">
|
||||
<span>kez-chat web</span>
|
||||
<a
|
||||
href={`https://git.ptud.biz/DukeInc/Kez/commit/${__BUILD_SHA__}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="font-mono px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 no-underline"
|
||||
title={`built ${__BUILD_TIME__}`}
|
||||
>
|
||||
{__BUILD_SHA__}
|
||||
</a>
|
||||
<span>·</span>
|
||||
<a
|
||||
href="https://git.ptud.biz/DukeInc/Kez"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
source
|
||||
</a>
|
||||
<!-- Mobile: bottom tab bar -->
|
||||
<nav class="sm:hidden fixed bottom-0 inset-x-0 z-40 h-16 border-t border-border bg-surface flex items-stretch" style="padding-bottom: env(safe-area-inset-bottom);">
|
||||
{#each nav as item}
|
||||
{@const active = isActive(item.path)}
|
||||
<a href={`#${item.path}`} class={`relative flex-1 flex flex-col items-center justify-center gap-0.5 ${active ? "text-accent" : "text-text-secondary"}`} aria-label={item.label}>
|
||||
{#if item.path === "/chats"}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
|
||||
{:else if item.path === "/identity"}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
{:else}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
{/if}
|
||||
<span class="text-[10px] font-medium">{item.label}</span>
|
||||
{#if item.badge && inboxService.unreadCount > 0 && $location !== "/chats"}
|
||||
<span class="absolute top-2 right-[calc(50%-1.5rem)] min-w-4 h-4 px-1 flex items-center justify-center text-[10px] font-bold bg-accent text-accent-contrast rounded-full">{inboxService.unreadCount > 9 ? "9+" : inboxService.unreadCount}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
{:else}
|
||||
<!-- Unauthenticated / auth flow — full-bleed, centered. -->
|
||||
<div class="min-h-dvh bg-bg text-text flex flex-col">
|
||||
<header class="border-b border-border">
|
||||
<div class="max-w-3xl mx-auto px-6 py-4">
|
||||
<a href="#/" class="no-underline"><Wordmark size={20} /></a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 max-w-3xl w-full mx-auto px-6 py-8">
|
||||
<Router {routes} />
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
73
kez-chat/web/src/lib/Avatar.svelte
Normal 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
|
||||
// (160–210°), 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>
|
||||
18
kez-chat/web/src/lib/Wordmark.svelte
Normal 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>
|
||||
186
kez-chat/web/src/routes/Identity.svelte
Normal file
@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import { lookup, ApiError } from "../lib/api.js";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
import {
|
||||
listClaims,
|
||||
setVerifyResult,
|
||||
type StoredClaim,
|
||||
} from "../lib/claims-store.js";
|
||||
import { verifyClaim } from "../lib/verify.js";
|
||||
import Avatar from "../lib/Avatar.svelte";
|
||||
|
||||
let registryRecord = $state<any | null>(null);
|
||||
let claims = $state<StoredClaim[]>([]);
|
||||
let verifyingAll = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
const verified = $derived(claims.filter((c) => c.last_verify?.status === "ok"));
|
||||
const failed = $derived(claims.filter((c) => c.last_verify?.status === "fail"));
|
||||
const pending = $derived(claims.filter((c) => !c.last_verify));
|
||||
|
||||
onMount(async () => {
|
||||
if (!session.unlocked) {
|
||||
push("/unlock");
|
||||
return;
|
||||
}
|
||||
claims = await listClaims();
|
||||
try {
|
||||
registryRecord = await lookup(session.unlocked.handle);
|
||||
} catch (e) {
|
||||
if (!(e instanceof ApiError && e.status === 404)) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function reverifyAll() {
|
||||
verifyingAll = true;
|
||||
try {
|
||||
for (const c of claims) {
|
||||
const result = await verifyClaim($state.snapshot(c) as StoredClaim);
|
||||
await setVerifyResult(c.id, result);
|
||||
}
|
||||
claims = await listClaims();
|
||||
} finally {
|
||||
verifyingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
return (
|
||||
{
|
||||
github: "GitHub",
|
||||
dns: "DNS",
|
||||
web: "Website",
|
||||
nostr: "Nostr",
|
||||
bluesky: "Bluesky",
|
||||
ap: "ActivityPub",
|
||||
} as Record<string, string>
|
||||
)[ch] ?? ch;
|
||||
}
|
||||
|
||||
async function copyKez() {
|
||||
if (!session.unlocked) return;
|
||||
await navigator.clipboard.writeText(
|
||||
`${session.unlocked.handle}@${session.unlocked.server}`,
|
||||
);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
|
||||
function fingerprint(primary: string): string {
|
||||
// ed25519:<hex> → grouped short fingerprint for human comparison.
|
||||
const hex = primary.startsWith("ed25519:")
|
||||
? primary.slice("ed25519:".length)
|
||||
: primary;
|
||||
return (hex.match(/.{1,4}/g) ?? []).slice(0, 8).join(" ");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if session.unlocked}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<!-- Identity card -->
|
||||
<section class="bg-surface border border-border rounded-xl p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<Avatar seed={session.unlocked.primary} size={64} ring />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-mono text-lg font-semibold text-text truncate">
|
||||
{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span>
|
||||
</span>
|
||||
<button
|
||||
class="text-xs px-2 py-0.5 rounded-sm border border-border text-text-secondary hover:bg-elevated hover:text-text shrink-0"
|
||||
onclick={copyKez}
|
||||
>
|
||||
{copied ? "✓ copied" : "copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-text-muted uppercase tracking-wider">Key fingerprint</p>
|
||||
<p class="font-mono text-sm text-text-secondary break-all">
|
||||
{fingerprint(session.unlocked.primary)}<span class="text-text-muted">…</span>
|
||||
</p>
|
||||
{#if registryRecord}
|
||||
<p class="mt-2 text-xs text-text-muted">
|
||||
Registered {new Date(registryRecord.registered_at).toLocaleDateString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Proofs -->
|
||||
<section class="bg-surface border border-border rounded-xl p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Proofs</h2>
|
||||
<p class="text-sm text-text-secondary mt-1">
|
||||
Other accounts cryptographically linked to your KEZ. Anyone can
|
||||
verify these without trusting the server.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 shrink-0">
|
||||
{#if claims.length > 0}
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
|
||||
onclick={reverifyAll}
|
||||
disabled={verifyingAll}
|
||||
>
|
||||
{verifyingAll ? "Verifying…" : "Re-verify"}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
href="#/claims/add"
|
||||
class="px-3 py-1.5 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim no-underline text-center"
|
||||
>
|
||||
+ Add proof
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if claims.length === 0}
|
||||
<div class="border border-dashed border-border rounded-lg p-6 text-center">
|
||||
<p class="text-sm text-text-muted">No proofs yet.</p>
|
||||
<p class="text-xs text-text-muted mt-1">
|
||||
Link GitHub, your domain, nostr, Bluesky — prove the accounts you control.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each verified as c (c.id)}
|
||||
<li class="flex items-center justify-between gap-3 p-3 bg-elevated border border-border rounded-lg">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-sm bg-elevated border border-border text-text-secondary">
|
||||
{channelLabel(c.channel)}
|
||||
</span>
|
||||
<span class="font-mono text-sm text-text truncate">{c.envelope.payload.subject}</span>
|
||||
<span class="text-xs font-mono font-semibold px-1.5 py-0.5 rounded-sm" style="background:#4ade8014;border:1px solid #4ade8040;color:var(--color-verified);">✓ verified</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if c.last_verify?.evidence_url}
|
||||
<a href={c.last_verify.evidence_url} target="_blank" rel="noopener noreferrer" class="text-xs text-accent hover:text-accent-dim underline shrink-0">proof ↗</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{#each failed as c (c.id)}
|
||||
<li class="flex items-center gap-2 p-3 bg-elevated border border-border rounded-lg">
|
||||
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-sm bg-elevated border border-border text-text-secondary">{channelLabel(c.channel)}</span>
|
||||
<span class="font-mono text-sm text-text truncate flex-1">{c.envelope.payload.subject}</span>
|
||||
<span class="text-xs font-mono font-semibold text-danger shrink-0">✗ failed</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#each pending as c (c.id)}
|
||||
<li class="flex items-center gap-2 p-3 bg-elevated border border-border rounded-lg">
|
||||
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-sm bg-elevated border border-border text-text-secondary">{channelLabel(c.channel)}</span>
|
||||
<span class="font-mono text-sm text-text truncate flex-1">{c.envelope.payload.subject}</span>
|
||||
<span class="text-xs font-mono text-warning shrink-0">pending</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<a href="#/claims" class="mt-3 inline-block text-xs text-text-secondary hover:text-text underline">Manage proofs →</a>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@ -6,6 +6,7 @@
|
||||
import { lookup, ApiError } from "../lib/api.js";
|
||||
import { inboxService } from "../lib/inbox-service.svelte.js";
|
||||
import EmojiButton from "../lib/EmojiButton.svelte";
|
||||
import Avatar from "../lib/Avatar.svelte";
|
||||
import {
|
||||
appendOutbound,
|
||||
ensureConversation,
|
||||
@ -245,210 +246,171 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-[calc(100vh-8rem)] gap-4">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-80 shrink-0 border border-gray-200 rounded-lg bg-white flex flex-col">
|
||||
<!-- Your KEZ (so users can share it to start chats) -->
|
||||
<div class="p-3 border-b border-gray-200 bg-gray-50">
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wide mb-1">Your KEZ</p>
|
||||
<div class="flex h-full bg-bg">
|
||||
<!-- Sidebar (conversation list). On mobile it's full-width and hides
|
||||
when a conversation is open. -->
|
||||
<aside class={`${activeConv ? "hidden" : "flex"} sm:flex w-full sm:w-80 shrink-0 border-r border-border bg-surface flex-col`}>
|
||||
<!-- Header: your KEZ + status -->
|
||||
<div class="p-3 border-b border-border">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h1 class="text-sm font-semibold text-text uppercase tracking-wider">Chats</h1>
|
||||
<span class="text-xs">
|
||||
{#if inboxService.status === "live"}
|
||||
<span class="text-accent">● live</span>
|
||||
{:else if inboxService.status === "reconnecting"}
|
||||
<span class="text-warning">● reconnecting</span>
|
||||
{:else if inboxService.status === "connecting"}
|
||||
<span class="text-text-muted">○ connecting</span>
|
||||
{:else}
|
||||
<span class="text-text-muted">○ off</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if session.unlocked}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<code class="font-mono text-sm text-gray-900 truncate">
|
||||
{session.unlocked.handle}@{session.unlocked.server}
|
||||
</code>
|
||||
<button
|
||||
class="text-xs px-2 py-0.5 border border-gray-300 rounded text-gray-700 hover:bg-white shrink-0"
|
||||
onclick={copyMyKez}
|
||||
title="Copy your KEZ — share it with someone so they can message you"
|
||||
>
|
||||
{copied ? "✓" : "copy"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="mt-2 w-full flex items-center justify-between gap-2 px-2 py-1.5 rounded-md bg-elevated border border-border hover:border-accent-dim transition-colors"
|
||||
onclick={copyMyKez}
|
||||
title="Copy your KEZ to share"
|
||||
>
|
||||
<code class="font-mono text-xs text-accent truncate">{session.unlocked.handle}@{session.unlocked.server}</code>
|
||||
<span class="text-[10px] text-text-muted shrink-0">{copied ? "✓ copied" : "copy"}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Start a new conversation -->
|
||||
<div class="p-3 border-b border-gray-200">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||
Start a chat
|
||||
</p>
|
||||
<form
|
||||
class="flex gap-2"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startConversation();
|
||||
}}
|
||||
>
|
||||
<!-- Start a chat -->
|
||||
<div class="p-3 border-b border-border">
|
||||
<form class="flex gap-2" onsubmit={(e) => { e.preventDefault(); startConversation(); }}>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPeerInput}
|
||||
placeholder="alice or alice@kez.lat"
|
||||
class="flex-1 min-w-0 px-2 py-1 text-sm border border-gray-300 rounded font-mono"
|
||||
placeholder="handle@kez.lat"
|
||||
class="flex-1 min-w-0 px-3 py-2 text-sm bg-elevated border border-border rounded-md font-mono text-text placeholder:text-text-muted focus:border-accent focus:outline-none"
|
||||
autocomplete="off"
|
||||
disabled={resolving}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-1 text-sm bg-gray-900 text-white rounded hover:bg-gray-700 disabled:opacity-50"
|
||||
class="px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
|
||||
disabled={!newPeerInput.trim() || resolving}
|
||||
>
|
||||
{resolving ? "…" : "+"}
|
||||
</button>
|
||||
</form>
|
||||
{#if resolveError}
|
||||
<p class="mt-2 text-xs text-red-700">{resolveError}</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
Enter the KEZ of someone you want to message. Ask them to share it
|
||||
with you (the button above does the same for yours).
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-danger">{resolveError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Conversation list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if conversations.length === 0}
|
||||
<p class="p-4 text-sm text-gray-500 italic">
|
||||
No conversations yet.
|
||||
</p>
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-sm text-text-muted">No conversations yet.</p>
|
||||
<p class="text-xs text-text-muted mt-1">Enter someone's KEZ above to start.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each conversations as c (c.peer_primary)}
|
||||
{@const last = c.messages[c.messages.length - 1]}
|
||||
{@const active = activePrimary === c.peer_primary}
|
||||
<li>
|
||||
<button
|
||||
class={`w-full text-left p-3 border-b border-gray-100 hover:bg-gray-50 ${activePrimary === c.peer_primary ? "bg-gray-100" : ""}`}
|
||||
class={`relative w-full text-left flex items-center gap-3 px-3 py-3 border-b border-border/60 transition-colors ${active ? "bg-elevated" : "hover:bg-elevated/60"}`}
|
||||
onclick={() => (activePrimary = c.peer_primary)}
|
||||
>
|
||||
<p class="font-mono text-sm font-semibold text-gray-900 truncate">
|
||||
{displayName(c)}
|
||||
</p>
|
||||
{#if last}
|
||||
<p class="text-xs text-gray-500 truncate">
|
||||
{last.direction === "out" ? "→ " : "← "}{last.body}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{formatTime(last.ts)}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-gray-400 italic">No messages yet</p>
|
||||
{/if}
|
||||
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
|
||||
<Avatar seed={c.peer_primary} size={40} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-mono text-sm font-semibold text-text truncate">{displayName(c)}</p>
|
||||
{#if last}
|
||||
<p class="text-xs text-text-secondary truncate">
|
||||
{last.direction === "out" ? "→ " : ""}{last.body}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-text-muted italic">No messages yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if last}<span class="text-[10px] text-text-muted shrink-0 self-start mt-0.5">{formatTime(last.ts)}</span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer status — reads from the global inbox service so it
|
||||
reflects the SAME connection that's running everywhere else. -->
|
||||
<div class="p-2 border-t border-gray-200 text-xs text-gray-500 space-y-0.5">
|
||||
<p>
|
||||
{#if inboxService.status === "live"}
|
||||
<span class="text-green-700">● live</span>
|
||||
{:else if inboxService.status === "reconnecting"}
|
||||
<span class="text-amber-700">● reconnecting…</span>
|
||||
{:else if inboxService.status === "connecting"}
|
||||
<span class="text-gray-500">○ connecting…</span>
|
||||
{:else}
|
||||
<span class="text-gray-400">○ off</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#if inboxService.lastError}
|
||||
<p class="text-red-700">⚠ {inboxService.lastError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main: thread or empty state -->
|
||||
<main class="flex-1 min-w-0 border border-gray-200 rounded-lg bg-white flex flex-col">
|
||||
<main class={`${activeConv ? "flex" : "hidden"} sm:flex flex-1 min-w-0 bg-bg flex-col`}>
|
||||
{#if !activeConv}
|
||||
<div class="flex-1 flex items-center justify-center p-8">
|
||||
<div class="max-w-md text-center space-y-4">
|
||||
<div class="text-4xl">🔒</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">
|
||||
End-to-end encrypted chat
|
||||
</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
Messages on kez-chat are encrypted between you and the recipient.
|
||||
Even kez.lat (the server) can't read them — it just relays
|
||||
opaque ciphertext.
|
||||
<div class="text-5xl">🔒</div>
|
||||
<h2 class="text-lg font-semibold text-text">End-to-end encrypted</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Messages are encrypted between you and the recipient. The server
|
||||
only relays opaque ciphertext — it can't read anything.
|
||||
</p>
|
||||
<p class="text-sm text-gray-700">
|
||||
To start, ask someone for their <strong>KEZ</strong> — their
|
||||
<code class="bg-gray-100 px-1 rounded text-xs">handle@server</code>
|
||||
(like an email address). Enter it on the left under
|
||||
<strong>Start a chat</strong>. Or share yours with them using the
|
||||
copy button.
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 pt-2 border-t border-gray-200">
|
||||
Don't know anyone yet? Open kez.lat in a second browser window,
|
||||
create a different account, and message yourself between the two.
|
||||
<p class="text-sm text-text-secondary">
|
||||
Ask someone for their <span class="font-mono text-accent">handle@server</span>
|
||||
and enter it on the left to start. Or share yours with the copy button.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-3 border-b border-gray-200">
|
||||
<p class="font-mono text-sm font-semibold text-gray-900">
|
||||
{displayName(activeConv)}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 break-all font-mono">
|
||||
{activeConv.peer_primary}
|
||||
</p>
|
||||
<!-- Thread header -->
|
||||
<div class="flex items-center gap-3 p-3 border-b border-border bg-surface">
|
||||
<button class="sm:hidden text-text-secondary hover:text-text -ml-1" onclick={() => (activePrimary = null)} aria-label="Back">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</button>
|
||||
<Avatar seed={activeConv.peer_primary} size={36} />
|
||||
<div class="min-w-0">
|
||||
<p class="font-mono text-sm font-semibold text-text truncate">{displayName(activeConv)}</p>
|
||||
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-2" bind:this={scrollEl}>
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-1.5" bind:this={scrollEl}>
|
||||
{#each activeConv.messages as m (m.seq + ":" + m.direction)}
|
||||
{@const boost = emojiOnlyBoost(m.body)}
|
||||
<div class={`max-w-md ${m.direction === "out" ? "ml-auto" : ""}`}>
|
||||
{@const out = m.direction === "out"}
|
||||
<div class={`max-w-[78%] ${out ? "ml-auto" : ""}`}>
|
||||
{#if boost}
|
||||
<!-- Emoji-only message: drop the bubble chrome, render big. -->
|
||||
<div
|
||||
class={`whitespace-pre-wrap break-words leading-none ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${m.direction === "out" ? "text-right" : ""}`}
|
||||
>
|
||||
{m.body}
|
||||
</div>
|
||||
<div class={`whitespace-pre-wrap break-words leading-none py-1 ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${out ? "text-right" : ""}`}>{m.body}</div>
|
||||
{:else}
|
||||
<div
|
||||
class={`px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words ${m.direction === "out" ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-900"}`}
|
||||
>
|
||||
{m.body}
|
||||
</div>
|
||||
class={`px-3 py-2 text-sm whitespace-pre-wrap break-words ${out ? "bg-accent text-accent-contrast rounded-lg rounded-br-sm" : "bg-bubble-recv text-text border border-border rounded-lg rounded-bl-sm"}`}
|
||||
>{m.body}</div>
|
||||
{/if}
|
||||
<p class={`mt-1 text-xs text-gray-400 ${m.direction === "out" ? "text-right" : ""}`}>
|
||||
{formatTime(m.ts)}
|
||||
</p>
|
||||
<p class={`mt-0.5 text-[10px] text-text-muted ${out ? "text-right" : ""}`}>{formatTime(m.ts)}</p>
|
||||
</div>
|
||||
{/each}
|
||||
{#if activeConv.messages.length === 0}
|
||||
<p class="text-gray-400 text-sm italic text-center mt-8">
|
||||
No messages yet. Say hi — it's encrypted before it leaves your
|
||||
browser.
|
||||
<p class="text-text-muted text-sm italic text-center mt-8">
|
||||
No messages yet. Say hi — it's encrypted before it leaves your browser.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="p-3 border-t border-gray-200 flex gap-2 items-center"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}}
|
||||
>
|
||||
<!-- Compose -->
|
||||
<form class="p-3 border-t border-border bg-surface flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
|
||||
<EmojiButton onpick={insertEmoji} />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={composeText}
|
||||
bind:this={composeEl}
|
||||
placeholder="Type a message…"
|
||||
class="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 rounded"
|
||||
class="flex-1 min-w-0 px-3 py-2 text-sm bg-elevated border border-border rounded-md text-text placeholder:text-text-muted focus:border-accent focus:outline-none"
|
||||
autocomplete="off"
|
||||
disabled={composing}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm bg-gray-900 text-white rounded hover:bg-gray-700 disabled:opacity-50"
|
||||
class="px-4 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
|
||||
disabled={composing || !composeText.trim()}
|
||||
>
|
||||
{composing ? "Sending…" : "Send"}
|
||||
{composing ? "…" : "Send"}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
185
kez-chat/web/src/routes/Settings.svelte
Normal file
@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
import {
|
||||
hasStoredBiometric,
|
||||
getStoredBiometricMeta,
|
||||
setupBiometricUnlock,
|
||||
removeStoredBiometric,
|
||||
isPlatformAuthenticatorAvailable,
|
||||
} from "../lib/webauthn.js";
|
||||
import {
|
||||
notificationsSupported,
|
||||
notificationsPermission,
|
||||
requestNotificationsPermission,
|
||||
fireTestNotification,
|
||||
} from "../lib/inbox-service.svelte.js";
|
||||
|
||||
let biometricSupported = $state(false);
|
||||
let biometricEnrolled = $state(false);
|
||||
let biometricLabel = $state<string | null>(null);
|
||||
let biometricCreatedAt = $state<string | null>(null);
|
||||
let biometricBusy = $state(false);
|
||||
let biometricError = $state<string | null>(null);
|
||||
|
||||
let notifSupported = $state(false);
|
||||
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
||||
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!session.unlocked) {
|
||||
push("/unlock");
|
||||
return;
|
||||
}
|
||||
await refreshBiometricStatus();
|
||||
notifSupported = notificationsSupported();
|
||||
notifPerm = notificationsPermission();
|
||||
});
|
||||
|
||||
async function refreshBiometricStatus() {
|
||||
biometricSupported = await isPlatformAuthenticatorAvailable();
|
||||
biometricEnrolled = await hasStoredBiometric();
|
||||
const m = await getStoredBiometricMeta();
|
||||
biometricLabel = m?.label ?? null;
|
||||
biometricCreatedAt = m?.created_at ?? null;
|
||||
}
|
||||
|
||||
async function enableBiometric() {
|
||||
if (!session.unlocked) return;
|
||||
biometricBusy = true;
|
||||
biometricError = null;
|
||||
try {
|
||||
await setupBiometricUnlock({
|
||||
handle: session.unlocked.handle,
|
||||
primary: session.unlocked.primary,
|
||||
seed: session.unlocked.seed,
|
||||
});
|
||||
await refreshBiometricStatus();
|
||||
} catch (e) {
|
||||
biometricError = (e as Error).message;
|
||||
} finally {
|
||||
biometricBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disableBiometric() {
|
||||
if (!confirm("Disable biometric unlock for this device? You'll need your passphrase next time.")) return;
|
||||
await removeStoredBiometric();
|
||||
await refreshBiometricStatus();
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
notifPerm = await requestNotificationsPermission();
|
||||
}
|
||||
|
||||
function sendTestNotification() {
|
||||
testNotifResult = fireTestNotification();
|
||||
setTimeout(() => (testNotifResult = null), 5_000);
|
||||
}
|
||||
|
||||
function showSeed() {
|
||||
if (!session.unlocked) return;
|
||||
const hex = bytesToHex(session.unlocked.seed);
|
||||
alert(`Your recovery seed (KEEP SECRET):\n\n${hex}\n\nWrite this down somewhere safe. It's the ONLY way to recover this account.`);
|
||||
}
|
||||
|
||||
function lock() {
|
||||
session.lock();
|
||||
push("/unlock");
|
||||
}
|
||||
|
||||
const buildSha = __BUILD_SHA__;
|
||||
</script>
|
||||
|
||||
{#if session.unlocked}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<!-- Security -->
|
||||
<section class="bg-surface border border-border rounded-xl p-6 space-y-5">
|
||||
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Security</h2>
|
||||
|
||||
<!-- App lock / biometric -->
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-text">App lock</p>
|
||||
<p class="text-sm text-text-secondary mt-0.5">
|
||||
Unlock with Touch ID / Face ID / Windows Hello instead of typing
|
||||
your passphrase. The passphrase still works as a backup.
|
||||
</p>
|
||||
</div>
|
||||
{#if biometricEnrolled}
|
||||
<button class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:text-danger hover:border-danger shrink-0" onclick={disableBiometric}>Disable</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !biometricSupported && !biometricEnrolled}
|
||||
<p class="mt-2 text-sm text-text-muted italic">No platform authenticator detected in this browser.</p>
|
||||
{:else if biometricEnrolled}
|
||||
<p class="mt-2 text-sm" style="color:var(--color-verified)">
|
||||
✓ {biometricLabel} enrolled{#if biometricCreatedAt} · {new Date(biometricCreatedAt).toLocaleDateString()}{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<button class="mt-2 px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50" onclick={enableBiometric} disabled={biometricBusy}>
|
||||
{biometricBusy ? "Setting up…" : "Enable app lock"}
|
||||
</button>
|
||||
{#if biometricError}<p class="mt-2 text-xs text-danger">{biometricError}</p>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border"></div>
|
||||
|
||||
<!-- Recovery phrase -->
|
||||
<div>
|
||||
<p class="text-sm font-medium text-text">Recovery seed</p>
|
||||
<p class="text-sm text-text-secondary mt-0.5">
|
||||
The only thing that can recover this account. Write it down offline.
|
||||
</p>
|
||||
<button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={showSeed}>
|
||||
Reveal seed
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="bg-surface border border-border rounded-xl p-6">
|
||||
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-2">Notifications</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
System notification when a new message arrives while you're in another
|
||||
tab or app. Silent while you're looking at this one.
|
||||
</p>
|
||||
{#if !notifSupported}
|
||||
<p class="mt-3 text-sm text-text-muted italic">Not supported in this browser.</p>
|
||||
{:else if notifPerm === "granted"}
|
||||
<div class="mt-3 space-y-2">
|
||||
<p class="text-sm" style="color:var(--color-verified)">✓ Enabled.</p>
|
||||
<button class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={sendTestNotification}>Send test notification</button>
|
||||
{#if testNotifResult?.ok === true}
|
||||
<p class="text-xs text-text-muted">✓ Sent. Nothing popped? Check your OS notification settings for this browser.</p>
|
||||
{:else if testNotifResult?.ok === false}
|
||||
<p class="text-xs text-danger">✗ {testNotifResult.reason}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if notifPerm === "denied"}
|
||||
<p class="mt-3 text-sm text-warning">Blocked. Re-enable in your browser's site settings.</p>
|
||||
{:else}
|
||||
<button class="mt-3 px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim" onclick={enableNotifications}>Enable notifications</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Account / About -->
|
||||
<section class="bg-surface border border-border rounded-xl p-6 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Account</h2>
|
||||
<button class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:text-danger hover:border-danger" onclick={lock}>
|
||||
Lock
|
||||
</button>
|
||||
<div class="border-t border-border"></div>
|
||||
<p class="text-xs text-text-muted">
|
||||
kez-chat ·
|
||||
<a href={`https://git.ptud.biz/DukeInc/Kez/commit/${buildSha}`} target="_blank" rel="noopener" class="font-mono text-text-secondary hover:text-accent">{buildSha}</a>
|
||||
·
|
||||
<a href="https://git.ptud.biz/DukeInc/Kez" target="_blank" rel="noopener" class="text-text-secondary hover:text-accent">source</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@ -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" },
|
||||
|
||||