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. +

+
+ Lock vault (placeholder) +
+
+ {% for p in profiles %} + +

{{ p.display_name }}

+ id: {{ p.id }} · created {{ p.created_at[:10] }} +
{{ p.public_key_hex[:32] }}…
+
+ {% endfor %} +
+
+{% 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.

+
+ Back to identities +
+
+{% 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 }}
+
+ + All identities +
+ +
+{% 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. +

+
+
+ + +
+
+ + +
+
+ + Already have a vault +
+
+
+{% 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. +

+
+
+ + +
+ +
+

+ Production flow will decrypt your vault in memory only and keep the bind on loopback. +

+

+ 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"]