From 2106b7a51a99cb92892ce575a5acaf4520ae5f9c Mon Sep 17 00:00:00 2001
From: Undline <103777919+Undline@users.noreply.github.com>
Date: Mon, 6 Apr 2026 20:05:51 -0400
Subject: [PATCH] feat(keymaster): loopback UI shell with Core-themed fireflies
Add modulr-keymaster package (FastAPI, Jinja, static CSS/JS) under keymaster/.
Screens: unlock, new vault, identities list, profile detail; mock profiles for layout.
Fireflies canvas matches customer shell FireflyField; theme toggle and reduced motion.
Remove New identity nav (registration stays in Core). Extend root Ruff config for keymaster/src.
Made-with: Cursor
---
keymaster/README.md | 44 ++
keymaster/pyproject.toml | 35 ++
keymaster/src/modulr_keymaster/__init__.py | 3 +
keymaster/src/modulr_keymaster/app.py | 115 +++++
keymaster/src/modulr_keymaster/cli.py | 50 ++
.../src/modulr_keymaster/static/keymaster.css | 448 ++++++++++++++++++
.../src/modulr_keymaster/static/keymaster.js | 208 ++++++++
.../modulr_keymaster/static/modulr-logo.svg | 8 +
.../modulr_keymaster/static/modulr-theme.css | 110 +++++
.../src/modulr_keymaster/templates/base.html | 128 +++++
.../modulr_keymaster/templates/dashboard.html | 22 +
.../modulr_keymaster/templates/not_found.html | 10 +
.../templates/profile_detail.html | 22 +
.../src/modulr_keymaster/templates/setup.html | 24 +
.../modulr_keymaster/templates/unlock.html | 26 +
pyproject.toml | 4 +-
16 files changed, 1255 insertions(+), 2 deletions(-)
create mode 100644 keymaster/README.md
create mode 100644 keymaster/pyproject.toml
create mode 100644 keymaster/src/modulr_keymaster/__init__.py
create mode 100644 keymaster/src/modulr_keymaster/app.py
create mode 100644 keymaster/src/modulr_keymaster/cli.py
create mode 100644 keymaster/src/modulr_keymaster/static/keymaster.css
create mode 100644 keymaster/src/modulr_keymaster/static/keymaster.js
create mode 100644 keymaster/src/modulr_keymaster/static/modulr-logo.svg
create mode 100644 keymaster/src/modulr_keymaster/static/modulr-theme.css
create mode 100644 keymaster/src/modulr_keymaster/templates/base.html
create mode 100644 keymaster/src/modulr_keymaster/templates/dashboard.html
create mode 100644 keymaster/src/modulr_keymaster/templates/not_found.html
create mode 100644 keymaster/src/modulr_keymaster/templates/profile_detail.html
create mode 100644 keymaster/src/modulr_keymaster/templates/setup.html
create mode 100644 keymaster/src/modulr_keymaster/templates/unlock.html
diff --git a/keymaster/README.md b/keymaster/README.md
new file mode 100644
index 0000000..40b140d
--- /dev/null
+++ b/keymaster/README.md
@@ -0,0 +1,44 @@
+# Keymaster (local)
+
+Local **Ed25519** identity tool: generate **named** key profiles, **password-protect** the vault, browse via a **loopback web UI**.
+
+**Full plan:** [`plan/keymaster_local_wallet.md`](../plan/keymaster_local_wallet.md)
+
+**Design context:** [`docs/identity_encryption_and_org_policy.md`](../docs/identity_encryption_and_org_policy.md)
+
+## Status
+
+The **web UI shell** is implemented (FastAPI + Jinja + static CSS aligned with the Modulr customer shell). Background matches the Core shell **fireflies** preset (canvas + gradient). With **`prefers-reduced-motion: reduce`**, only the static gradient runs. Vault encryption, key generation, and persistence are **not** wired yet — you will see an “UI preview” banner.
+
+## Run (development)
+
+From the **repository root**, with your Modulr.Core venv activated:
+
+```powershell
+pip install -e ./keymaster
+modulr-keymaster --reload
+```
+
+Or from `keymaster/`:
+
+```powershell
+pip install -e .
+modulr-keymaster --reload
+```
+
+Defaults: **127.0.0.1:8765**. Open [http://127.0.0.1:8765](http://127.0.0.1:8765) (redirects to `/unlock`).
+
+- **`--port`** — listen port
+- **`--host`** — bind address (avoid `0.0.0.0`; this tool is meant for loopback only)
+- **`--reload`** — auto-reload on code changes
+
+## Layout
+
+| Path | Screen |
+|------|--------|
+| `/unlock` | Unlock vault |
+| `/setup` | First-run / create vault |
+| `/identities` | Dashboard (mock profiles) |
+| `/identities/{id}` | Profile detail + copy public key |
+
+Static theme files live under `src/modulr_keymaster/static/`; templates under `templates/`.
diff --git a/keymaster/pyproject.toml b/keymaster/pyproject.toml
new file mode 100644
index 0000000..7d7a26a
--- /dev/null
+++ b/keymaster/pyproject.toml
@@ -0,0 +1,35 @@
+[build-system]
+requires = ["hatchling>=1.24"]
+build-backend = "hatchling.build"
+
+[project]
+name = "modulr-keymaster"
+version = "0.1.0"
+description = "Keymaster — local Ed25519 identity vault (loopback UI)"
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "BSL-1.1" }
+authors = [{ name = "Modulr" }]
+dependencies = [
+ "fastapi>=0.115.0",
+ "jinja2>=3.1.0",
+ "uvicorn[standard]>=0.30.0",
+]
+
+[project.scripts]
+modulr-keymaster = "modulr_keymaster.cli:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/modulr_keymaster"]
+
+[tool.hatch.build.targets.wheel.force-include]
+"src/modulr_keymaster/static" = "modulr_keymaster/static"
+"src/modulr_keymaster/templates" = "modulr_keymaster/templates"
+
+[tool.ruff]
+target-version = "py311"
+line-length = 88
+src = ["src"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP"]
diff --git a/keymaster/src/modulr_keymaster/__init__.py b/keymaster/src/modulr_keymaster/__init__.py
new file mode 100644
index 0000000..b483e15
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/__init__.py
@@ -0,0 +1,3 @@
+"""Local Keymaster loopback UI and (future) encrypted vault."""
+
+__version__ = "0.1.0"
diff --git a/keymaster/src/modulr_keymaster/app.py b/keymaster/src/modulr_keymaster/app.py
new file mode 100644
index 0000000..5faf12b
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/app.py
@@ -0,0 +1,115 @@
+"""FastAPI app: themed static UI for Keymaster (mock data until vault exists)."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+
+_PKG_DIR = Path(__file__).resolve().parent
+
+MOCK_PROFILES: list[dict[str, str]] = [
+ {
+ "id": "personal",
+ "display_name": "Personal",
+ "public_key_hex": (
+ "3f8a2c1e9b0d4f7a6e5c8b1d2f0a3948"
+ "7e6d5c4b3a291807f6e5d4c3b2a1908f"
+ ),
+ "created_at": "2026-03-28T14:22:00Z",
+ },
+ {
+ "id": "organization",
+ "display_name": "Organization",
+ "public_key_hex": (
+ "a1b2c3d4e5f60718293a4b5c6d7e8f90"
+ "0f1e2d3c4b5a69788796a5b4c3d2e1f0"
+ ),
+ "created_at": "2026-03-29T09:15:00Z",
+ },
+]
+
+
+def _profile_by_id(profile_id: str) -> dict[str, str] | None:
+ for p in MOCK_PROFILES:
+ if p["id"] == profile_id:
+ return p
+ return None
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(title="Keymaster", version="0.1.0", docs_url=None, redoc_url=None)
+ templates = Jinja2Templates(directory=str(_PKG_DIR / "templates"))
+ app.mount(
+ "/static",
+ StaticFiles(directory=str(_PKG_DIR / "static")),
+ name="static",
+ )
+
+ def ctx(request: Request, **extra: object) -> dict[str, object]:
+ out: dict[str, object] = {
+ "request": request,
+ "ui_preview": True,
+ "nav_section": "none",
+ }
+ out.update(extra)
+ return out
+
+ @app.get("/", response_class=RedirectResponse)
+ async def root() -> RedirectResponse:
+ return RedirectResponse(url="/unlock", status_code=302)
+
+ @app.get("/unlock", response_class=HTMLResponse)
+ async def unlock(request: Request) -> HTMLResponse:
+ return templates.TemplateResponse(
+ request,
+ "unlock.html",
+ ctx(request, page_title="Unlock vault", nav_section="unlock"),
+ )
+
+ @app.get("/setup", response_class=HTMLResponse)
+ async def setup(request: Request) -> HTMLResponse:
+ return templates.TemplateResponse(
+ request,
+ "setup.html",
+ ctx(request, page_title="Create vault", nav_section="setup"),
+ )
+
+ @app.get("/identities", response_class=HTMLResponse)
+ async def identities(request: Request) -> HTMLResponse:
+ return templates.TemplateResponse(
+ request,
+ "dashboard.html",
+ ctx(
+ request,
+ page_title="Identities",
+ profiles=MOCK_PROFILES,
+ nav_section="identities",
+ ),
+ )
+
+ @app.get("/identities/{profile_id}", response_class=HTMLResponse)
+ async def identity_detail(request: Request, profile_id: str) -> HTMLResponse:
+ profile = _profile_by_id(profile_id)
+ if profile is None:
+ return templates.TemplateResponse(
+ request,
+ "not_found.html",
+ ctx(request, page_title="Not found", nav_section="none"),
+ status_code=404,
+ )
+ return templates.TemplateResponse(
+ request,
+ "profile_detail.html",
+ ctx(
+ request,
+ page_title=profile["display_name"],
+ profile=profile,
+ nav_section="detail",
+ ),
+ )
+
+ return app
diff --git a/keymaster/src/modulr_keymaster/cli.py b/keymaster/src/modulr_keymaster/cli.py
new file mode 100644
index 0000000..4d2b8ff
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/cli.py
@@ -0,0 +1,50 @@
+"""CLI: run Keymaster loopback server."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+
+import uvicorn
+
+from modulr_keymaster.app import create_app
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Keymaster local web UI")
+ parser.add_argument(
+ "--host",
+ default="127.0.0.1",
+ help="Bind address (default: 127.0.0.1)",
+ )
+ parser.add_argument(
+ "--port",
+ type=int,
+ default=8765,
+ help="Port (default: 8765)",
+ )
+ parser.add_argument(
+ "--reload",
+ action="store_true",
+ help="Dev auto-reload (watch package files)",
+ )
+ args = parser.parse_args()
+
+ if args.host in ("0.0.0.0", "::"):
+ print(
+ "Keymaster: binding to all interfaces is discouraged; "
+ "this tool is intended for loopback only.",
+ file=sys.stderr,
+ )
+
+ uvicorn.run(
+ "modulr_keymaster.app:create_app",
+ factory=True,
+ host=args.host,
+ port=args.port,
+ reload=args.reload,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/keymaster/src/modulr_keymaster/static/keymaster.css b/keymaster/src/modulr_keymaster/static/keymaster.css
new file mode 100644
index 0000000..1edaaaf
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/static/keymaster.css
@@ -0,0 +1,448 @@
+/* Keymaster layout (vanilla; mirrors Core shell spacing) */
+
+/**
+ * Background stack: same idea as Core `AnimatedBackground` fireflies preset —
+ * page fill + rich gradient + canvas particles (`keymaster.js`).
+ */
+.km-bg-host {
+ pointer-events: none;
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ overflow: hidden;
+}
+
+.km-bg-base {
+ position: absolute;
+ inset: 0;
+ opacity: 0.9;
+ transition: opacity var(--modulr-theme-duration) var(--modulr-theme-ease);
+ background:
+ radial-gradient(ellipse 120% 80% at 50% 20%, rgba(255, 183, 0, 0.14), transparent 55%),
+ radial-gradient(ellipse 90% 60% at 80% 90%, rgba(120, 140, 255, 0.08), transparent 50%),
+ linear-gradient(165deg, var(--modulr-page-bg-2), var(--modulr-page-bg));
+}
+
+html[data-theme="light"] .km-bg-base {
+ background:
+ radial-gradient(ellipse 120% 80% at 50% 20%, rgba(255, 183, 0, 0.1), transparent 55%),
+ radial-gradient(ellipse 90% 60% at 80% 90%, rgba(120, 140, 255, 0.06), transparent 50%),
+ linear-gradient(165deg, var(--modulr-page-bg-2), var(--modulr-page-bg));
+}
+
+.km-fireflies {
+ position: absolute;
+ inset: 0;
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.km-z-main {
+ position: relative;
+ z-index: 10;
+}
+
+.km-layout-col {
+ display: flex;
+ min-height: 100vh;
+ flex-direction: column;
+}
+
+.km-header {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ padding: 1rem 1rem;
+}
+
+@media (min-width: 640px) {
+ .km-header {
+ padding: 1rem 2rem;
+ }
+}
+
+.km-header-inner {
+ margin: 0 auto;
+ max-width: 72rem;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.km-brand-row {
+ display: flex;
+ min-width: 0;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 1rem;
+ border-radius: 1rem;
+ border: 1px solid var(--modulr-glass-border);
+ background-color: var(--modulr-glass-fill);
+ padding: 0.75rem 1rem;
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.1),
+ inset 0 1px 0 var(--modulr-glass-highlight);
+}
+
+@media (min-width: 640px) {
+ .km-brand-row {
+ flex-direction: row;
+ align-items: center;
+ gap: 2.5rem;
+ padding: 0.75rem 1.25rem;
+ }
+}
+
+@media (min-width: 768px) {
+ .km-brand-row {
+ gap: 3.5rem;
+ }
+}
+
+.km-brand-link {
+ display: flex;
+ min-width: 0;
+ flex-shrink: 0;
+ align-items: flex-start;
+ gap: 0.75rem;
+ text-decoration: none;
+ color: inherit;
+ border-radius: 0.75rem;
+ outline-offset: 2px;
+}
+
+.km-brand-link:hover {
+ opacity: 0.92;
+}
+
+.km-brand-link:focus-visible {
+ outline: 2px solid var(--modulr-accent);
+}
+
+.km-logo {
+ width: 28px;
+ height: auto;
+ flex-shrink: 0;
+}
+
+.km-brand-titles {
+ display: none;
+ min-width: 0;
+ flex-direction: column;
+ gap: 0.125rem;
+ padding-top: 1px;
+}
+
+@media (min-width: 640px) {
+ .km-brand-titles {
+ display: flex;
+ }
+}
+
+.km-brand-title {
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1.25;
+}
+
+.km-brand-sub {
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.05em;
+ line-height: 1.25;
+}
+
+.km-nav {
+ display: flex;
+ min-width: 0;
+ flex: 1 1 auto;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.5rem 2.5rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid var(--modulr-glass-border);
+ font-size: 0.875rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+}
+
+@media (min-width: 640px) {
+ .km-nav {
+ padding-top: 0;
+ border-top: none;
+ }
+}
+
+.km-nav a {
+ color: var(--modulr-text);
+ text-decoration: none;
+ transition: color 0.2s ease;
+}
+
+.km-nav a:hover {
+ color: var(--modulr-accent);
+}
+
+.km-nav a.km-nav--active {
+ color: var(--modulr-accent);
+}
+
+.km-chrome-actions {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.km-icon-btn {
+ display: flex;
+ width: 2.75rem;
+ height: 2.75rem;
+ align-items: center;
+ justify-content: center;
+ border-radius: 0.75rem;
+ border: 1px solid var(--modulr-glass-border);
+ background-color: var(--modulr-glass-fill);
+ color: var(--modulr-text);
+ cursor: pointer;
+ box-shadow:
+ 0 4px 24px rgba(0, 0, 0, 0.12),
+ inset 0 1px 0 var(--modulr-glass-highlight);
+ transition:
+ border-color 0.2s ease,
+ color 0.2s ease,
+ box-shadow 0.55s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.km-icon-btn:hover {
+ border-color: color-mix(in srgb, var(--modulr-accent) 50%, var(--modulr-glass-border));
+ color: var(--modulr-accent);
+}
+
+.km-icon-btn:focus-visible {
+ outline: 2px solid var(--modulr-accent);
+ outline-offset: 2px;
+}
+
+.km-main {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ padding: 1rem 1rem 3rem;
+}
+
+@media (min-width: 640px) {
+ .km-main {
+ padding-left: 2rem;
+ padding-right: 2rem;
+ }
+}
+
+.km-main-inner {
+ margin: 0 auto;
+ width: 100%;
+ max-width: 72rem;
+ flex: 1 1 auto;
+}
+
+.km-preview-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ margin-bottom: 1rem;
+ padding: 0.35rem 0.65rem;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ border: 1px solid color-mix(in srgb, var(--modulr-accent) 35%, var(--modulr-glass-border));
+ background: color-mix(in srgb, var(--modulr-accent) 12%, var(--modulr-glass-panel-fill));
+ color: var(--modulr-text-muted);
+}
+
+.km-panel {
+ border-radius: 1rem;
+ border: 1px solid var(--modulr-glass-border);
+ background-color: var(--modulr-glass-panel-fill);
+ padding: 1.25rem 1.5rem;
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.08),
+ inset 0 1px 0 var(--modulr-glass-highlight);
+}
+
+@media (min-width: 640px) {
+ .km-panel {
+ padding: 1.5rem 2rem;
+ }
+}
+
+.km-panel h1 {
+ margin: 0 0 0.5rem;
+ font-size: 1.5rem;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+
+.km-panel .km-lead {
+ margin: 0 0 1.25rem;
+ font-size: 0.9375rem;
+ line-height: 1.55;
+}
+
+.km-form-group {
+ margin-bottom: 1.1rem;
+}
+
+.km-form-group label {
+ display: block;
+ margin-bottom: 0.35rem;
+ font-size: 0.8125rem;
+ font-weight: 600;
+}
+
+.km-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0.55rem 0.75rem;
+ border-radius: 0.5rem;
+ border: 1px solid var(--modulr-glass-border);
+ background-color: var(--modulr-glass-fill);
+ color: var(--modulr-text);
+ font-size: 0.9375rem;
+ font-family: inherit;
+ transition:
+ border-color var(--modulr-theme-duration) var(--modulr-theme-ease),
+ background-color var(--modulr-theme-duration) var(--modulr-theme-ease);
+}
+
+.km-input:focus {
+ outline: none;
+ border-color: color-mix(in srgb, var(--modulr-accent) 45%, var(--modulr-glass-border));
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--modulr-accent) 25%, transparent);
+}
+
+.km-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin-top: 1.25rem;
+}
+
+.km-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+ padding: 0.55rem 1.1rem;
+ border-radius: 0.5rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+ font-family: inherit;
+ cursor: pointer;
+ text-decoration: none;
+ border: 1px solid transparent;
+ transition:
+ background-color 0.2s ease,
+ border-color 0.2s ease,
+ color 0.2s ease;
+}
+
+.km-btn--primary {
+ background-color: var(--modulr-accent);
+ color: var(--modulr-accent-contrast);
+ border-color: color-mix(in srgb, var(--modulr-accent) 80%, black);
+}
+
+.km-btn--primary:hover {
+ filter: brightness(1.06);
+}
+
+.km-btn--ghost {
+ background-color: var(--modulr-glass-fill);
+ color: var(--modulr-text);
+ border-color: var(--modulr-glass-border);
+}
+
+.km-btn--ghost:hover {
+ border-color: color-mix(in srgb, var(--modulr-accent) 35%, var(--modulr-glass-border));
+ color: var(--modulr-accent);
+}
+
+.km-profile-grid {
+ display: grid;
+ gap: 1rem;
+ margin-top: 1rem;
+}
+
+@media (min-width: 640px) {
+ .km-profile-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.km-profile-card {
+ display: block;
+ padding: 1.1rem 1.25rem;
+ border-radius: 0.75rem;
+ border: 1px solid var(--modulr-glass-border);
+ background-color: var(--modulr-glass-fill);
+ text-decoration: none;
+ color: inherit;
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+.km-profile-card:hover {
+ border-color: color-mix(in srgb, var(--modulr-accent) 40%, var(--modulr-glass-border));
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
+}
+
+.km-profile-card h2 {
+ margin: 0 0 0.25rem;
+ font-size: 1.05rem;
+ font-weight: 700;
+}
+
+.km-profile-card .km-meta {
+ font-size: 0.75rem;
+}
+
+.km-mono-block {
+ margin: 0.75rem 0 0;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ border: 1px solid var(--modulr-glass-border);
+ background: color-mix(in srgb, var(--modulr-page-bg-2) 85%, transparent);
+ font-family: ui-monospace, monospace;
+ font-size: 0.7rem;
+ line-height: 1.5;
+ word-break: break-all;
+ max-height: 8rem;
+ overflow: auto;
+}
+
+.km-foot-hint {
+ margin-top: 1.5rem;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+}
+
+.km-skip-row {
+ margin-top: 1rem;
+ font-size: 0.8125rem;
+}
+
+.km-skip-row a {
+ color: var(--modulr-accent);
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.km-skip-row a:hover {
+ text-decoration: underline;
+}
diff --git a/keymaster/src/modulr_keymaster/static/keymaster.js b/keymaster/src/modulr_keymaster/static/keymaster.js
new file mode 100644
index 0000000..4a0ee07
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/static/keymaster.js
@@ -0,0 +1,208 @@
+/**
+ * Theme toggle (Core shell tokens) + fireflies canvas (parity with `FireflyField.tsx`)
+ * + pubkey copy helper.
+ */
+(function () {
+ const STORAGE_KEY = "modulr.keymaster.theme";
+ const root = document.documentElement;
+
+ function getStored() {
+ try {
+ return localStorage.getItem(STORAGE_KEY);
+ } catch {
+ return null;
+ }
+ }
+
+ function setStored(mode) {
+ try {
+ localStorage.setItem(STORAGE_KEY, mode);
+ } catch {
+ /* ignore */
+ }
+ }
+
+ function apply(mode) {
+ root.setAttribute("data-theme", mode);
+ setStored(mode);
+ const btn = document.getElementById("km-theme-toggle");
+ if (btn) {
+ btn.setAttribute(
+ "aria-label",
+ mode === "dark" ? "Switch to light theme" : "Switch to dark theme",
+ );
+ btn.setAttribute("data-mode", mode);
+ }
+ }
+
+ function initTheme() {
+ const stored = getStored();
+ const prefersDark =
+ window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+ const initial =
+ stored === "light" || stored === "dark" ? stored : prefersDark ? "dark" : "dark";
+ apply(initial);
+ }
+
+ function toggleTheme() {
+ const cur = root.getAttribute("data-theme") || "dark";
+ apply(cur === "dark" ? "light" : "dark");
+ }
+
+ /**
+ * Port of `frontend/components/background/FireflyField.tsx` (COUNT, motion, gradients).
+ * Skipped when `prefers-reduced-motion: reduce`.
+ */
+ function initFireflies() {
+ const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const canvas = document.getElementById("km-fireflies");
+ if (!canvas || motionQuery.matches) return;
+
+ const COUNT = 48;
+ const ACCENT = { r: 255, g: 183, b: 0 };
+ const DARK_BG = { r: 16, g: 19, b: 26 };
+ const DARK_BG_2 = { r: 22, g: 27, b: 38 };
+
+ let dpr = Math.min(window.devicePixelRatio || 1, 2);
+ /** @type {{ x: number; y: number; vx: number; vy: number; r: number; phase: number; tw: number }[]} */
+ let particles = [];
+ let raf = 0;
+ let last = performance.now();
+
+ function lightMode() {
+ return document.documentElement.getAttribute("data-theme") === "light";
+ }
+
+ function seed(w, h) {
+ particles = Array.from({ length: COUNT }, () => ({
+ x: Math.random() * w,
+ y: Math.random() * h,
+ vx: (Math.random() - 0.5) * 0.35,
+ vy: (Math.random() - 0.5) * 0.35,
+ r: 1.2 + Math.random() * 2.2,
+ phase: Math.random() * Math.PI * 2,
+ tw: 0.4 + Math.random() * 0.9,
+ }));
+ }
+
+ function resize() {
+ const c = canvas.getContext("2d", { alpha: true });
+ if (!c) return;
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+ canvas.width = Math.floor(w * dpr);
+ canvas.height = Math.floor(h * dpr);
+ canvas.style.width = `${w}px`;
+ canvas.style.height = `${h}px`;
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
+ seed(w, h);
+ }
+
+ function onResize() {
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
+ resize();
+ }
+
+ resize();
+ window.addEventListener("resize", onResize);
+
+ function tick(now) {
+ const c = canvas.getContext("2d", { alpha: true });
+ if (!c) return;
+
+ const light = lightMode();
+ const dt = Math.min(32, now - last);
+ last = now;
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
+ c.clearRect(0, 0, w, h);
+ c.globalCompositeOperation = "source-over";
+
+ for (const p of particles) {
+ p.phase += dt * 0.001 * p.tw;
+ const pulse = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(p.phase));
+ p.x += p.vx * dt * 0.06;
+ p.y += p.vy * dt * 0.06;
+ if (p.x < -20) p.x = w + 20;
+ if (p.x > w + 20) p.x = -20;
+ if (p.y < -20) p.y = h + 20;
+ if (p.y > h + 20) p.y = -20;
+
+ const radiusMul = light ? 7.5 : 9;
+ const g = c.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * radiusMul);
+
+ if (light) {
+ const a = pulse;
+ const { r: dr, g: dg, b: db } = DARK_BG;
+ const { r: d2r, g: d2g, b: d2b } = DARK_BG_2;
+ g.addColorStop(0, `rgba(${dr},${dg},${db},${0.34 * a})`);
+ g.addColorStop(0.18, `rgba(${d2r},${d2g},${d2b},${0.2 * a})`);
+ g.addColorStop(0.45, `rgba(${dr},${dg},${db},${0.08 * a})`);
+ g.addColorStop(1, `rgba(${dr},${dg},${db},0)`);
+ } else {
+ g.addColorStop(0, `rgba(${ACCENT.r},${ACCENT.g},${ACCENT.b},${0.75 * pulse})`);
+ g.addColorStop(0.22, `rgba(${ACCENT.r},${ACCENT.g},${ACCENT.b},${0.2 * pulse})`);
+ g.addColorStop(0.55, `rgba(${ACCENT.r},${ACCENT.g},${ACCENT.b},${0.06 * pulse})`);
+ g.addColorStop(1, "rgba(0,0,0,0)");
+ }
+
+ c.fillStyle = g;
+ c.beginPath();
+ c.arc(p.x, p.y, p.r * radiusMul, 0, Math.PI * 2);
+ c.fill();
+ }
+
+ c.globalCompositeOperation = "source-over";
+ raf = requestAnimationFrame(tick);
+ }
+
+ raf = requestAnimationFrame(tick);
+
+ function onMotionChange() {
+ if (motionQuery.matches) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ const c = canvas.getContext("2d", { alpha: true });
+ if (c) {
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
+ c.clearRect(0, 0, w, h);
+ }
+ } else if (!raf) {
+ last = performance.now();
+ raf = requestAnimationFrame(tick);
+ }
+ }
+
+ if (motionQuery.addEventListener) {
+ motionQuery.addEventListener("change", onMotionChange);
+ } else {
+ motionQuery.addListener(onMotionChange);
+ }
+ }
+
+ document.addEventListener("DOMContentLoaded", function () {
+ initTheme();
+ initFireflies();
+ const btn = document.getElementById("km-theme-toggle");
+ if (btn) btn.addEventListener("click", toggleTheme);
+ });
+
+ window.kmCopyPubkey = function (id) {
+ const el = document.getElementById(id);
+ if (!el || !navigator.clipboard) return;
+ const text = el.textContent.replace(/\s+/g, "").trim();
+ navigator.clipboard.writeText(text).then(function () {
+ const hint = document.getElementById("km-copy-hint");
+ if (hint) {
+ hint.hidden = false;
+ window.setTimeout(function () {
+ hint.hidden = true;
+ }, 2000);
+ }
+ });
+ };
+})();
diff --git a/keymaster/src/modulr_keymaster/static/modulr-logo.svg b/keymaster/src/modulr_keymaster/static/modulr-logo.svg
new file mode 100644
index 0000000..e9bbdfb
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/static/modulr-logo.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/keymaster/src/modulr_keymaster/static/modulr-theme.css b/keymaster/src/modulr_keymaster/static/modulr-theme.css
new file mode 100644
index 0000000..eae28ee
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/static/modulr-theme.css
@@ -0,0 +1,110 @@
+/**
+ * Subset of `frontend/app/globals.css` — Core shell tokens + glass.
+ * Light theme: toggle `data-theme="light"` on `` (see keymaster.js).
+ */
+:root {
+ --modulr-page-bg: #10131a;
+ --modulr-page-bg-2: #161b26;
+ --modulr-text: #e9eaef;
+ --modulr-text-muted: #9aa0b0;
+ --modulr-accent: #ffb700;
+ --modulr-accent-contrast: #111218;
+ --modulr-glass-fill: rgba(22, 25, 36, 0.22);
+ --modulr-glass-panel-fill: rgba(22, 25, 36, 0.1);
+ --modulr-glass-border: rgba(255, 255, 255, 0.1);
+ --modulr-glass-highlight: rgba(255, 255, 255, 0.06);
+ --modulr-theme-ease: cubic-bezier(0.4, 0, 0.2, 1);
+ --modulr-theme-duration: 0.5s;
+ --modulr-text-color-duration: 0.18s;
+ --modulr-glass-blur: 22px;
+ --modulr-glass-sat: 1.35;
+}
+
+html[data-theme="light"] {
+ --modulr-page-bg: #e8eaf2;
+ --modulr-page-bg-2: #dce0ee;
+ --modulr-text: #111218;
+ --modulr-text-muted: #5c6170;
+ --modulr-glass-fill: rgba(255, 255, 255, 0.34);
+ --modulr-glass-panel-fill: rgba(255, 255, 255, 0.4);
+ --modulr-glass-border: rgba(15, 23, 42, 0.11);
+ --modulr-glass-highlight: rgba(255, 255, 255, 0.72);
+ --modulr-glass-blur: 32px;
+ --modulr-glass-sat: 1.55;
+}
+
+html {
+ color-scheme: dark light;
+ transition: background-color var(--modulr-theme-duration) var(--modulr-theme-ease);
+ background-color: var(--modulr-page-bg);
+}
+
+html[data-theme="light"] {
+ color-scheme: light;
+}
+
+html[data-theme="dark"] {
+ color-scheme: dark;
+}
+
+.km-display {
+ font-family: var(--font-quantico), ui-sans-serif, sans-serif;
+}
+
+.km-body {
+ margin: 0;
+ min-height: 100vh;
+ background-color: var(--modulr-page-bg);
+ color: var(--modulr-text);
+ font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
+ transition:
+ background-color var(--modulr-theme-duration) var(--modulr-theme-ease),
+ color var(--modulr-text-color-duration) var(--modulr-theme-ease);
+}
+
+.km-text {
+ color: var(--modulr-text);
+ transition: color var(--modulr-text-color-duration) var(--modulr-theme-ease);
+}
+
+.km-text-muted {
+ color: var(--modulr-text-muted);
+ transition: color var(--modulr-text-color-duration) var(--modulr-theme-ease);
+}
+
+.modulr-glass-surface {
+ -webkit-backdrop-filter: blur(var(--modulr-glass-blur)) saturate(var(--modulr-glass-sat));
+ backdrop-filter: blur(var(--modulr-glass-blur)) saturate(var(--modulr-glass-sat));
+ transition:
+ background-color var(--modulr-theme-duration) var(--modulr-theme-ease),
+ border-color var(--modulr-theme-duration) var(--modulr-theme-ease),
+ box-shadow var(--modulr-theme-duration) var(--modulr-theme-ease),
+ -webkit-backdrop-filter var(--modulr-theme-duration) var(--modulr-theme-ease),
+ backdrop-filter var(--modulr-theme-duration) var(--modulr-theme-ease);
+}
+
+::selection {
+ background: color-mix(in srgb, var(--modulr-accent) 35%, transparent);
+}
+
+.km-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: #ffb700 var(--modulr-page-bg-2);
+}
+
+.km-scrollbar::-webkit-scrollbar {
+ width: 9px;
+ height: 9px;
+}
+
+.km-scrollbar::-webkit-scrollbar-track {
+ margin: 4px 0;
+ background: var(--modulr-page-bg-2);
+ border-radius: 999px;
+}
+
+.km-scrollbar::-webkit-scrollbar-thumb {
+ background: #ffb700;
+ border-radius: 999px;
+ border: 2px solid var(--modulr-page-bg-2);
+}
diff --git a/keymaster/src/modulr_keymaster/templates/base.html b/keymaster/src/modulr_keymaster/templates/base.html
new file mode 100644
index 0000000..423179f
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/templates/base.html
@@ -0,0 +1,128 @@
+
+
+
+
+
+ {% block title %}{{ page_title }} — Keymaster{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if ui_preview %}
+
+ UI preview — encrypted vault and signing are not wired yet
+
+ {% endif %}
+ {% block content %}{% endblock %}
+
+
+
+
+
+
+
+
diff --git a/keymaster/src/modulr_keymaster/templates/dashboard.html b/keymaster/src/modulr_keymaster/templates/dashboard.html
new file mode 100644
index 0000000..4a81aaf
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/templates/dashboard.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+{% block content %}
+
+ Identities
+
+ Local Ed25519 profiles in your vault (mock data). Registration and name resolution use
+ Modulr.Core — open a row for public key details and copy-to-clipboard.
+
+
+
+
+{% endblock %}
diff --git a/keymaster/src/modulr_keymaster/templates/not_found.html b/keymaster/src/modulr_keymaster/templates/not_found.html
new file mode 100644
index 0000000..d1d416a
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/templates/not_found.html
@@ -0,0 +1,10 @@
+{% extends "base.html" %}
+{% block content %}
+
+ Not found
+ No identity matches that path.
+
+
+{% endblock %}
diff --git a/keymaster/src/modulr_keymaster/templates/profile_detail.html b/keymaster/src/modulr_keymaster/templates/profile_detail.html
new file mode 100644
index 0000000..37d39e0
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/templates/profile_detail.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+{% block content %}
+
+ {{ profile.display_name }}
+
+ Signing public key (Ed25519, 64 hex chars). Safe to share; never share the private seed.
+
+ Profile id: {{ profile.id }}
+ {{ profile.public_key_hex }}
+
+
+
+{% endblock %}
diff --git a/keymaster/src/modulr_keymaster/templates/setup.html b/keymaster/src/modulr_keymaster/templates/setup.html
new file mode 100644
index 0000000..ca1302b
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/templates/setup.html
@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+{% block content %}
+
+ Create vault
+
+ First-run setup: choose a strong passphrase. Keys never leave this machine. This screen is
+ visual only until the vault module exists.
+
+
+
+{% endblock %}
diff --git a/keymaster/src/modulr_keymaster/templates/unlock.html b/keymaster/src/modulr_keymaster/templates/unlock.html
new file mode 100644
index 0000000..bdd29ee
--- /dev/null
+++ b/keymaster/src/modulr_keymaster/templates/unlock.html
@@ -0,0 +1,26 @@
+{% extends "base.html" %}
+{% block content %}
+
+ Unlock vault
+
+ Enter the passphrase for your local vault. Nothing is persisted in this preview — the form is
+ for layout only.
+
+
+
+
+ Skip to identities (mock data) to review the dashboard layout.
+
+
+{% endblock %}
diff --git a/pyproject.toml b/pyproject.toml
index 49dfa9f..af6ebb0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,10 +36,10 @@ addopts = "-q"
[tool.ruff]
target-version = "py311"
line-length = 88
-src = ["src", "tests", "scripts"]
+src = ["src", "tests", "scripts", "keymaster/src"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
[tool.ruff.lint.isort]
-known-first-party = ["modulr_core"]
+known-first-party = ["modulr_core", "modulr_keymaster"]