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:
parent
832e1144f0
commit
8366af7d86
10 changed files with 1258 additions and 3 deletions
267
frontend/src/components/ProfileEditModal.tsx
Normal file
267
frontend/src/components/ProfileEditModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue