feat(web): editorial-studio visual polish — Fraunces serif, IBM Plex mono, warm dark, ember accents
Browse files- web/index.html +6 -0
- web/src/components/DeviceBadge.tsx +7 -4
- web/src/components/HistoryList.tsx +35 -12
- web/src/components/LoadingBanner.tsx +5 -2
- web/src/components/ModelPicker.tsx +15 -16
- web/src/components/ParamsPanel.tsx +28 -15
- web/src/components/TagBar.tsx +3 -2
- web/src/components/VoiceComposer.tsx +12 -9
- web/src/components/VoiceLibrary.tsx +49 -27
- web/src/pages/Studio.tsx +122 -82
- web/src/styles/index.css +104 -22
- web/tailwind.config.ts +23 -1
web/index.html
CHANGED
|
@@ -4,6 +4,12 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>Chatterbox Voice Studio</title>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
</head>
|
| 8 |
<body>
|
| 9 |
<div id="root"></div>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>Chatterbox Voice Studio</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
|
| 11 |
+
rel="stylesheet"
|
| 12 |
+
/>
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|
web/src/components/DeviceBadge.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useEffect, useState } from "react";
|
| 2 |
|
| 3 |
export default function DeviceBadge() {
|
| 4 |
-
const [device, setDevice] = useState<string>("
|
| 5 |
useEffect(() => {
|
| 6 |
fetch("/api/health")
|
| 7 |
.then((r) => r.json())
|
|
@@ -9,8 +9,11 @@ export default function DeviceBadge() {
|
|
| 9 |
.catch(() => setDevice("offline"));
|
| 10 |
}, []);
|
| 11 |
return (
|
| 12 |
-
<
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
);
|
| 16 |
}
|
|
|
|
| 1 |
import { useEffect, useState } from "react";
|
| 2 |
|
| 3 |
export default function DeviceBadge() {
|
| 4 |
+
const [device, setDevice] = useState<string>("…");
|
| 5 |
useEffect(() => {
|
| 6 |
fetch("/api/health")
|
| 7 |
.then((r) => r.json())
|
|
|
|
| 9 |
.catch(() => setDevice("offline"));
|
| 10 |
}, []);
|
| 11 |
return (
|
| 12 |
+
<div className="flex items-center gap-2">
|
| 13 |
+
<span className="label-mono">device</span>
|
| 14 |
+
<span className="font-mono text-[12px] tracking-wider text-foreground">
|
| 15 |
+
{device}
|
| 16 |
+
</span>
|
| 17 |
+
</div>
|
| 18 |
);
|
| 19 |
}
|
web/src/components/HistoryList.tsx
CHANGED
|
@@ -6,6 +6,10 @@ type Props = {
|
|
| 6 |
onRegenerate: (h: HistoryRecord) => void;
|
| 7 |
};
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
export default function HistoryList({ refreshKey, onRegenerate }: Props) {
|
| 10 |
const [items, setItems] = useState<HistoryRecord[]>([]);
|
| 11 |
useEffect(() => {
|
|
@@ -13,24 +17,43 @@ export default function HistoryList({ refreshKey, onRegenerate }: Props) {
|
|
| 13 |
}, [refreshKey]);
|
| 14 |
|
| 15 |
if (items.length === 0) {
|
| 16 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
return (
|
| 20 |
-
<ul className="space-y-
|
| 21 |
-
{items.map((h) => {
|
| 22 |
const url = URL.createObjectURL(h.audioBlob);
|
| 23 |
return (
|
| 24 |
-
<li key={h.id} className="
|
| 25 |
-
<div className="
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</div>
|
| 29 |
-
<
|
| 30 |
-
<
|
| 31 |
-
|
| 32 |
-
<
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
</button>
|
| 35 |
</div>
|
| 36 |
</li>
|
|
|
|
| 6 |
onRegenerate: (h: HistoryRecord) => void;
|
| 7 |
};
|
| 8 |
|
| 9 |
+
function fmtTime(ts: number): string {
|
| 10 |
+
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
export default function HistoryList({ refreshKey, onRegenerate }: Props) {
|
| 14 |
const [items, setItems] = useState<HistoryRecord[]>([]);
|
| 15 |
useEffect(() => {
|
|
|
|
| 17 |
}, [refreshKey]);
|
| 18 |
|
| 19 |
if (items.length === 0) {
|
| 20 |
+
return (
|
| 21 |
+
<p className="text-sm text-muted-foreground italic">
|
| 22 |
+
Generations will be archived here.
|
| 23 |
+
</p>
|
| 24 |
+
);
|
| 25 |
}
|
| 26 |
|
| 27 |
return (
|
| 28 |
+
<ul className="space-y-3">
|
| 29 |
+
{items.map((h, i) => {
|
| 30 |
const url = URL.createObjectURL(h.audioBlob);
|
| 31 |
return (
|
| 32 |
+
<li key={h.id} className="card-paper p-3 space-y-2.5">
|
| 33 |
+
<div className="flex items-baseline justify-between gap-3">
|
| 34 |
+
<span className="marker-num">
|
| 35 |
+
{String(items.length - i).padStart(2, "0")}
|
| 36 |
+
</span>
|
| 37 |
+
<span className="label-mono">
|
| 38 |
+
{h.modelId.replace("chatterbox-", "")} · {h.language ?? "—"} · {fmtTime(h.createdAt)}
|
| 39 |
+
</span>
|
| 40 |
</div>
|
| 41 |
+
<p className="text-[13px] leading-snug line-clamp-3">{h.text}</p>
|
| 42 |
+
<audio controls src={url} className="w-full h-9" />
|
| 43 |
+
<div className="flex justify-end gap-3">
|
| 44 |
+
<a
|
| 45 |
+
href={url}
|
| 46 |
+
download={`${h.id}.wav`}
|
| 47 |
+
className="label-mono hover:text-foreground transition-colors"
|
| 48 |
+
>
|
| 49 |
+
↓ download
|
| 50 |
+
</a>
|
| 51 |
+
<button
|
| 52 |
+
type="button"
|
| 53 |
+
className="label-mono hover:text-[hsl(var(--ember))] transition-colors"
|
| 54 |
+
onClick={() => onRegenerate(h)}
|
| 55 |
+
>
|
| 56 |
+
↻ regenerate
|
| 57 |
</button>
|
| 58 |
</div>
|
| 59 |
</li>
|
web/src/components/LoadingBanner.tsx
CHANGED
|
@@ -3,8 +3,11 @@ type Props = { visible: boolean; message: string };
|
|
| 3 |
export default function LoadingBanner({ visible, message }: Props) {
|
| 4 |
if (!visible) return null;
|
| 5 |
return (
|
| 6 |
-
<div className="
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
</div>
|
| 9 |
);
|
| 10 |
}
|
|
|
|
| 3 |
export default function LoadingBanner({ visible, message }: Props) {
|
| 4 |
if (!visible) return null;
|
| 5 |
return (
|
| 6 |
+
<div className="border-b border-[hsl(var(--ember))]/30 bg-[hsl(var(--ember))]/10 px-8 py-2.5">
|
| 7 |
+
<div className="flex items-center gap-3">
|
| 8 |
+
<span className="size-1.5 rounded-full bg-[hsl(var(--ember))] animate-pulse-dot" />
|
| 9 |
+
<span className="label-mono text-[hsl(var(--ember))]">{message}</span>
|
| 10 |
+
</div>
|
| 11 |
</div>
|
| 12 |
);
|
| 13 |
}
|
web/src/components/ModelPicker.tsx
CHANGED
|
@@ -9,21 +9,20 @@ type Props = {
|
|
| 9 |
|
| 10 |
export default function ModelPicker({ models, activeId, loading, onPick }: Props) {
|
| 11 |
return (
|
| 12 |
-
<
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
</select>
|
| 28 |
);
|
| 29 |
}
|
|
|
|
| 9 |
|
| 10 |
export default function ModelPicker({ models, activeId, loading, onPick }: Props) {
|
| 11 |
return (
|
| 12 |
+
<div className="flex items-center gap-2">
|
| 13 |
+
<span className="label-mono">model</span>
|
| 14 |
+
<select
|
| 15 |
+
aria-label="Model"
|
| 16 |
+
disabled={loading || models.length === 0}
|
| 17 |
+
value={activeId ?? ""}
|
| 18 |
+
onChange={(e) => onPick(e.target.value)}
|
| 19 |
+
className="rounded-sm border border-border bg-paper/60 px-2.5 py-1 font-mono text-[12px] tracking-wider focus:outline-none focus:border-[hsl(var(--ember))]/60"
|
| 20 |
+
>
|
| 21 |
+
<option value="" disabled>choose…</option>
|
| 22 |
+
{models.map((m) => (
|
| 23 |
+
<option key={m.id} value={m.id}>{m.label}</option>
|
| 24 |
+
))}
|
| 25 |
+
</select>
|
| 26 |
+
</div>
|
|
|
|
| 27 |
);
|
| 28 |
}
|
web/src/components/ParamsPanel.tsx
CHANGED
|
@@ -11,14 +11,20 @@ export default function ParamsPanel({ specs, values, onChange }: Props) {
|
|
| 11 |
onChange({ ...values, [name]: v });
|
| 12 |
}
|
| 13 |
return (
|
| 14 |
-
<div className="space-y-
|
| 15 |
{specs.map((s) => {
|
| 16 |
const id = `param-${s.name}`;
|
| 17 |
-
const current =
|
| 18 |
if (s.type === "float" || s.type === "int") {
|
|
|
|
| 19 |
return (
|
| 20 |
-
<
|
| 21 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
<input
|
| 23 |
id={id}
|
| 24 |
aria-label={s.label}
|
|
@@ -26,43 +32,50 @@ export default function ParamsPanel({ specs, values, onChange }: Props) {
|
|
| 26 |
min={s.min}
|
| 27 |
max={s.max}
|
| 28 |
step={s.step ?? 0.01}
|
| 29 |
-
value={
|
| 30 |
onChange={(e) => set(s.name, Number(e.target.value))}
|
| 31 |
-
className="w-full"
|
| 32 |
/>
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
);
|
| 36 |
}
|
| 37 |
if (s.type === "bool") {
|
| 38 |
return (
|
| 39 |
-
<label
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
<input
|
| 42 |
id={id}
|
| 43 |
aria-label={s.label}
|
| 44 |
type="checkbox"
|
| 45 |
checked={!!current}
|
| 46 |
onChange={(e) => set(s.name, e.target.checked)}
|
|
|
|
| 47 |
/>
|
| 48 |
</label>
|
| 49 |
);
|
| 50 |
}
|
| 51 |
return (
|
| 52 |
-
<
|
| 53 |
-
<
|
| 54 |
<select
|
| 55 |
id={id}
|
| 56 |
aria-label={s.label}
|
| 57 |
-
value={current
|
| 58 |
onChange={(e) => set(s.name, e.target.value)}
|
| 59 |
-
className="
|
| 60 |
>
|
| 61 |
{(s.choices ?? []).map((c) => (
|
| 62 |
<option key={c} value={c}>{c}</option>
|
| 63 |
))}
|
| 64 |
</select>
|
| 65 |
-
</
|
| 66 |
);
|
| 67 |
})}
|
| 68 |
</div>
|
|
|
|
| 11 |
onChange({ ...values, [name]: v });
|
| 12 |
}
|
| 13 |
return (
|
| 14 |
+
<div className="space-y-5">
|
| 15 |
{specs.map((s) => {
|
| 16 |
const id = `param-${s.name}`;
|
| 17 |
+
const current: unknown = values[s.name] ?? s.default;
|
| 18 |
if (s.type === "float" || s.type === "int") {
|
| 19 |
+
const n = typeof current === "number" ? current : Number(current);
|
| 20 |
return (
|
| 21 |
+
<div key={s.name} className="space-y-1.5">
|
| 22 |
+
<div className="flex items-baseline justify-between">
|
| 23 |
+
<label htmlFor={id} className="label-mono">{s.label}</label>
|
| 24 |
+
<span className="font-mono text-[12px] text-foreground tracking-wider">
|
| 25 |
+
{Number.isFinite(n) ? n.toFixed(2) : String(current)}
|
| 26 |
+
</span>
|
| 27 |
+
</div>
|
| 28 |
<input
|
| 29 |
id={id}
|
| 30 |
aria-label={s.label}
|
|
|
|
| 32 |
min={s.min}
|
| 33 |
max={s.max}
|
| 34 |
step={s.step ?? 0.01}
|
| 35 |
+
value={Number.isFinite(n) ? n : 0}
|
| 36 |
onChange={(e) => set(s.name, Number(e.target.value))}
|
| 37 |
+
className="w-full accent-[hsl(var(--ember))]"
|
| 38 |
/>
|
| 39 |
+
{s.help && (
|
| 40 |
+
<p className="text-[11px] text-muted-foreground/80 italic">{s.help}</p>
|
| 41 |
+
)}
|
| 42 |
+
</div>
|
| 43 |
);
|
| 44 |
}
|
| 45 |
if (s.type === "bool") {
|
| 46 |
return (
|
| 47 |
+
<label
|
| 48 |
+
key={s.name}
|
| 49 |
+
htmlFor={id}
|
| 50 |
+
className="flex items-center justify-between cursor-pointer"
|
| 51 |
+
>
|
| 52 |
+
<span className="label-mono">{s.label}</span>
|
| 53 |
<input
|
| 54 |
id={id}
|
| 55 |
aria-label={s.label}
|
| 56 |
type="checkbox"
|
| 57 |
checked={!!current}
|
| 58 |
onChange={(e) => set(s.name, e.target.checked)}
|
| 59 |
+
className="accent-[hsl(var(--ember))]"
|
| 60 |
/>
|
| 61 |
</label>
|
| 62 |
);
|
| 63 |
}
|
| 64 |
return (
|
| 65 |
+
<div key={s.name} className="space-y-1.5">
|
| 66 |
+
<label htmlFor={id} className="label-mono block">{s.label}</label>
|
| 67 |
<select
|
| 68 |
id={id}
|
| 69 |
aria-label={s.label}
|
| 70 |
+
value={String(current)}
|
| 71 |
onChange={(e) => set(s.name, e.target.value)}
|
| 72 |
+
className="field-input font-mono text-[12px]"
|
| 73 |
>
|
| 74 |
{(s.choices ?? []).map((c) => (
|
| 75 |
<option key={c} value={c}>{c}</option>
|
| 76 |
))}
|
| 77 |
</select>
|
| 78 |
+
</div>
|
| 79 |
);
|
| 80 |
})}
|
| 81 |
</div>
|
web/src/components/TagBar.tsx
CHANGED
|
@@ -25,13 +25,14 @@ export default function TagBar({ tags, targetRef }: Props) {
|
|
| 25 |
el.focus();
|
| 26 |
}
|
| 27 |
return (
|
| 28 |
-
<div className="flex flex-wrap gap-1.5">
|
|
|
|
| 29 |
{tags.map((t) => (
|
| 30 |
<button
|
| 31 |
key={t}
|
| 32 |
type="button"
|
| 33 |
onClick={() => insert(t)}
|
| 34 |
-
className="text-
|
| 35 |
>
|
| 36 |
{t}
|
| 37 |
</button>
|
|
|
|
| 25 |
el.focus();
|
| 26 |
}
|
| 27 |
return (
|
| 28 |
+
<div className="flex flex-wrap items-center gap-1.5">
|
| 29 |
+
<span className="label-mono mr-1">insert</span>
|
| 30 |
{tags.map((t) => (
|
| 31 |
<button
|
| 32 |
key={t}
|
| 33 |
type="button"
|
| 34 |
onClick={() => insert(t)}
|
| 35 |
+
className="font-mono text-[11px] px-2 py-0.5 rounded-sm border border-border text-muted-foreground hover:text-[hsl(var(--ember))] hover:border-[hsl(var(--ember))]/50 transition-colors"
|
| 36 |
>
|
| 37 |
{t}
|
| 38 |
</button>
|
web/src/components/VoiceComposer.tsx
CHANGED
|
@@ -52,43 +52,46 @@ export default function VoiceComposer({ onSaved }: Props) {
|
|
| 52 |
}
|
| 53 |
|
| 54 |
return (
|
| 55 |
-
<div className="space-y-
|
| 56 |
<input
|
| 57 |
type="text"
|
| 58 |
-
placeholder="
|
| 59 |
value={name}
|
| 60 |
onChange={(e) => setName(e.target.value)}
|
| 61 |
-
className="
|
| 62 |
/>
|
| 63 |
<div className="flex gap-2">
|
| 64 |
<button
|
| 65 |
type="button"
|
| 66 |
onClick={() => fileRef.current?.click()}
|
| 67 |
-
className="
|
| 68 |
>
|
| 69 |
-
|
| 70 |
</button>
|
| 71 |
<input ref={fileRef} type="file" accept="audio/*" hidden onChange={onFile} />
|
| 72 |
{recState === "recording" ? (
|
| 73 |
<button
|
| 74 |
type="button"
|
| 75 |
onClick={stopRec}
|
| 76 |
-
className="
|
| 77 |
>
|
|
|
|
| 78 |
Stop & save
|
| 79 |
</button>
|
| 80 |
) : (
|
| 81 |
<button
|
| 82 |
type="button"
|
| 83 |
onClick={startRec}
|
| 84 |
-
className="
|
| 85 |
>
|
| 86 |
-
Record
|
| 87 |
</button>
|
| 88 |
)}
|
| 89 |
</div>
|
| 90 |
{recState === "error" && (
|
| 91 |
-
<p className="text-
|
|
|
|
|
|
|
| 92 |
)}
|
| 93 |
</div>
|
| 94 |
);
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
return (
|
| 55 |
+
<div className="space-y-3">
|
| 56 |
<input
|
| 57 |
type="text"
|
| 58 |
+
placeholder="Name this voice"
|
| 59 |
value={name}
|
| 60 |
onChange={(e) => setName(e.target.value)}
|
| 61 |
+
className="field-input"
|
| 62 |
/>
|
| 63 |
<div className="flex gap-2">
|
| 64 |
<button
|
| 65 |
type="button"
|
| 66 |
onClick={() => fileRef.current?.click()}
|
| 67 |
+
className="btn-ghost flex-1"
|
| 68 |
>
|
| 69 |
+
↑ Upload
|
| 70 |
</button>
|
| 71 |
<input ref={fileRef} type="file" accept="audio/*" hidden onChange={onFile} />
|
| 72 |
{recState === "recording" ? (
|
| 73 |
<button
|
| 74 |
type="button"
|
| 75 |
onClick={stopRec}
|
| 76 |
+
className="btn-primary flex-1 !py-2 flex items-center justify-center gap-2"
|
| 77 |
>
|
| 78 |
+
<span className="size-1.5 rounded-full bg-current animate-pulse-dot" />
|
| 79 |
Stop & save
|
| 80 |
</button>
|
| 81 |
) : (
|
| 82 |
<button
|
| 83 |
type="button"
|
| 84 |
onClick={startRec}
|
| 85 |
+
className="btn-ghost flex-1"
|
| 86 |
>
|
| 87 |
+
● Record
|
| 88 |
</button>
|
| 89 |
)}
|
| 90 |
</div>
|
| 91 |
{recState === "error" && (
|
| 92 |
+
<p className="text-[11px] text-red-400 font-mono uppercase tracking-wider">
|
| 93 |
+
microphone permission denied
|
| 94 |
+
</p>
|
| 95 |
)}
|
| 96 |
</div>
|
| 97 |
);
|
web/src/components/VoiceLibrary.tsx
CHANGED
|
@@ -15,46 +15,68 @@ export default function VoiceLibrary({ selectedId, onSelect, refreshKey }: Props
|
|
| 15 |
}, [refreshKey]);
|
| 16 |
|
| 17 |
if (voices.length === 0) {
|
| 18 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
return (
|
| 22 |
<ul className="space-y-2">
|
| 23 |
-
{voices.map((v) => (
|
| 24 |
<li
|
| 25 |
key={v.id}
|
| 26 |
className={cn(
|
| 27 |
-
"
|
| 28 |
-
selectedId === v.id
|
|
|
|
|
|
|
| 29 |
)}
|
| 30 |
>
|
| 31 |
-
<
|
| 32 |
-
className="
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
>
|
| 36 |
-
<div className="font-medium">{v.name}</div>
|
| 37 |
-
<div className="text-xs text-muted-foreground">
|
| 38 |
-
{(v.durationMs / 1000).toFixed(1)}s · {v.sampleRate} Hz
|
| 39 |
-
</div>
|
| 40 |
-
</button>
|
| 41 |
-
<div className="flex items-center gap-1">
|
| 42 |
-
<button
|
| 43 |
-
type="button"
|
| 44 |
-
aria-label={v.isFavorite ? "Unfavorite" : "Favorite"}
|
| 45 |
-
onClick={() => setFavorite(v.id!, !v.isFavorite).then(() => listVoices().then(setVoices))}
|
| 46 |
-
className="text-xs px-1"
|
| 47 |
-
>
|
| 48 |
-
{v.isFavorite ? "★" : "☆"}
|
| 49 |
-
</button>
|
| 50 |
<button
|
| 51 |
type="button"
|
| 52 |
-
|
| 53 |
-
onClick={() =>
|
| 54 |
-
className="text-xs px-1 text-muted-foreground"
|
| 55 |
>
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
| 57 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
</div>
|
| 59 |
</li>
|
| 60 |
))}
|
|
|
|
| 15 |
}, [refreshKey]);
|
| 16 |
|
| 17 |
if (voices.length === 0) {
|
| 18 |
+
return (
|
| 19 |
+
<p className="text-sm text-muted-foreground italic">
|
| 20 |
+
Voices will appear here once you upload or record one.
|
| 21 |
+
</p>
|
| 22 |
+
);
|
| 23 |
}
|
| 24 |
|
| 25 |
return (
|
| 26 |
<ul className="space-y-2">
|
| 27 |
+
{voices.map((v, i) => (
|
| 28 |
<li
|
| 29 |
key={v.id}
|
| 30 |
className={cn(
|
| 31 |
+
"card-paper p-3 transition-colors",
|
| 32 |
+
selectedId === v.id
|
| 33 |
+
? "border-[hsl(var(--ember))]/60 bg-[hsl(var(--ember))]/5"
|
| 34 |
+
: "hover:border-foreground/30",
|
| 35 |
)}
|
| 36 |
>
|
| 37 |
+
<div className="flex items-start gap-3">
|
| 38 |
+
<span className="marker-num pt-0.5">
|
| 39 |
+
{String(i + 1).padStart(2, "0")}
|
| 40 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
<button
|
| 42 |
type="button"
|
| 43 |
+
className="flex-1 text-left"
|
| 44 |
+
onClick={() => onSelect(v)}
|
|
|
|
| 45 |
>
|
| 46 |
+
<div className="display-serif text-[18px] leading-tight">{v.name}</div>
|
| 47 |
+
<div className="label-mono mt-1">
|
| 48 |
+
{(v.durationMs / 1000).toFixed(1)}s · {v.sampleRate} Hz
|
| 49 |
+
</div>
|
| 50 |
</button>
|
| 51 |
+
<div className="flex items-center gap-1.5">
|
| 52 |
+
<button
|
| 53 |
+
type="button"
|
| 54 |
+
aria-label={v.isFavorite ? "Unfavorite" : "Favorite"}
|
| 55 |
+
onClick={() =>
|
| 56 |
+
setFavorite(v.id!, !v.isFavorite).then(() =>
|
| 57 |
+
listVoices().then(setVoices),
|
| 58 |
+
)
|
| 59 |
+
}
|
| 60 |
+
className={cn(
|
| 61 |
+
"text-base leading-none transition-colors",
|
| 62 |
+
v.isFavorite
|
| 63 |
+
? "text-[hsl(var(--ember))]"
|
| 64 |
+
: "text-muted-foreground hover:text-foreground",
|
| 65 |
+
)}
|
| 66 |
+
>
|
| 67 |
+
{v.isFavorite ? "★" : "☆"}
|
| 68 |
+
</button>
|
| 69 |
+
<button
|
| 70 |
+
type="button"
|
| 71 |
+
aria-label="Delete"
|
| 72 |
+
onClick={() =>
|
| 73 |
+
deleteVoice(v.id!).then(() => listVoices().then(setVoices))
|
| 74 |
+
}
|
| 75 |
+
className="text-xs text-muted-foreground hover:text-red-400 transition-colors"
|
| 76 |
+
>
|
| 77 |
+
✕
|
| 78 |
+
</button>
|
| 79 |
+
</div>
|
| 80 |
</div>
|
| 81 |
</li>
|
| 82 |
))}
|
web/src/pages/Studio.tsx
CHANGED
|
@@ -16,6 +16,20 @@ import ParamsPanel from "@/components/ParamsPanel";
|
|
| 16 |
import TagBar from "@/components/TagBar";
|
| 17 |
import VoiceComposer from "@/components/VoiceComposer";
|
| 18 |
import VoiceLibrary from "@/components/VoiceLibrary";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
export default function Studio() {
|
| 21 |
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
@@ -117,33 +131,43 @@ export default function Studio() {
|
|
| 117 |
}
|
| 118 |
|
| 119 |
return (
|
| 120 |
-
<div className="min-h-screen
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
<
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
<
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
| 134 |
</div>
|
| 135 |
</header>
|
| 136 |
|
| 137 |
<LoadingBanner
|
| 138 |
visible={loadingModel}
|
| 139 |
-
message="Loading model
|
| 140 |
/>
|
| 141 |
-
{err &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
-
<main className="
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
| 147 |
<VoiceComposer onSaved={() => setLibraryKey((k) => k + 1)} />
|
| 148 |
<VoiceLibrary
|
| 149 |
selectedId={selectedVoice?.id}
|
|
@@ -152,88 +176,97 @@ export default function Studio() {
|
|
| 152 |
/>
|
| 153 |
</div>
|
| 154 |
|
| 155 |
-
{
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
<
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
{l.label}
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
</
|
| 172 |
-
|
| 173 |
-
)}
|
| 174 |
-
|
| 175 |
-
<div className="space-y-2">
|
| 176 |
-
<label htmlFor="prompt" className="text-sm font-medium">
|
| 177 |
-
Text
|
| 178 |
-
</label>
|
| 179 |
<textarea
|
| 180 |
id="prompt"
|
| 181 |
ref={textRef}
|
| 182 |
value={text}
|
| 183 |
onChange={(e) => setText(e.target.value)}
|
| 184 |
-
rows={
|
| 185 |
-
className="
|
| 186 |
-
placeholder="
|
| 187 |
/>
|
| 188 |
<div className="flex items-center justify-between">
|
| 189 |
<TagBar tags={active?.paralinguistic_tags ?? []} targetRef={textRef} />
|
| 190 |
-
<span className="
|
| 191 |
</div>
|
| 192 |
</div>
|
| 193 |
|
|
|
|
| 194 |
{active && (
|
| 195 |
-
<div className="space-y-
|
| 196 |
-
<
|
| 197 |
<ParamsPanel specs={active.params} values={params} onChange={setParams} />
|
| 198 |
</div>
|
| 199 |
)}
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
onClick={() => onGenerate()}
|
| 204 |
-
disabled={busy || loadingModel || !text.trim()}
|
| 205 |
-
className="w-full rounded-md bg-primary text-primary-foreground py-2.5 text-sm font-medium disabled:opacity-50"
|
| 206 |
-
>
|
| 207 |
-
{busy ? "Generating…" : "Generate"}
|
| 208 |
-
</button>
|
| 209 |
-
|
| 210 |
-
{outputUrl && (
|
| 211 |
-
<div className="space-y-1">
|
| 212 |
-
<h2 className="text-sm font-medium">Output</h2>
|
| 213 |
-
<audio controls src={outputUrl} className="w-full" />
|
| 214 |
-
<a href={outputUrl} download="chatterbox.wav" className="text-xs underline">
|
| 215 |
-
download
|
| 216 |
-
</a>
|
| 217 |
-
</div>
|
| 218 |
-
)}
|
| 219 |
-
</section>
|
| 220 |
-
|
| 221 |
-
<aside className="space-y-3">
|
| 222 |
-
<div className="flex gap-1">
|
| 223 |
-
<button
|
| 224 |
-
type="button"
|
| 225 |
-
onClick={() => setTab("voices")}
|
| 226 |
-
className={`flex-1 rounded-md px-2 py-1 text-sm ${tab === "voices" ? "bg-muted" : ""}`}
|
| 227 |
-
>
|
| 228 |
-
Voices
|
| 229 |
-
</button>
|
| 230 |
<button
|
| 231 |
type="button"
|
| 232 |
-
onClick={() =>
|
| 233 |
-
|
|
|
|
| 234 |
>
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
</div>
|
| 238 |
{tab === "voices" ? (
|
| 239 |
<VoiceLibrary
|
|
@@ -246,6 +279,13 @@ export default function Studio() {
|
|
| 246 |
)}
|
| 247 |
</aside>
|
| 248 |
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
</div>
|
| 250 |
);
|
| 251 |
}
|
|
|
|
| 16 |
import TagBar from "@/components/TagBar";
|
| 17 |
import VoiceComposer from "@/components/VoiceComposer";
|
| 18 |
import VoiceLibrary from "@/components/VoiceLibrary";
|
| 19 |
+
import { cn } from "@/lib/utils";
|
| 20 |
+
|
| 21 |
+
function SectionHeader({ num, title, hint }: { num: string; title: string; hint?: string }) {
|
| 22 |
+
return (
|
| 23 |
+
<div className="space-y-1">
|
| 24 |
+
<div className="flex items-baseline gap-3">
|
| 25 |
+
<span className="marker-num">{num}</span>
|
| 26 |
+
<h2 className="display-serif text-[22px] leading-tight">{title}</h2>
|
| 27 |
+
</div>
|
| 28 |
+
{hint && <p className="label-mono">{hint}</p>}
|
| 29 |
+
<div className="rule-dotted mt-2" />
|
| 30 |
+
</div>
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
|
| 34 |
export default function Studio() {
|
| 35 |
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
|
|
| 131 |
}
|
| 132 |
|
| 133 |
return (
|
| 134 |
+
<div className="min-h-screen relative-z animate-fade-up">
|
| 135 |
+
{/* Header */}
|
| 136 |
+
<header className="border-b border-border">
|
| 137 |
+
<div className="mx-auto max-w-[1280px] px-8 py-5 flex items-end justify-between">
|
| 138 |
+
<div className="flex items-end gap-4">
|
| 139 |
+
<span className="display-serif text-[34px] leading-none">Chatterbox</span>
|
| 140 |
+
<span className="label-mono pb-1">voice studio · v0.1</span>
|
| 141 |
+
</div>
|
| 142 |
+
<div className="flex items-center gap-6">
|
| 143 |
+
<ModelPicker
|
| 144 |
+
models={models}
|
| 145 |
+
activeId={activeId}
|
| 146 |
+
loading={loadingModel || busy}
|
| 147 |
+
onPick={pickModel}
|
| 148 |
+
/>
|
| 149 |
+
<DeviceBadge />
|
| 150 |
+
</div>
|
| 151 |
</div>
|
| 152 |
</header>
|
| 153 |
|
| 154 |
<LoadingBanner
|
| 155 |
visible={loadingModel}
|
| 156 |
+
message="Loading model — first activation can take 30–60s"
|
| 157 |
/>
|
| 158 |
+
{err && (
|
| 159 |
+
<div className="border-b border-red-900/40 bg-red-950/30 px-8 py-2.5">
|
| 160 |
+
<span className="label-mono text-red-400">error</span>
|
| 161 |
+
<span className="ml-3 text-sm text-red-300/90">{err}</span>
|
| 162 |
+
</div>
|
| 163 |
+
)}
|
| 164 |
|
| 165 |
+
<main className="mx-auto max-w-[1280px] px-8 py-10 grid lg:grid-cols-[minmax(0,1fr)_400px] gap-12">
|
| 166 |
+
{/* Composer column */}
|
| 167 |
+
<section className="space-y-12">
|
| 168 |
+
{/* 01 — Voice */}
|
| 169 |
+
<div className="space-y-5">
|
| 170 |
+
<SectionHeader num="01" title="Reference voice" hint="upload, record, or pick from your library" />
|
| 171 |
<VoiceComposer onSaved={() => setLibraryKey((k) => k + 1)} />
|
| 172 |
<VoiceLibrary
|
| 173 |
selectedId={selectedVoice?.id}
|
|
|
|
| 176 |
/>
|
| 177 |
</div>
|
| 178 |
|
| 179 |
+
{/* 02 — Script */}
|
| 180 |
+
<div className="space-y-4">
|
| 181 |
+
<SectionHeader num="02" title="Script" hint="what should the voice say?" />
|
| 182 |
+
{active?.languages && active.languages.length > 1 && (
|
| 183 |
+
<div className="flex items-center gap-3">
|
| 184 |
+
<label htmlFor="lang-select" className="label-mono">language</label>
|
| 185 |
+
<select
|
| 186 |
+
id="lang-select"
|
| 187 |
+
value={language ?? ""}
|
| 188 |
+
onChange={(e) => setLanguage(e.target.value)}
|
| 189 |
+
className="field-input !w-auto font-mono text-[12px] py-1"
|
| 190 |
+
>
|
| 191 |
+
{active.languages.map((l) => (
|
| 192 |
+
<option key={l.code} value={l.code}>{l.label}</option>
|
| 193 |
+
))}
|
| 194 |
+
</select>
|
| 195 |
+
</div>
|
| 196 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
<textarea
|
| 198 |
id="prompt"
|
| 199 |
ref={textRef}
|
| 200 |
value={text}
|
| 201 |
onChange={(e) => setText(e.target.value)}
|
| 202 |
+
rows={7}
|
| 203 |
+
className="field-input font-display text-[18px] leading-relaxed"
|
| 204 |
+
placeholder="Once upon a midnight dreary, while I pondered, weak and weary…"
|
| 205 |
/>
|
| 206 |
<div className="flex items-center justify-between">
|
| 207 |
<TagBar tags={active?.paralinguistic_tags ?? []} targetRef={textRef} />
|
| 208 |
+
<span className="label-mono">{text.length} chars</span>
|
| 209 |
</div>
|
| 210 |
</div>
|
| 211 |
|
| 212 |
+
{/* 03 — Parameters */}
|
| 213 |
{active && (
|
| 214 |
+
<div className="space-y-5">
|
| 215 |
+
<SectionHeader num="03" title="Parameters" hint={active.description} />
|
| 216 |
<ParamsPanel specs={active.params} values={params} onChange={setParams} />
|
| 217 |
</div>
|
| 218 |
)}
|
| 219 |
|
| 220 |
+
{/* Generate */}
|
| 221 |
+
<div className="space-y-4 pt-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
<button
|
| 223 |
type="button"
|
| 224 |
+
onClick={() => onGenerate()}
|
| 225 |
+
disabled={busy || loadingModel || !text.trim()}
|
| 226 |
+
className="btn-primary w-full flex items-center justify-center gap-3 ember-ring"
|
| 227 |
>
|
| 228 |
+
{busy ? (
|
| 229 |
+
<>
|
| 230 |
+
<span className="size-1.5 rounded-full bg-current animate-pulse-dot" />
|
| 231 |
+
Generating
|
| 232 |
+
</>
|
| 233 |
+
) : (
|
| 234 |
+
<>Generate <span className="opacity-60">→</span></>
|
| 235 |
+
)}
|
| 236 |
</button>
|
| 237 |
+
|
| 238 |
+
{outputUrl && (
|
| 239 |
+
<div className="card-paper p-4 space-y-3">
|
| 240 |
+
<div className="flex items-baseline justify-between">
|
| 241 |
+
<span className="label-mono">latest output</span>
|
| 242 |
+
<a href={outputUrl} download="chatterbox.wav" className="label-mono hover:text-foreground">
|
| 243 |
+
↓ download
|
| 244 |
+
</a>
|
| 245 |
+
</div>
|
| 246 |
+
<audio controls src={outputUrl} className="w-full h-10" />
|
| 247 |
+
</div>
|
| 248 |
+
)}
|
| 249 |
+
</div>
|
| 250 |
+
</section>
|
| 251 |
+
|
| 252 |
+
{/* Workspace column */}
|
| 253 |
+
<aside className="space-y-5 lg:sticky lg:top-8 self-start">
|
| 254 |
+
<div className="flex border-b border-border">
|
| 255 |
+
{(["voices", "history"] as const).map((t) => (
|
| 256 |
+
<button
|
| 257 |
+
key={t}
|
| 258 |
+
type="button"
|
| 259 |
+
onClick={() => setTab(t)}
|
| 260 |
+
className={cn(
|
| 261 |
+
"flex-1 label-mono py-2 transition-colors border-b-2",
|
| 262 |
+
tab === t
|
| 263 |
+
? "text-foreground border-[hsl(var(--ember))]"
|
| 264 |
+
: "border-transparent hover:text-foreground",
|
| 265 |
+
)}
|
| 266 |
+
>
|
| 267 |
+
{t}
|
| 268 |
+
</button>
|
| 269 |
+
))}
|
| 270 |
</div>
|
| 271 |
{tab === "voices" ? (
|
| 272 |
<VoiceLibrary
|
|
|
|
| 279 |
)}
|
| 280 |
</aside>
|
| 281 |
</main>
|
| 282 |
+
|
| 283 |
+
<footer className="border-t border-border mt-16">
|
| 284 |
+
<div className="mx-auto max-w-[1280px] px-8 py-6 flex items-center justify-between">
|
| 285 |
+
<span className="label-mono">chatterbox · resemble ai</span>
|
| 286 |
+
<span className="label-mono">stateless · browser-persisted</span>
|
| 287 |
+
</div>
|
| 288 |
+
</footer>
|
| 289 |
</div>
|
| 290 |
);
|
| 291 |
}
|
web/src/styles/index.css
CHANGED
|
@@ -4,31 +4,113 @@
|
|
| 4 |
|
| 5 |
@layer base {
|
| 6 |
:root {
|
| 7 |
-
|
| 8 |
-
--
|
| 9 |
-
--
|
| 10 |
-
--muted
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
--
|
| 16 |
-
--
|
| 17 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
.dark {
|
| 20 |
-
-
|
| 21 |
-
--
|
| 22 |
-
--
|
| 23 |
-
--muted
|
| 24 |
-
--
|
| 25 |
-
--
|
| 26 |
-
--
|
| 27 |
-
--
|
| 28 |
-
--
|
| 29 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
-
body {
|
| 32 |
@apply bg-background text-foreground antialiased;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
}
|
|
|
|
| 4 |
|
| 5 |
@layer base {
|
| 6 |
:root {
|
| 7 |
+
/* parchment / warm light (unused but kept for future toggle) */
|
| 8 |
+
--background: 36 30% 96%;
|
| 9 |
+
--foreground: 25 30% 12%;
|
| 10 |
+
--muted: 36 22% 92%;
|
| 11 |
+
--muted-foreground: 25 15% 35%;
|
| 12 |
+
--border: 32 18% 82%;
|
| 13 |
+
--ring: 28 78% 52%;
|
| 14 |
+
--paper: 36 36% 98%;
|
| 15 |
+
--ink: 25 32% 10%;
|
| 16 |
+
--primary: 28 78% 52%;
|
| 17 |
+
--primary-foreground: 36 36% 98%;
|
| 18 |
+
--accent: 18 58% 38%;
|
| 19 |
+
--accent-foreground: 36 36% 98%;
|
| 20 |
+
--ember: 28 92% 60%;
|
| 21 |
+
--radius: 0.4rem;
|
| 22 |
}
|
| 23 |
.dark {
|
| 24 |
+
/* warm near-black, parchment text, single ember accent */
|
| 25 |
+
--background: 28 14% 7%;
|
| 26 |
+
--foreground: 38 28% 92%;
|
| 27 |
+
--muted: 28 10% 12%;
|
| 28 |
+
--muted-foreground: 35 14% 64%;
|
| 29 |
+
--border: 28 10% 18%;
|
| 30 |
+
--ring: 28 92% 60%;
|
| 31 |
+
--paper: 28 16% 9%;
|
| 32 |
+
--ink: 38 28% 92%;
|
| 33 |
+
--primary: 28 92% 60%;
|
| 34 |
+
--primary-foreground: 28 14% 7%;
|
| 35 |
+
--accent: 18 58% 48%;
|
| 36 |
+
--accent-foreground: 38 28% 92%;
|
| 37 |
+
--ember: 28 92% 60%;
|
| 38 |
}
|
| 39 |
+
html, body {
|
| 40 |
@apply bg-background text-foreground antialiased;
|
| 41 |
+
font-family: theme("fontFamily.sans");
|
| 42 |
+
font-feature-settings: "ss01", "cv11";
|
| 43 |
+
}
|
| 44 |
+
body {
|
| 45 |
+
background-image:
|
| 46 |
+
radial-gradient(ellipse 90% 60% at 100% 0%, hsl(28 70% 30% / 0.10), transparent 60%),
|
| 47 |
+
radial-gradient(ellipse 80% 50% at 0% 100%, hsl(18 50% 24% / 0.10), transparent 60%);
|
| 48 |
+
background-attachment: fixed;
|
| 49 |
+
}
|
| 50 |
+
/* subtle grain via CSS */
|
| 51 |
+
body::before {
|
| 52 |
+
content: "";
|
| 53 |
+
position: fixed;
|
| 54 |
+
inset: 0;
|
| 55 |
+
pointer-events: none;
|
| 56 |
+
z-index: 0;
|
| 57 |
+
opacity: 0.06;
|
| 58 |
+
mix-blend-mode: overlay;
|
| 59 |
+
background-image:
|
| 60 |
+
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.55'/></svg>");
|
| 61 |
+
}
|
| 62 |
+
::selection {
|
| 63 |
+
background: hsl(var(--ember) / 0.35);
|
| 64 |
+
color: hsl(var(--foreground));
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
@layer components {
|
| 69 |
+
.label-mono {
|
| 70 |
+
@apply font-mono text-[10.5px] tracking-widest2 uppercase text-muted-foreground;
|
| 71 |
+
}
|
| 72 |
+
.display-serif {
|
| 73 |
+
font-family: theme("fontFamily.display");
|
| 74 |
+
font-optical-sizing: auto;
|
| 75 |
+
font-variation-settings: "opsz" 144;
|
| 76 |
+
letter-spacing: -0.015em;
|
| 77 |
+
}
|
| 78 |
+
.rule-dotted {
|
| 79 |
+
background-image: radial-gradient(circle, hsl(var(--border)) 1px, transparent 1.4px);
|
| 80 |
+
background-size: 6px 1px;
|
| 81 |
+
background-repeat: repeat-x;
|
| 82 |
+
height: 1px;
|
| 83 |
+
}
|
| 84 |
+
.ember-ring:focus-visible {
|
| 85 |
+
outline: none;
|
| 86 |
+
box-shadow: 0 0 0 1px hsl(var(--background)), 0 0 0 3px hsl(var(--ember) / 0.7);
|
| 87 |
+
}
|
| 88 |
+
.btn-primary {
|
| 89 |
+
@apply font-mono uppercase tracking-widest2 text-[12px] py-3 px-4 rounded-sm
|
| 90 |
+
bg-primary text-primary-foreground transition-colors duration-200
|
| 91 |
+
hover:bg-[hsl(var(--ember))] disabled:opacity-40 disabled:cursor-not-allowed;
|
| 92 |
+
}
|
| 93 |
+
.btn-ghost {
|
| 94 |
+
@apply font-mono uppercase tracking-widest2 text-[11px] py-2 px-3 rounded-sm
|
| 95 |
+
border border-border text-muted-foreground transition-colors duration-200
|
| 96 |
+
hover:text-foreground hover:border-foreground/40;
|
| 97 |
+
}
|
| 98 |
+
.field-input {
|
| 99 |
+
@apply w-full rounded-sm border border-border bg-paper/60 px-3 py-2 text-sm
|
| 100 |
+
placeholder:text-muted-foreground focus:border-[hsl(var(--ember))]/60
|
| 101 |
+
focus:outline-none transition-colors;
|
| 102 |
+
}
|
| 103 |
+
.card-paper {
|
| 104 |
+
@apply rounded-sm border border-border bg-paper/40 backdrop-blur-[1px];
|
| 105 |
+
}
|
| 106 |
+
.marker-num {
|
| 107 |
+
@apply font-mono text-[11px] text-[hsl(var(--ember))] tracking-widest2;
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
@layer utilities {
|
| 112 |
+
.relative-z {
|
| 113 |
+
position: relative;
|
| 114 |
+
z-index: 1;
|
| 115 |
}
|
| 116 |
}
|
web/tailwind.config.ts
CHANGED
|
@@ -12,6 +12,8 @@ const config: Config = {
|
|
| 12 |
"muted-foreground": "hsl(var(--muted-foreground))",
|
| 13 |
border: "hsl(var(--border))",
|
| 14 |
ring: "hsl(var(--ring))",
|
|
|
|
|
|
|
| 15 |
primary: {
|
| 16 |
DEFAULT: "hsl(var(--primary))",
|
| 17 |
foreground: "hsl(var(--primary-foreground))",
|
|
@@ -20,6 +22,7 @@ const config: Config = {
|
|
| 20 |
DEFAULT: "hsl(var(--accent))",
|
| 21 |
foreground: "hsl(var(--accent-foreground))",
|
| 22 |
},
|
|
|
|
| 23 |
},
|
| 24 |
borderRadius: {
|
| 25 |
lg: "var(--radius)",
|
|
@@ -27,7 +30,26 @@ const config: Config = {
|
|
| 27 |
sm: "calc(var(--radius) - 4px)",
|
| 28 |
},
|
| 29 |
fontFamily: {
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
},
|
| 32 |
},
|
| 33 |
},
|
|
|
|
| 12 |
"muted-foreground": "hsl(var(--muted-foreground))",
|
| 13 |
border: "hsl(var(--border))",
|
| 14 |
ring: "hsl(var(--ring))",
|
| 15 |
+
paper: "hsl(var(--paper))",
|
| 16 |
+
ink: "hsl(var(--ink))",
|
| 17 |
primary: {
|
| 18 |
DEFAULT: "hsl(var(--primary))",
|
| 19 |
foreground: "hsl(var(--primary-foreground))",
|
|
|
|
| 22 |
DEFAULT: "hsl(var(--accent))",
|
| 23 |
foreground: "hsl(var(--accent-foreground))",
|
| 24 |
},
|
| 25 |
+
ember: "hsl(var(--ember))",
|
| 26 |
},
|
| 27 |
borderRadius: {
|
| 28 |
lg: "var(--radius)",
|
|
|
|
| 30 |
sm: "calc(var(--radius) - 4px)",
|
| 31 |
},
|
| 32 |
fontFamily: {
|
| 33 |
+
display: ['"Fraunces"', "ui-serif", "Georgia", "serif"],
|
| 34 |
+
sans: ['"IBM Plex Sans"', "ui-sans-serif", "system-ui"],
|
| 35 |
+
mono: ['"IBM Plex Mono"', "ui-monospace", "SFMono-Regular", "monospace"],
|
| 36 |
+
},
|
| 37 |
+
letterSpacing: {
|
| 38 |
+
widest2: "0.18em",
|
| 39 |
+
},
|
| 40 |
+
keyframes: {
|
| 41 |
+
"fade-up": {
|
| 42 |
+
"0%": { opacity: "0", transform: "translateY(6px)" },
|
| 43 |
+
"100%": { opacity: "1", transform: "translateY(0)" },
|
| 44 |
+
},
|
| 45 |
+
pulse_dot: {
|
| 46 |
+
"0%,100%": { opacity: "1" },
|
| 47 |
+
"50%": { opacity: "0.4" },
|
| 48 |
+
},
|
| 49 |
+
},
|
| 50 |
+
animation: {
|
| 51 |
+
"fade-up": "fade-up 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both",
|
| 52 |
+
"pulse-dot": "pulse_dot 1.6s ease-in-out infinite",
|
| 53 |
},
|
| 54 |
},
|
| 55 |
},
|