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

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. */
export type SignerPhase = 'stopped' | 'connecting' | 'connected';

View file

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

View file

@ -106,10 +106,85 @@ export function SettingsScreen() {
<option value="dark">Dark</option>
<option value="glass">Aurora</option>
<option value="neon">Neon</option>
<option value="impeccable">Impeccable — Light</option>
<option value="impeccable-dark">Impeccable — Dark</option>
</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">
“Light” and “Dark” follow your manual selection. “Aurora” and “Neon” are aesthetic
themes with distinctive color palettes.
“Light” and “Dark” follow your manual selection. “Aurora”, “Neon”, “Impeccable” and
“Impeccable Dark” are aesthetic themes with distinctive color palettes.
</p>
</div>
</div>

View file

@ -165,6 +165,189 @@ html[data-theme='glass'] .modal-backdrop {
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;
}