feat: add Impeccable themes (light + dark) + unified ProfileEditModal

- settings: add Theme::Impeccable / ImpeccableDark (serde rename impeccable-dark)
- types: extend Theme union
- styles: add :root[data-theme=impeccable] (oklch lacquer/kinpaku) and impeccable-dark (lacquer-deep), Alumni Sans display, editorial refinements
- SettingsScreen: add Impeccable options with live swatch preview
- ProfilesScreen: wire ProfileEditModal (Paper Lift) with segmented tabs, keep legacy modals for compat
- DESIGN.md/PRODUCT.md + .impeccable/design.json from impeccable document
This commit is contained in:
Avi 2026-08-27 14:12:52 -05:00
commit 8366af7d86
10 changed files with 1258 additions and 3 deletions

1
.impeccable/config.json Normal file
View file

@ -0,0 +1 @@
{"buildPath":"code"}

380
.impeccable/design.json Normal file
View file

@ -0,0 +1,380 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-27T18:00:17.873Z",
"title": "Design System: Nostr Keynctr",
"extensions": {
"colorMeta": {
"warm-ivory": {
"role": "neutral",
"displayName": "Warm Ivory",
"canonical": "#f6f4f0",
"tonalRamp": [
"#1e1b15",
"#3d3830",
"#5c564e",
"#7c756d",
"#9c958d",
"#bdb9b3",
"#deddda",
"#f6f4f0"
]
},
"paper": {
"role": "neutral",
"displayName": "Paper",
"canonical": "#ffffff",
"tonalRamp": [
"#1f1b15",
"#3e3a34",
"#5d5953",
"#7d7872",
"#9c9791",
"#bcb6b0",
"#deddda",
"#ffffff"
]
},
"soft-stone": {
"role": "neutral",
"displayName": "Soft Stone",
"canonical": "#f1ede7",
"tonalRamp": [
"#1d1b18",
"#3c3936",
"#5b5854",
"#7a7772",
"#999690",
"#b8b5ae",
"#d7d4cc",
"#f1ede7"
]
},
"ink": {
"role": "neutral",
"displayName": "Graphite Ink",
"canonical": "#201b15",
"tonalRamp": [
"#201b15",
"#4d4741",
"#7a756d",
"#a7a39e",
"#d4d1ce",
"#eceae8",
"#f5f4f3",
"#faf9f8"
]
},
"ink-muted": {
"role": "neutral",
"displayName": "Ink Muted",
"canonical": "#6d655a",
"tonalRamp": [
"#2a2722",
"#403d38",
"#56544f",
"#6d655a",
"#8a857e",
"#a7a5a1",
"#c4c2be",
"#e1dfdc"
]
},
"line": {
"role": "neutral",
"displayName": "Line",
"canonical": "#e6e0d6",
"tonalRamp": [
"#2c2a27",
"#4a4845",
"#686663",
"#868481",
"#a4a29f",
"#c2c0bd",
"#e0dedb",
"#e6e0d6"
]
},
"antique-brass": {
"role": "primary",
"displayName": "Antique Brass",
"canonical": "#6d5ae6",
"tonalRamp": [
"#221b4a",
"#3a2d7a",
"#523faa",
"#6d5ae6",
"#8b7cff",
"#a99dff",
"#c7bdff",
"#efecfd"
]
},
"antique-brass-soft": {
"role": "primary",
"displayName": "Brass Wash",
"canonical": "#efecfd",
"tonalRamp": [
"#221b4a",
"#3a2d7a",
"#523faa",
"#6d5ae6",
"#8b7cff",
"#c7bdff",
"#efecfd",
"#f8f7ff"
]
},
"signal-coral": {
"role": "neutral",
"displayName": "Signal Coral",
"canonical": "#c0392b",
"tonalRamp": [
"#3a1512",
"#5a1f1b",
"#7a2a24",
"#9a352d",
"#c0392b",
"#d66a60",
"#e99a96",
"#fdecec"
]
},
"muted-sage": {
"role": "neutral",
"displayName": "Muted Sage",
"canonical": "#22713c",
"tonalRamp": [
"#0f2a18",
"#143d22",
"#1a502c",
"#22713c",
"#4a8a5f",
"#73a382",
"#9cbca5",
"#e7f5ec"
]
},
"antique-gold": {
"role": "neutral",
"displayName": "Antique Gold",
"canonical": "#9a6b13",
"tonalRamp": [
"#2e2110",
"#4a3418",
"#664720",
"#825a28",
"#9a6b13",
"#b88a3a",
"#d6aa62",
"#fcf3e0"
]
},
"clear-ink": {
"role": "neutral",
"displayName": "Clear Ink",
"canonical": "#2868a4",
"tonalRamp": [
"#0f2538",
"#163a58",
"#1d4f78",
"#2868a4",
"#4d82b4",
"#739dc4",
"#99b8d4",
"#e9f2fb"
]
}
},
"typographyMeta": {
"display": {
"displayName": "Display",
"purpose": "Page titles (h1) only — 24px, weight 650."
},
"headline": {
"displayName": "Headline",
"purpose": "Card and modal heads (h2) — 16px, weight 650."
},
"body": {
"displayName": "Body",
"purpose": "Reading text — 15px / 1.5, max 65–75ch."
},
"label": {
"displayName": "Label",
"purpose": "Field labels and hints — 13px / 600."
},
"mono": {
"displayName": "Mono",
"purpose": "Code and npub — SFMono 13.8px."
}
},
"shadows": [
{
"name": "paper-lift",
"value": "0 1px 2px rgba(30,25,18,0.05), 0 8px 24px rgba(30,25,18,0.06)",
"purpose": "Default card lift on Warm Ivory."
},
{
"name": "modal-lift",
"value": "0 12px 40px rgba(20,15,10,0.22)",
"purpose": "Modal over app."
},
{
"name": "neon-glow",
"value": "0 0 32px rgba(255,20,147,0.12)",
"purpose": "Gated neon modal glow only on dark."
}
],
"motion": [
{
"name": "ease-standard",
"value": "0.12s ease",
"purpose": "Button, nav, border-color transitions."
},
{
"name": "ease-glass",
"value": "blur 18px saturate(160%)",
"purpose": "Aurora glass panels."
}
],
"breakpoints": [
{
"name": "sidebar",
"value": "920px"
},
{
"name": "card-grid",
"value": "280px"
}
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "Main action — Antique Brass on Paper.",
"html": "<button class=\"ds-btn-primary\">Publish</button>",
"css": ".ds-btn-primary{background:var(--primary);color:var(--on-primary);border:1px solid transparent;border-radius:9px;padding:10px 18px;font-weight:600;transition:background 0.12s ease} .ds-btn-primary:hover{background:var(--primary-hover)} .ds-btn-primary:focus-visible{outline:2px solid var(--focus);outline-offset:2px}"
},
{
"name": "Secondary Button",
"kind": "button",
"refersTo": "button-secondary",
"description": "Paper button with strong line border.",
"html": "<button class=\"ds-btn-secondary\">Select</button>",
"css": ".ds-btn-secondary{background:var(--surface);color:var(--text);border:1px solid var(--border-strong);border-radius:9px;padding:10px 18px;font-weight:600} .ds-btn-secondary:hover{background:var(--surface-hover)}"
},
{
"name": "Ghost Button",
"kind": "button",
"refersTo": "button-ghost",
"description": "Transparent for tertiary actions.",
"html": "<button class=\"ds-btn-ghost\">Edit name</button>",
"css": ".ds-btn-ghost{background:transparent;color:var(--text-muted);border:1px solid transparent;border-radius:9px;padding:7px 12px;font-weight:600} .ds-btn-ghost:hover{background:var(--surface-2);color:var(--text)}"
},
{
"name": "Danger Button",
"kind": "button",
"refersTo": "button-danger",
"description": "Signal Coral wash for destructive actions.",
"html": "<button class=\"ds-btn-danger\">Delete</button>",
"css": ".ds-btn-danger{background:var(--danger-soft);color:var(--danger);border:1px solid transparent;border-radius:9px;padding:7px 12px;font-weight:600} .ds-btn-danger:hover{background:var(--danger);color:#fff}"
},
{
"name": "Card",
"kind": "card",
"refersTo": "card",
"description": "Paper surface with soft lift.",
"html": "<div class=\"ds-card\"><div class=\"ds-card-head\">Active profile</div><div class=\"ds-card-body\">Profile content</div></div>",
"css": ".ds-card{background:var(--surface);border:1px solid var(--border);border-radius:14px;box-shadow:0 1px 2px rgba(30,25,18,0.05),0 8px 24px rgba(30,25,18,0.06);overflow:hidden} .ds-card-head{padding:16px 20px;border-bottom:1px solid var(--border);font-weight:650} .ds-card-body{padding:20px}"
},
{
"name": "Input",
"kind": "input",
"refersTo": "input",
"description": "Paper field with strong line border.",
"html": "<label class=\"ds-field\">Label<input class=\"ds-input\" placeholder=\"name@domain.com\" /></label>",
"css": ".ds-input{font:inherit;color:var(--text);background:var(--surface);border:1px solid var(--border-strong);border-radius:9px;padding:10px 12px;width:100%} .ds-input:focus{outline:none;border-color:var(--focus);box-shadow:0 0 0 3px color-mix(in srgb,var(--focus) 22%, transparent)}"
},
{
"name": "Navigation Item",
"kind": "nav",
"refersTo": "nav-item",
"description": "Sidebar nav with muted → brass active.",
"html": "<button class=\"ds-nav is-active\">Home</button>",
"css": ".ds-nav{display:flex;gap:12px;padding:11px 12px;border-radius:9px;border:none;background:transparent;color:var(--text-muted);font-weight:550} .ds-nav:hover{background:var(--surface-2);color:var(--text)} .ds-nav.is-active{background:var(--primary-soft);color:var(--primary)}"
},
{
"name": "Undo Bar",
"kind": "custom",
"refersTo": "undo-bar",
"description": "Themed dismissal bar after delete — 5s, primary-soft.",
"html": "<div class=\"ds-undo\"><span>Profile “Alice” deleted.</span><span>—</span><button class=\"ds-undo-link\">Undo and restore profile</button></div>",
"css": ".ds-undo{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:12px 0;padding:10px 14px;background:var(--primary-soft);border:1px solid var(--border);border-radius:8px} .ds-undo-link{background:none;border:none;padding:0;color:var(--primary);font-weight:600;cursor:pointer;text-decoration:underline;text-underline-offset:2px}"
},
{
"name": "Badge",
"kind": "chip",
"refersTo": "badge",
"description": "Pill tag for state.",
"html": "<span class=\"ds-badge success\">Active</span>",
"css": ".ds-badge{display:inline-flex;gap:5px;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:650;white-space:nowrap} .ds-badge.success{background:var(--success-soft);color:var(--success)}"
},
{
"name": "Profile Edit Modal",
"kind": "custom",
"refersTo": "modal-profile-edit",
"description": "Paper modal for Rename / Picture / NIP-05 — Paper Lift, vault separation.",
"html": "<div class=\"ds-backdrop\"><div class=\"ds-modal\"><div class=\"ds-modal-head\"><h3>Edit profile — Alice</h3><button class=\"ds-icon\">×</button></div><div class=\"ds-modal-body\"><div class=\"ds-avatar-lg\">A</div><p class=\"ds-muted\">Public npub is visible to relays; private key never leaves Rust.</p><label class=\"ds-field\"><span>Profile name</span><input class=\"ds-input\" value=\"Alice\" /></label><label class=\"ds-field\"><span>Picture URL</span><input class=\"ds-input\" placeholder=\"https://…/avatar.png\" /></label><label class=\"ds-field\"><span>NIP-05</span><input class=\"ds-input\" placeholder=\"alice@example.com\" /></label><div class=\"ds-modal-actions\"><button class=\"ds-btn-danger\">Remove profile</button><span style=\"flex:1\"></span><button class=\"ds-btn-ghost\">Cancel</button><button class=\"ds-btn-primary\">Save & publish</button></div></div></div></div>",
"css": ".ds-backdrop{position:fixed;inset:0;background:rgba(20,16,12,0.45);display:grid;place-items:center;padding:24px} .ds-modal{background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:0 12px 40px rgba(20,15,10,0.22);width:100%;max-width:520px;max-height:88vh;overflow:auto} .ds-modal-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:18px 22px;border-bottom:1px solid var(--border);font-weight:650} .ds-modal-body{padding:20px 22px;display:flex;flex-direction:column;gap:16px} .ds-avatar-lg{width:52px;height:52px;border-radius:50%;display:grid;place-items:center;background:var(--primary-soft);color:var(--primary);font-weight:700;font-size:21px} .ds-muted{color:var(--text-muted);font-size:13px;margin:0} .ds-field{display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:600;color:var(--text)} .ds-modal-actions{display:flex;justify-content:flex-end;gap:10px;align-items:center;margin-top:4px} .ds-icon{width:34px;height:34px;border-radius:999px;border:1px solid var(--border);background:var(--surface)} [data-theme=\"glass\"] .ds-modal{backdrop-filter:blur(18px) saturate(160%);-webkit-backdrop-filter:blur(18px) saturate(160%)} [data-theme=\"neon\"] .ds-modal{box-shadow:0 12px 40px rgba(0,0,0,0.85),0 0 32px rgba(255,20,147,0.12)}"
}
],
"narrative": {
"northStar": "Vault & Atelier",
"overview": "Nostr Keynctr is an atelier, not a dashboard — a warm, quiet workshop where identity work is done with care. The space feels like heavy paper and soft stone, with ink that is near-black, not pure black. Instruments are laid out plainly; nothing shouts for attention. Trust is built through precision: consistent edges, settled type, and state that is always legible.",
"keyCharacteristics": [
"Warm and human, precise and calm",
"Paper and stone material language — tonal layering over heavy shadows",
"Semantic tokens only — every color has a job",
"Operate density — tasks complete faster because hierarchy is consistent",
"Vault logic made visible — locked/muted states are distinct at a glance"
],
"rules": [
{
"name": "The One Accent Rule",
"body": "Antique Brass is the only saturated accent in the restrained palette. Its rarity is the point — use it for primary actions and active state only.",
"section": "colors"
},
{
"name": "The Vault Separation Rule",
"body": "Secret-dependent UI never shares color with public data: private operations are flagged via muted-sage/signal-coral and explicit Approve/Reject.",
"section": "colors"
},
{
"name": "The Weight Restraint Rule",
"body": "Only headings are 650. Do not add semibold to body or emphasize muted text with weight.",
"section": "typography"
},
{
"name": "The Flat-By-Default Rule",
"body": "Surfaces are flat at rest. Shadows appear only as a response to layer (card on canvas, modal over app).",
"section": "elevation"
}
],
"dos": [
"Do keep Warm Ivory as the only page canvas on light",
"Do use Muted Sage wash only for successful security confirmation",
"Do keep one primary per screen — Antique Brass is rare by design",
"Do use var(--focus) for every focus ring; keep the 2px + 2px offset invariant",
"Do preserve the side-by-side rate: cards on Paper with 1px Line borders before adding any shadow"
],
"donts": [
"Don't use Signal Coral outside error/destructive states",
"Don't introduce a new accent hue outside the four semantic washes",
"Don't bump heading weight beyond 650 or add bold to muted text",
"Don't apply neon glow outside data-theme='neon' modals",
"Don't invent a new radius — step on 9px/14px/16px/999px only",
"Don't replace the 5s auto-dismiss undo bar with a permanent modal"
]
}
}

263
DESIGN.md Normal file
View file

@ -0,0 +1,263 @@
---
name: Nostr Keynctr
description: Local-first Nostr identity atelier — keys stay in Rust, the UI stays calm and warm.
colors:
warm-ivory: "#f6f4f0"
soft-stone: "#f1ede7"
stone-hover: "#faf8f5"
paper: "#ffffff"
ink: "#201b15"
ink-muted: "#6d655a"
line: "#e6e0d6"
line-strong: "#d8d0c3"
antique-brass: "#6d5ae6"
antique-brass-hover: "#5c49d4"
antique-brass-soft: "#efecfd"
on-brass: "#ffffff"
signal-coral: "#c0392b"
signal-coral-soft: "#fdecec"
antique-gold: "#9a6b13"
antique-gold-soft: "#fcf3e0"
muted-sage: "#22713c"
muted-sage-soft: "#e7f5ec"
clear-ink: "#2868a4"
clear-ink-soft: "#e9f2fb"
focus: "#6d5ae6"
typography:
display:
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, 'Noto Sans', sans-serif"
fontSize: "24px"
fontWeight: 650
lineHeight: 1.25
letterSpacing: "normal"
headline:
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, 'Noto Sans', sans-serif"
fontSize: "16px"
fontWeight: 650
lineHeight: 1.25
letterSpacing: "normal"
body:
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, 'Noto Sans', sans-serif"
fontSize: "15px"
fontWeight: 400
lineHeight: 1.5
letterSpacing: "normal"
label:
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, 'Noto Sans', sans-serif"
fontSize: "13px"
fontWeight: 600
lineHeight: 1.5
letterSpacing: "normal"
mono:
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
fontSize: "13.8px"
fontWeight: 400
lineHeight: 1.5
letterSpacing: "normal"
rounded:
sm: "9px"
md: "14px"
lg: "16px"
pill: "999px"
spacing:
xs: "8px"
sm: "12px"
md: "16px"
lg: "20px"
xl: "32px"
components:
button-primary:
backgroundColor: "{colors.antique-brass}"
textColor: "{colors.on-brass}"
rounded: "{rounded.sm}"
padding: "10px 18px"
button-primary-hover:
backgroundColor: "{colors.antique-brass-hover}"
textColor: "{colors.on-brass}"
rounded: "{rounded.sm}"
padding: "10px 18px"
button-secondary:
backgroundColor: "{colors.paper}"
textColor: "{colors.ink}"
rounded: "{rounded.sm}"
padding: "10px 18px"
button-ghost:
backgroundColor: "transparent"
textColor: "{colors.ink-muted}"
rounded: "{rounded.sm}"
padding: "7px 12px"
button-danger:
backgroundColor: "{colors.signal-coral-soft}"
textColor: "{colors.signal-coral}"
rounded: "{rounded.sm}"
padding: "7px 12px"
card:
backgroundColor: "{colors.paper}"
textColor: "{colors.ink}"
rounded: "{rounded.md}"
padding: "18px"
input:
backgroundColor: "{colors.paper}"
textColor: "{colors.ink}"
rounded: "{rounded.sm}"
padding: "10px 12px"
---
# Design System: Nostr Keynctr
## Overview
**Creative North Star: "Vault & Atelier"**
Nostr Keynctr is an atelier, not a dashboard — a warm, quiet workshop where identity work is done with care. The space feels like heavy paper and soft stone, with ink that is near-black, not pure black. Instruments are laid out plainly; nothing shouts for attention. Trust is built through precision: consistent edges, settled type, and state that is always legible. The product truth — keys never leave Rust — is mirrored visually: the UI is restrained, the material is honest, and every destructive or security-relevant moment is given deliberate weight.
The aesthetic is *warm and human*, not technical or bold. Density is Operate: scannable lists, clear hierarchies, and generous but not loose spacing (8/12/16/20/32). The four themes (light, dark, glass/Aurora, neon) share the same semantic roles; only the material values shift. Neon and glass are gated expressions, never the default.
**Key Characteristics:**
- Warm and human, precise and calm — humanity first, then exactness.
- Paper and stone material language — tonal layering over heavy shadows.
- Semantic tokens only — every color has a job (public data vs. private operation vs. relay health vs. security).
- Operate density — tasks complete faster because hierarchy is consistent.
- Vault logic made visible — locked/muted states are distinct at a glance.
## Colors
Four themes share the same semantic roles; values below are the light (paper) baseline extracted from `frontend/src/styles.css` (`:root` / `:root[data-theme='light']`). Dark, neon, and glass override the same tokens (see Elevation notes for glass treatment).
### Primary
- **Antique Brass — restrained identity accent** (`{colors.antique-brass}` #6d5ae6): Used for primary actions, active nav (`is-active`), focus rings, and the themed undo bar link ("Undo and restore profile"). Restrained — ≤10% of any screen. Hover is `{colors.antique-brass-hover}` #5c49d4; wash is `{colors.antique-brass-soft}` #efecfd.
- **Focus Violet** (`{colors.focus}` #6d5ae6): Same value as primary; outlines via `outline: 2px solid var(--focus)`.
### Neutral
- **Warm Ivory — canvas** (`{colors.warm-ivory}` #f6f4f0): `var(--bg)` — page background.
- **Paper — surface** (`{colors.paper}` #ffffff): `var(--surface)` — cards, sidebar, modals.
- **Soft Stone — raised/tinted surface** (`{colors.soft-stone}` #f1ede7): `var(--surface-2)` — inline code, secondary surfaces, segmented control track, `create-explainer`.
- **Stone Hover — hover wash** (`{colors.stone-hover}` #faf8f5): `var(--surface-hover)`.
- **Ink — primary text** (`{colors.ink}` #201b15): `var(--text)` — near-black, never pure black.
- **Ink Muted — secondary text** (`{colors.ink-muted}` #6d655a): `var(--text-muted)` — subtitles, hints, `.muted`, dates.
- **Line — hairline border** (`{colors.line}` #e6e0d6): `var(--border)`.
- **Line Strong — emphasized border** (`{colors.line-strong}` #d8d0c3): `var(--border-strong)` — input strokes, secondary buttons.
### Semantic Feedback
- **Muted Sage — secure/success** (`{colors.muted-sage}` #22713c / wash #e7f5ec): active profile border, success badges/alerts. Not decorative — only for successful security-state confirmation.
- **Signal Coral — destructive/warning/error** (`{colors.signal-coral}` #c0392b / wash #fdecec): danger buttons, error alerts, relay bad dots. Destructive actions live here alone.
- **Antique Gold — caution** (`{colors.antique-gold}` #9a6b13 / wash #fcf3e0): warning badges/alerts.
- **Clear Ink — information/relay** (`{colors.clear-ink}` #2868a4 / wash #e9f2fb): info badges/alerts, relay neutral states.
- **On Brass — text on accent** (`{colors.on-brass}` #ffffff): label inside primary buttons.
**Theme overrides (same roles, different material):** Dark inverts to ink surfaces (`#211d29`, `#2a2534`) with primary `#8b7cff`; Neon uses pure black panels (`#000000`) and Cyber Pink primary `#ff1493` + Aurora Purple/Yellow highlights; Glass (Aurora) uses translucent `rgba(23,29,41,0.58)` surfaces with `backdrop-filter: blur(18px) saturate(160%)` and cyan primary `#7fdbff`. Tokens stay semantically identical, so components do not branch per theme.
### Named Rules
**The One Accent Rule.** Antique Brass is the only saturated accent in the restrained palette. Its rarity is the point — use it for primary actions and active state only.
**The Vault Separation Rule.** Secret-dependent UI never shares color with public data: private operations are flagged via `muted-sage`/`signal-coral` and explicit Approve/Reject, never via primary alone.
## Typography
**Display Font:** System UI stack (`system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, 'Noto Sans', sans-serif`) — no custom display face; trust is in weight and rhythm, not ornament.
**Body Font:** Same System UI stack — continuity over contrast.
**Label/Mono Font:** `SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace` — code and `npub` only.
**Character:** Humanist, quiet, and precise. Weight 650 for headings is the only emphasis; body is regular 15/1.5 for long-form readability without dense compaction.
### Hierarchy
- **Display** (650, 24px, 1.25): `h1` page titles only — one per screen.
- **Headline** (650, 16px, 1.25): `h2` card heads, modal titles.
- **Title** (650, 16px, 1.25): Profile/cluster names (`.profile-name`, `.profile-card-meta h3`).
- **Body** (400, 15px, 1.5): All reading text, `p`, `li`, `.page-subtitle` (14px muted variant). Max width ~65–75ch in cards.
- **Label** (600, 13px): Field labels, `.hint`, `.char-count`, `.field > label`.
- **Mono** (400, 13.8px / 0.92em): `code`, `.mono`, npub displays — always on `Soft Stone` pill.
### Named Rules
**The Weight Restraint Rule.** Only headings are 650. Do not add semibold to body or emphasize muted text with weight — color shift (`ink` → `ink-muted`) is the emphasis.
## Layout
**Canvas:** Centered column (`max-width: 920px`, `margin: 0 auto`) with screen padding `32px 40px 56px` (collapses proportionally on narrow viewports). Inner stack via `.screen-inner` (`gap: 20px`, `flex-direction: column`).
**Shell:** Fixed sidebar `248px` + flexible `.main` (`overflow-y: auto`). Sidebar is paper on light, translucent blurred pane on glass. App is `height: 100vh` locked; only main scrolls.
**Grids:** Home is single-column (`gap: 20px`). Profiles use responsive `repeat(auto-fill, minmax(280px, 1fr))` (`gap: 16px`). Relays are vertical list (`gap: 12px`). Compose card keeps tabs above editor; preview stacks below.
**Spacing rhythm:** 8/12/16/20/32. Section gaps 20px; card internal padding 18–20px; button padding `10px 18px` (md) / `7px 12px` (sm); input padding `10px 12px`. No arbitrary values — step on the scale.
**Responsive:** Flex-wrap at page heads and action rows; segmented controls wrap; profile grid collapses to single column; modal max-width `520px`, max-height `88vh`.
## Elevation & Depth
System is **flat-by-default, tonal before lifted.** Depth is conveyed first by material contrast (Warm Ivory → Paper → Soft Stone) and 1px `Line` borders. Shadows are whisper-low and never the primary separation.
### Shadow Vocabulary
- **Paper Lift** (`box-shadow: 0 1px 2px rgba(30,25,18,0.05), 0 8px 24px rgba(30,25,18,0.06)`): Cards, profile cards — the default lift for paper surfaces on ivory.
- **Modal Lift** (`box-shadow: 0 12px 40px rgba(20,15,10,0.22)`): Modals — only overlay that earns a large, soft shadow. On dark: `0 12px 40px rgba(0,0,0,0.6)`.
- **Neon Glow (gated)** (`0 0 32px rgba(255,20,147,0.12)` paired with dark shadows): Only in `data-theme='neon'` modals — never on light/paper.
**Glass/Aurora exception:** Panels become translucent (`rgba(23,29,41,0.58)`, `rgba(255,255,255,0.05)`) with `backdrop-filter: blur(18px) saturate(160%)`; body carries three radial gradients (cyan/purple/aqua washes) to create depth through blur, not shadow.
### Named Rules
**The Flat-By-Default Rule.** Surfaces are flat at rest. Shadows appear only as a response to layer (card on canvas, modal over app). Never add shadow to inline controls or navigation.
## Shapes
Form language is *softly squared with a pill exception.* Corners are gentle, never sharp, never fully rounded except for tags and avatars.
- **Soft square** (`border-radius: var(--radius)` 14px): Cards, modals (16px), empty states — the workhorse.
- **Compact square** (`border-radius: var(--radius-sm)` 9px): Buttons, inputs, alerts, segmented control, nav items.
- **Pill** (`border-radius: 999px`): Badges, `npub-chip`, toggles, compose tabs, segmented buttons.
- **Circle** (`50%`): Avatars (42px / 52px lg), toggles thumb.
- **Empty-state dash** (`border: 1.5px dashed var(--border-strong)`): Deliberately unfinished — signals "bring your own."
- **Glass blur** is shape-adjacent: panels keep the same radii but gain `backdrop-filter` to soften edges without changing geometry.
## Components
### Buttons
- **Shape:** Compact square (9px), `font-weight: 600`, `gap: 8px`.
- **Primary (Antique Brass on Paper):** `background: var(--primary)` / `color: var(--on-primary)` / `padding: 10px 18px` (md) or `7px 12px` (sm). Hover → `var(--primary-hover)`. Loading shows spinner in `rgba(255,255,255,0.4)` track.
- **Secondary:** `background: var(--surface)` / `border: 1px solid var(--border-strong)` / `color: var(--text)` / Hover → `var(--surface-hover)`.
- **Danger:** `background: var(--danger-soft)` / `color: var(--danger)` / Hover → `var(--danger)` on white.
- **Ghost:** `background: transparent` / `color: var(--text-muted)` / Hover → `var(--surface-2)` + `var(--text)`.
- Focus everywhere: `outline: 2px solid var(--focus)` `offset: 2px`.
### Chips / Badges
- **Style:** Pill (999px), `padding: 3px 10px`, `font-size: 12px` / `weight: 650`, neutral uses `var(--surface-2)` + `var(--border)`, semantic variants use `<role>-soft` washes.
### Cards / Containers
- **Corner Style:** Soft square (14px).
- **Background:** `var(--surface)` (or translucent on glass).
- **Shadow Strategy:** Paper Lift by default; see Elevation.
- **Border:** `1px solid var(--border)` (active profile card upgrades to `var(--success)`).
- **Internal Padding:** `18px` (profile card) / `20px` (card-body).
### Inputs / Fields
- **Style:** `background: var(--surface)` / `border: 1px solid var(--border-strong)` / `radius: 9px` / `padding: 10px 12px` / `font: inherit`.
- **Focus:** `border-color: var(--focus)` + `box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 22%, transparent)`.
- **Error:** `.field-error` in `var(--danger)` 13px / 500.
- **Hint:** `.hint` in `var(--text-muted)` 13px.
### Navigation (Sidebar)
- **Style:** Sidebar `248px`, `background: var(--surface)`, `border-right: 1px solid var(--border)`. Brand block with `38px` white tile (inverted `filter: invert(1)` on dark/neon/glass).
- **Nav item:** `padding: 11px 12px` / `radius: 9px` / `font-weight: 550` / `color: var(--text-muted)` → hover `var(--surface-2)` + `var(--text)` → active `var(--primary-soft)` + `var(--primary)`.
- **Footer:** Profile row + top border `1px solid var(--border)`.
### Undo Bar (Signature Component)
- **Shape:** Compact square (`8px`), `padding: 10px 14px`, `border: 1px solid var(--border)`, `background: var(--primary-soft)`.
- **Character:** Warm, not alarming — confirms deletion without shouting. Label in `var(--text)` 500, dash in `var(--text-muted)`, action as themed link.
- **Action:** Inline `<button>` styled as link (`background: none` / `color: var(--primary)` / `font-weight: 600` / `underline` `2px` offset). Auto-dismisses after 5s; restores via `undoDelete`. Same token treatment on all themes.
## Do's and Don'ts
### Do:
- **Do** keep Warm Ivory (`#f6f4f0`) as the only page canvas on light; let Paper and Soft Stone carry the hierarchy.
- **Do** use Muted Sage wash only for successful security confirmation (active badge, save success).
- **Do** keep one primary per screen — Antique Brass is rare by design; secondary actions stay ghost or secondary.
- **Do** use `var(--focus)` (same as primary) for every focus ring; keep the 2px + 2px offset invariant.
- **Do** preserve the side-by-side rate: cards on Paper with 1px Line borders before adding any shadow.
- **Do** let glass blur inherit radii; never change corner size just for translucency.
### Don't:
- **Don't** use Signal Coral outside error/destructive states — never for decorative accents or neutral badges.
- **Don't** introduce a new accent hue outside the four semantic washes (primary/success/warning/danger/info); add a new feature with existing tokens.
- **Don't** bump heading weight beyond 650 or add bold to muted text — shift color to Ink Muted instead.
- **Don't** apply neon glow (pink `0 0 32px rgba(255,20,147,0.12)`) outside `data-theme='neon'` modals.
- **Don't** invent a new radius — step on `9px` / `14px` / `16px` / `999px` only.
- **Don't** replace the 5s auto-dismiss undo bar with a permanent modal; the atelier stays calm after a task.

47
PRODUCT.md Normal file
View file

@ -0,0 +1,47 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
Primary: Linux Nostr users (daily use) who manage one or more keypairs and need to publish securely without exposing secrets. Situation: at their Linux desktop (X11/Wayland), switching profiles, composing notes, managing relays, and approving NIP-46 signing requests from other apps.
Secondary/expanded: Newcomers creating their first Nostr identity via a friendly GUI. The product is progressive — zero-to-first-profile onboarding is frictionless, but the same vault scales to power workflows (multiple profiles, CLI, remote signer). Success means the user can create, select, and publish as any profile, keep keys encrypted at rest, and never feel forced to paste an `nsec` elsewhere.
## Product Purpose
Nostr Keynctr (Nostr Feed Manager) pairs a hardened Rust core with an Electron + React desktop shell so private keys never leave the machine. It makes self-custodied Nostr publishing practical: generate/switch profiles, compose with preview and rich attachments, publish with per-relay receipts, curate relays and feeds, and serve as a NIP-46 remote signer ("bunker") for other Nostr apps. Success is a trustworthy, local-first identity manager you can use daily, via GUI or the same Rust CLI.
## Positioning
**Keys never leave the machine — and the architecture proves it.** The renderer never receives secret material; all key generation, signing, relay communication, and encryption happen inside the Rust backend over a JSON-lines IPC channel, with NIP-46 approval gating every external sign/decrypt request. A neighboring app could copy features, but cannot truthfully copy this verifiable separation while offering the same dual CLI + GUI surface.
## Operating Context
Workflows: create/switch/delete/undo profiles, compose (Write/Preview, character count, up to 3 link previews, image pick → nostr.build upload with NIP-92 imeta), publish with per-relay receipts, feed aggregation (all vs. My contacts, 24h window), relay add/remove/enable/disable/test, NIP-05 assignment, secret reveal after unlock, vault backup, lock/unlock.
Environments: Linux desktop (Electron 33), Rust release backend (`target/release/keynectr serve` via stdio), Vite dev server optionally via `NOSTR_GUI_DEV_URL`. Data directory `~/.local/share/nost-feed-manager` (override `XDG_DATA_HOME`), vault `profiles_vault.json` (migrated from legacy Python CLI with timestamped backup), settings file alongside.
Rituals: single-click Approve/Reject for NIP-46 requests; explicit `window.confirm` for delete; 5s themed undo bar; publish confirmation toggle; per-relay status dots.
## Capabilities and Constraints
Capabilities: profile create/switch/rename/set-picture/set-nip05/publish metadata; note publish with image imeta + link cards; feed aggregation and contact filtering; relay management and latency test; encrypted vault (AES-256-GCM + Argon2id), lock/unlock, set/remove password, reveal secret (hex + nsec); NIP-46 bunker; settings (theme light/dark/glass/neon, confirm_before_publish, shorten_npub); vault backup; CLI parity via same Rust crate.
Constraints (durable): local-only vault — no cloud sync; secrets encrypted at rest, derived key in-memory only and zeroized on lock/drop; renderer never sees secrets; dialogs/HTTP/media handled in Electron main, not renderer; files 0600, dirs 0700; NIP-98 auth for uploads; passwords via `NFM_PASSWORD` or prompt, never CLI args; CSP (`app://` prod vs devserver).
Decisions left open: whether undo_history should preserve full `StoredProfile` (currently `ProfileSummary` → secret lost on undo); long-term packaging/distribution; relay defaults.
## Brand Commitments
Name: Nostr Keynctr / Nostr Feed Manager (early beta v0.1.0, MIT, Forgejo-hosted). Voice: friendly, security-explicit, no hype. Assets: `public/icon.png`, sidebar logo, `KeynectrAppIconPossibility02.jpeg` (untracked concept). No fabricated testimonials or benchmarks; existing copy in `README.md`, empty states, and settings descriptions is authoritative.
## Evidence on Hand
Real content: Rust crate (`src/app, vault, crypto, profiles, publish, relays, signer, feed`), React screens (`Compose, Home, Profiles, Relays, Settings, Signer`), `frontend/src/lib/types`, `AppProvider` context, `fakeBackend` Vitest suite (99 tests), `CHECKPOINT-encryption.md`. No marketing site or pricing; no external testimonials to preserve.
## Product Principles
1. **Keys never leave** — every feature must preserve the Rust/renderer boundary and approval gates; convenience never bypasses explicit consent.
2. **Local-first, verifiable** — encrypt at rest, least-privilege files, per-relay receipts, and auditable IPC over transient convenience.
3. **Progressive disclosure** — newcomer can succeed in two clicks; power user can stay in CLI or manage many profiles without UI churn.
4. **One core, two doors** — GUI and CLI remain interchangeable via the same Rust engine; no feature lives only in one surface without justification.
5. **Calm trust** — theming (light/dark/glass/neon) and microcopy earn confidence through precision, not loudness; undo and receipts make errors recoverable.
## Accessibility & Inclusion
Linux desktop accessibility baseline (keyboard-navigable, focus-visible `var(--focus)`, screen-reader roles on status/alerts). No product-specific a11y standard declared; future work should keep contrast per theme tokens and ensure dialogs are dismissible via keyboard.

View file

@ -0,0 +1,267 @@
import { useState } from 'react';
import { Avatar } from './Avatar';
import { Button } from './Button';
import { ErrorText } from './ErrorText';
import { Icon } from './Icon';
import { Modal } from './Modal';
import { useApp } from '../state/AppProvider';
type Tab = 'name' | 'picture' | 'nip05';
interface ProfileEditModalProps {
open: boolean;
onClose: () => void;
target: { npub: string; label: string; picture?: string | null; nip05?: string | null };
initialTab?: Tab;
onSaved: (message: string) => void;
onError: (message: string | null) => void;
}
function nip05Problem(value: string): string | null {
const trimmed = value.trim().toLowerCase();
if (!trimmed) return null;
const at = trimmed.indexOf('@');
if (at <= 0 || at === trimmed.length - 1) return 'Use the form name@domain.com.';
const [local, domain] = [trimmed.slice(0, at), trimmed.slice(at + 1)];
if (local !== '_' && !/^[a-z0-9_-]+$/.test(local))
return 'The part before @ may only use letters, numbers, dashes and underscores.';
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain))
return 'The part after @ must be a domain like example.com.';
return null;
}
export function ProfileEditModal({
open,
onClose,
target,
initialTab = 'name',
onSaved,
onError,
}: ProfileEditModalProps) {
const { renameProfile, setProfilePicture, setNip05, pickImages, uploadImage } = useApp();
const [tab, setTab] = useState<Tab>(initialTab);
const [label, setLabel] = useState(target.label);
const [pictureUrl, setPictureUrl] = useState(target.picture ?? '');
const [nip05, setNip05Value] = useState(target.nip05 ?? '');
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const trimmedLabel = label.trim();
const canSaveName = trimmedLabel.length > 0 && trimmedLabel !== target.label;
const canSavePicture = /^https?:\/\//.test(pictureUrl.trim());
const nipProblem = nip05Problem(nip05);
const canSaveNip = nip05.trim().toLowerCase() !== (target.nip05 ?? '') && !nipProblem;
const handleSaveName = async () => {
if (!canSaveName) return;
onError(null);
setSaving(true);
try {
const report = await renameProfile(target.npub, trimmedLabel);
onSaved(
report.failed.length === 0
? `Renamed to "${trimmedLabel}" and published to ${report.succeeded.length} relay(s).`
: `Renamed to "${trimmedLabel}", but ${report.failed.length} relay(s) did not accept it.`,
);
onClose();
} catch (e) {
onError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const handleSavePicture = async (next: string | null) => {
onError(null);
setSaving(true);
try {
const report = await setProfilePicture(target.npub, next);
onSaved(
report.failed.length === 0
? `Picture for "${target.label}" published to ${report.succeeded.length} relay(s).`
: `Picture saved, but ${report.failed.length} relay(s) did not accept it.`,
);
onClose();
} catch (e) {
onError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const handleSaveNip = async (next: string | null) => {
onError(null);
setSaving(true);
try {
const report = await setNip05(target.npub, next);
onSaved(
report.failed.length === 0
? next
? `NIP-05 "${next}" published to ${report.succeeded.length} relay(s).`
: 'NIP-05 removed and the change published.'
: `NIP-05 saved, but ${report.failed.length} relay(s) did not accept it.`,
);
onClose();
} catch (e) {
onError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const onUpload = async () => {
onError(null);
setUploading(true);
try {
const picked = await pickImages();
if (picked.length === 0) return;
const uploaded = await uploadImage(picked[0].token);
setPictureUrl(uploaded.url);
} catch (e) {
onError(e instanceof Error ? e.message : String(e));
} finally {
setUploading(false);
}
};
return (
<Modal open={open} title={`Edit profile — ${target.label}`} onClose={onClose}>
{/* Paper Lift modal — matches .impeccable/design.json modal-profile-edit */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
<Avatar
npub={target.npub}
label={target.label}
size="lg"
picture={canSavePicture ? pictureUrl : (target.picture ?? null)}
/>
<div>
<div style={{ fontWeight: 650 }}>{target.label}</div>
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '12px' }}>
{target.npub}
</div>
</div>
</div>
<p className="muted" style={{ margin: 0, fontSize: '13px' }}>
Public npub is visible to relays; private key never leaves Rust.
</p>
<div className="segmented" role="tablist">
{(['name', 'picture', 'nip05'] as const).map((t) => (
<button
key={t}
role="tab"
aria-selected={tab === t}
className={`segmented-btn ${tab === t ? 'is-active' : ''}`}
onClick={() => setTab(t)}
>
{t === 'name' ? 'Name' : t === 'picture' ? 'Picture' : 'NIP-05'}
</button>
))}
</div>
{tab === 'name' && (
<div className="field">
<label htmlFor="profile-edit-name">Profile name</label>
<input
id="profile-edit-name"
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="My Profile"
autoFocus
/>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void handleSaveName()}
loading={saving}
disabled={!canSaveName}
>
Save & publish
</Button>
</div>
</div>
)}
{tab === 'picture' && (
<div className="field">
<label htmlFor="profile-edit-picture">Picture URL</label>
<input
id="profile-edit-picture"
type="text"
value={pictureUrl}
onChange={(e) => setPictureUrl(e.target.value)}
placeholder="https://…/avatar.png"
/>
<Button variant="ghost" onClick={() => void onUpload()} loading={uploading}>
<Icon name="plus" size={14} /> Upload an image instead
</Button>
{nipProblem && <ErrorText>{nipProblem}</ErrorText>}
<div className="modal-actions">
{target.picture && (
<Button
variant="danger"
onClick={() => void handleSavePicture(null)}
disabled={saving || uploading}
>
Remove picture
</Button>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void handleSavePicture(pictureUrl.trim())}
loading={saving}
disabled={!canSavePicture}
>
Save & publish
</Button>
</div>
</div>
)}
{tab === 'nip05' && (
<div className="field">
<label htmlFor="profile-edit-nip05">NIP-05 address</label>
<input
id="profile-edit-nip05"
type="text"
value={nip05}
onChange={(e) => setNip05Value(e.target.value)}
placeholder="name@domain.com"
/>
{nipProblem && nip05.trim() && <ErrorText>{nipProblem}</ErrorText>}
<p className="muted" style={{ fontSize: '13px' }}>
Also serve <code>/.well-known/nostr.json</code> on your domain — see{' '}
<code>docs/NIP-05.md</code>.
</p>
<div className="modal-actions">
{target.nip05 && (
<Button variant="danger" onClick={() => void handleSaveNip(null)} disabled={saving}>
Remove
</Button>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void handleSaveNip(nip05.trim() ? nip05.trim().toLowerCase() : null)}
loading={saving}
disabled={!canSaveNip}
>
Save & publish
</Button>
</div>
</div>
)}
</div>
</Modal>
);
}

View file

@ -1,4 +1,4 @@
export type Theme = 'light' | 'dark' | 'glass' | 'neon'; export type Theme = 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark';
/** Lifecycle of the NIP-46 remote signer. */ /** Lifecycle of the NIP-46 remote signer. */
export type SignerPhase = 'stopped' | 'connecting' | 'connected'; export type SignerPhase = 'stopped' | 'connecting' | 'connected';

View file

@ -7,6 +7,7 @@ import { EmptyState } from '../components/EmptyState';
import { ErrorText } from '../components/ErrorText'; import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { Modal } from '../components/Modal'; import { Modal } from '../components/Modal';
import { ProfileEditModal } from '../components/ProfileEditModal';
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal'; import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
import { formatDate, shortenNpub } from '../lib/format'; import { formatDate, shortenNpub } from '../lib/format';
import type { MetadataPublishReport } from '../lib/types'; import type { MetadataPublishReport } from '../lib/types';
@ -34,6 +35,12 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
const [pictureTarget, setPictureTarget] = useState<PictureTarget | null>(null); const [pictureTarget, setPictureTarget] = useState<PictureTarget | null>(null);
const [renameTarget, setRenameTarget] = useState<{ npub: string; label: string } | null>(null); const [renameTarget, setRenameTarget] = useState<{ npub: string; label: string } | null>(null);
const [nip05Target, setNip05Target] = useState<Nip05Target | null>(null); const [nip05Target, setNip05Target] = useState<Nip05Target | null>(null);
const [editTarget, setEditTarget] = useState<{
npub: string;
label: string;
picture?: string | null;
nip05?: string | null;
} | null>(null);
const profiles = state?.profiles ?? []; const profiles = state?.profiles ?? [];
const shorten = state?.settings.shorten_npub ?? true; const shorten = state?.settings.shorten_npub ?? true;
@ -244,6 +251,20 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
> >
<Icon name="publish" size={14} /> Publish name <Icon name="publish" size={14} /> Publish name
</Button> </Button>
<Button
variant="secondary"
size="sm"
onClick={() =>
setEditTarget({
npub: profile.npub,
label: profile.label,
picture: profile.picture,
nip05: profile.nip05,
})
}
>
<Icon name="edit" size={14} /> Edit profile
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -360,6 +381,19 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
onSavingChange={setPublishing} onSavingChange={setPublishing}
/> />
)} )}
{editTarget && (
<ProfileEditModal
open={editTarget !== null}
target={editTarget}
onClose={() => setEditTarget(null)}
onSaved={(message) => {
setNotice(message);
setEditTarget(null);
}}
onError={setError}
/>
)}
</div> </div>
</div> </div>
); );

View file

@ -106,10 +106,85 @@ export function SettingsScreen() {
<option value="dark">Dark</option> <option value="dark">Dark</option>
<option value="glass">Aurora</option> <option value="glass">Aurora</option>
<option value="neon">Neon</option> <option value="neon">Neon</option>
<option value="impeccable">Impeccable — Light</option>
<option value="impeccable-dark">Impeccable — Dark</option>
</select> </select>
<div
aria-hidden="true"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginTop: '10px',
}}
>
<span
title="background"
style={{
width: 22,
height: 22,
borderRadius: 4,
background: 'var(--bg)',
border: '1px solid var(--border)',
}}
/>
<span
title="surface"
style={{
width: 22,
height: 22,
borderRadius: 4,
background: 'var(--surface)',
border: '1px solid var(--border)',
}}
/>
<span
title="primary"
style={{
width: 22,
height: 22,
borderRadius: 4,
background: 'var(--primary)',
border: '1px solid var(--border)',
}}
/>
<span
title="border"
style={{
width: 22,
height: 22,
borderRadius: 4,
background: 'var(--surface-2)',
border: '1px solid var(--border-strong)',
}}
/>
<span
title="text"
style={{
width: 22,
height: 22,
borderRadius: 4,
background: 'var(--text)',
border: '1px solid var(--border)',
}}
/>
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 4 }}>
{settings.theme === 'impeccable'
? 'Impeccable — warm ivory, charcoal, gold'
: settings.theme === 'impeccable-dark'
? 'Impeccable Dark — lacquer, champagne, gold'
: settings.theme === 'neon'
? 'Neon — black + Cyber Pink'
: settings.theme === 'glass'
? 'Aurora — frosted glass, cyan'
: settings.theme === 'dark'
? 'Dark — deep ink'
: 'Light — warm paper'}
</span>
</div>
<p className="hint"> <p className="hint">
“Light” and “Dark” follow your manual selection. “Aurora” and “Neon” are aesthetic “Light” and “Dark” follow your manual selection. “Aurora”, “Neon”, “Impeccable” and
themes with distinctive color palettes. “Impeccable Dark” are aesthetic themes with distinctive color palettes.
</p> </p>
</div> </div>
</div> </div>

View file

@ -165,6 +165,189 @@ html[data-theme='glass'] .modal-backdrop {
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
} }
/* "Impeccable" — verbatim editorial system from https://impeccable.style
(light variant): lacquer/kinpaku/patina tokens, Alumni Sans display. */
:root[data-theme='impeccable'] {
--bg: oklch(97% 0.012 95);
--surface: oklch(99% 0.008 95);
--surface-2: oklch(91% 0.012 95);
--surface-hover: oklch(92% 0.014 95);
--border: oklch(25% 0.02 95 / 0.16);
--border-strong: oklch(25% 0.02 95 / 0.42);
--text: oklch(25% 0.018 95);
--text-muted: oklch(45% 0.015 95);
--primary: oklch(77% 0.13 82);
--primary-hover: oklch(61% 0.085 78);
--primary-soft: oklch(77% 0.13 82 / 0.14);
--on-primary: oklch(14% 0.018 95);
--danger: oklch(52% 0.16 35);
--danger-soft: oklch(97% 0.012 95);
--warning: oklch(61% 0.085 78);
--warning-soft: oklch(77% 0.13 82 / 0.12);
--success: oklch(49% 0.08 188);
--success-soft: oklch(70% 0.12 188 / 0.12);
--info: oklch(49% 0.08 188);
--info-soft: oklch(70% 0.12 188 / 0.08);
--focus: oklch(77% 0.13 82);
--shadow: 0 2px 6px oklch(25% 0.02 95 / 0.1);
--shadow-modal: 0 10px 28px oklch(25% 0.02 95 / 0.16);
--radius: 8px;
--radius-sm: 3px;
}
/* Impeccable editorial typography — Alumni Sans / Albert Sans like the site */
html[data-theme='impeccable'] h1,
html[data-theme='impeccable'] h2 {
font-family: 'Alumni Sans', 'Albert Sans', 'Avenir Next', 'Helvetica Neue', Arial, sans-serif;
letter-spacing: -0.01em;
font-weight: 300;
text-transform: none;
}
html[data-theme='impeccable'] h1 {
font-size: 32px;
line-height: 1.05;
font-weight: 700;
}
html[data-theme='impeccable'] h2 {
font-size: 18px;
line-height: 1.2;
}
/* Small uppercase metadata labels */
html[data-theme='impeccable'] .field-label,
html[data-theme='impeccable'] .field > label,
html[data-theme='impeccable'] .info-list dt,
html[data-theme='impeccable'] .path-row .field-label,
html[data-theme='impeccable'] .link-preview-site {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
font-weight: 650;
color: var(--text-muted);
}
/* Impeccable component refinements — editorial, restrained */
html[data-theme='impeccable'] .btn-primary {
background: var(--primary);
color: var(--on-primary);
}
html[data-theme='impeccable'] .btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
}
html[data-theme='impeccable'] .btn-secondary {
background: var(--surface);
border-color: var(--border-strong);
}
html[data-theme='impeccable'] .nav-item.is-active {
background: transparent;
color: var(--primary);
border-left: 3px solid var(--primary);
border-radius: 0 4px 4px 0;
}
html[data-theme='impeccable'] .card,
html[data-theme='impeccable'] .profile-card,
html[data-theme='impeccable'] .relay-row,
html[data-theme='impeccable'] .empty-state {
border-width: 1px;
box-shadow: var(--shadow);
}
html[data-theme='impeccable'] .empty-state {
border-style: solid;
}
html[data-theme='impeccable'] code,
html[data-theme='impeccable'] .mono {
background: var(--surface-2);
border-color: var(--border);
}
html[data-theme='impeccable'] .badge {
border-radius: 4px;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
html[data-theme='impeccable'] input[type='text'],
html[data-theme='impeccable'] input[type='search'],
html[data-theme='impeccable'] input[type='password'],
html[data-theme='impeccable'] select,
html[data-theme='impeccable'] textarea {
background: var(--surface);
}
html[data-theme='impeccable'] .profile-card.is-active {
border-color: var(--primary);
}
/* Offset decorative detail — subtle asymmetrical accent on cards */
html[data-theme='impeccable'] .profile-card {
position: relative;
}
html[data-theme='impeccable'] .profile-card::before {
content: '';
position: absolute;
top: 14px;
left: -1px;
width: 3px;
height: 24px;
background: var(--primary);
border-radius: 0 2px 2px 0;
opacity: 0;
transition: opacity 0.15s ease;
}
html[data-theme='impeccable'] .profile-card:hover::before,
html[data-theme='impeccable'] .profile-card.is-active::before {
opacity: 1;
}
/* Impeccable Dark — same kinpaku system, lacquer-deep base from the site's dark default */
:root[data-theme='impeccable-dark'] {
--bg: oklch(15% 0.006 95);
--surface: oklch(20% 0.006 95);
--surface-2: oklch(25% 0.008 95);
--surface-hover: oklch(30% 0.008 95);
--border: oklch(78% 0 0 / 0.22);
--border-strong: oklch(80% 0 0 / 0.5);
--text: oklch(88% 0 0);
--text-muted: oklch(72% 0 0);
--primary: oklch(84% 0.19 80.46);
--primary-hover: oklch(77% 0.13 82);
--primary-soft: oklch(77% 0.13 82 / 0.14);
--on-primary: oklch(14% 0.018 95);
--danger: oklch(62% 0.15 35);
--danger-soft: oklch(62% 0.15 35 / 0.18);
--warning: oklch(77% 0.13 82);
--warning-soft: oklch(77% 0.13 82 / 0.12);
--success: oklch(70% 0.12 188);
--success-soft: oklch(70% 0.12 188 / 0.12);
--info: oklch(70% 0.12 188);
--info-soft: oklch(70% 0.12 188 / 0.08);
--focus: oklch(84% 0.19 80.46);
--shadow: 0 2px 6px oklch(0% 0 0 / 0.35);
--shadow-modal: 0 10px 28px oklch(0% 0 0 / 0.45);
--radius: 8px;
--radius-sm: 3px;
color-scheme: dark;
}
html[data-theme='impeccable-dark'] h1,
html[data-theme='impeccable-dark'] h2 {
font-family: 'Alumni Sans', 'Albert Sans', 'Avenir Next', 'Helvetica Neue', Arial, sans-serif;
letter-spacing: -0.01em;
font-weight: 300;
}
html[data-theme='impeccable-dark'] .field-label,
html[data-theme='impeccable-dark'] .field > label,
html[data-theme='impeccable-dark'] .info-list dt {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
}
html[data-theme='impeccable-dark'] .card,
html[data-theme='impeccable-dark'] .profile-card,
html[data-theme='impeccable-dark'] .relay-row {
border-color: var(--border);
}
html[data-theme='impeccable-dark'] .sidebar {
background: oklch(12% 0.006 95);
border-right-color: var(--border);
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }

View file

@ -24,6 +24,9 @@ pub enum Theme {
Dark, Dark,
Glass, Glass,
Neon, Neon,
Impeccable,
#[serde(rename = "impeccable-dark")]
ImpeccableDark,
} }
impl Theme { impl Theme {
@ -34,6 +37,8 @@ impl Theme {
"dark" => Some(Self::Dark), "dark" => Some(Self::Dark),
"glass" => Some(Self::Glass), "glass" => Some(Self::Glass),
"neon" => Some(Self::Neon), "neon" => Some(Self::Neon),
"impeccable" => Some(Self::Impeccable),
"impeccable-dark" => Some(Self::ImpeccableDark),
_ => None, _ => None,
} }
} }