Commit ·
c4cec2e
1
Parent(s): 2949f1d
Redesign the composer as a single input shell, not loose siblings
Browse filesThe duration select and import link were separate elements next to the
textarea — the select's focus ring read as a validation error (thick
accent-colored border, mismatched height vs. the button), and "or paste
/ upload a script" was a barely-visible dotted-underline link.
Replaces it with the chat-composer pattern (ChatGPT/Claude.ai): one
bordered card containing the textarea, with a footer toolbar strip for
secondary controls — a borderless pill-style duration select, an actual
"Import script" button with an icon, and the primary CTA. Reads as a
single obvious input at a glance instead of three competing controls.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- static/app.js +112 -7
- static/index.html +21 -10
- static/styles.css +103 -40
static/app.js
CHANGED
|
@@ -29,6 +29,7 @@ const state = {
|
|
| 29 |
numSpeakers: 2,
|
| 30 |
voices: [],
|
| 31 |
voiceSelections: [null, null, null, null],
|
|
|
|
| 32 |
models: [],
|
| 33 |
examples: [],
|
| 34 |
parodyLines: [],
|
|
@@ -41,6 +42,7 @@ const el = {};
|
|
| 41 |
[
|
| 42 |
"runtimeStatus", "runtimeLabel", "aboutBtn", "aboutDialog", "closeAboutBtn",
|
| 43 |
"modelSelect", "speakerStepper", "voiceRows", "cfgScale", "cfgScaleValue",
|
|
|
|
| 44 |
"scriptPrompt", "durationSelect", "generateScriptBtn", "examplePills", "openImportBtn", "scriptGenStatus",
|
| 45 |
"scriptTitle", "scriptDuration", "turnsList", "addTurnBtn",
|
| 46 |
"generateBarMeta", "generateBtn",
|
|
@@ -120,6 +122,21 @@ state.previewAudio.addEventListener("ended", () => {
|
|
| 120 |
});
|
| 121 |
|
| 122 |
/* ---------------- Sidebar: model / speakers / voices ---------------- */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
function renderSidebar() {
|
| 124 |
el.modelSelect.innerHTML = state.models.map((m) => `<option value="${m}">${m}</option>`).join("");
|
| 125 |
|
|
@@ -137,12 +154,10 @@ function renderSidebar() {
|
|
| 137 |
dot.style.background = `var(--speaker-${i + 1})`;
|
| 138 |
|
| 139 |
const select = document.createElement("select");
|
| 140 |
-
select.innerHTML =
|
|
|
|
|
|
|
| 141 |
if (state.voiceSelections[i]) select.value = state.voiceSelections[i];
|
| 142 |
-
select.addEventListener("change", () => {
|
| 143 |
-
state.voiceSelections[i] = select.value;
|
| 144 |
-
renderTurns();
|
| 145 |
-
});
|
| 146 |
|
| 147 |
const playBtn = document.createElement("button");
|
| 148 |
playBtn.type = "button";
|
|
@@ -150,11 +165,62 @@ function renderSidebar() {
|
|
| 150 |
playBtn.textContent = "▶";
|
| 151 |
playBtn.title = "Preview voice";
|
| 152 |
playBtn.addEventListener("click", () => playVoicePreview(select.value, playBtn));
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
row.append(dot, select, playBtn);
|
| 156 |
el.voiceRows.append(row);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
el.speakerStepper.querySelectorAll("button").forEach((btn) => {
|
|
@@ -171,6 +237,7 @@ el.cfgScale.addEventListener("input", () => {
|
|
| 171 |
/* ---------------- Turn editor ---------------- */
|
| 172 |
function speakerChoiceLabel(i) {
|
| 173 |
const sel = state.voiceSelections[i];
|
|
|
|
| 174 |
return sel ? `Speaker ${i + 1} · ${sel} (${genderOf(sel)})` : `Speaker ${i + 1}`;
|
| 175 |
}
|
| 176 |
|
|
@@ -413,6 +480,15 @@ el.logToggleBtn.addEventListener("click", () => {
|
|
| 413 |
});
|
| 414 |
|
| 415 |
/* ---------------- Generate ---------------- */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
el.generateBtn.addEventListener("click", async () => {
|
| 417 |
const script = state.turns.map((t) => (t.text || "").trim()).filter(Boolean).join(" ");
|
| 418 |
if (!script) {
|
|
@@ -420,6 +496,17 @@ el.generateBtn.addEventListener("click", async () => {
|
|
| 420 |
return;
|
| 421 |
}
|
| 422 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
el.generateBtn.disabled = true;
|
| 424 |
el.generateBtn.textContent = "Generating...";
|
| 425 |
el.resultBlock.classList.remove("visible");
|
|
@@ -431,12 +518,30 @@ el.generateBtn.addEventListener("click", async () => {
|
|
| 431 |
const started = performance.now();
|
| 432 |
setStatus("connecting", nextParodyLine() || "Provisioning GPU resources...");
|
| 433 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
const payload = {
|
| 435 |
model: el.modelSelect.value,
|
| 436 |
num_speakers: state.numSpeakers,
|
| 437 |
turns: state.turns,
|
| 438 |
-
speakers: state.voiceSelections,
|
| 439 |
cfg_scale: Number(el.cfgScale.value),
|
|
|
|
|
|
|
| 440 |
};
|
| 441 |
|
| 442 |
try {
|
|
|
|
| 29 |
numSpeakers: 2,
|
| 30 |
voices: [],
|
| 31 |
voiceSelections: [null, null, null, null],
|
| 32 |
+
customVoiceFiles: [null, null, null, null],
|
| 33 |
models: [],
|
| 34 |
examples: [],
|
| 35 |
parodyLines: [],
|
|
|
|
| 42 |
[
|
| 43 |
"runtimeStatus", "runtimeLabel", "aboutBtn", "aboutDialog", "closeAboutBtn",
|
| 44 |
"modelSelect", "speakerStepper", "voiceRows", "cfgScale", "cfgScaleValue",
|
| 45 |
+
"voiceConsentRow", "voiceConsentCheckbox",
|
| 46 |
"scriptPrompt", "durationSelect", "generateScriptBtn", "examplePills", "openImportBtn", "scriptGenStatus",
|
| 47 |
"scriptTitle", "scriptDuration", "turnsList", "addTurnBtn",
|
| 48 |
"generateBarMeta", "generateBtn",
|
|
|
|
| 122 |
});
|
| 123 |
|
| 124 |
/* ---------------- Sidebar: model / speakers / voices ---------------- */
|
| 125 |
+
const CUSTOM_VOICE_VALUE = "__custom__";
|
| 126 |
+
const MAX_CUSTOM_AUDIO_BYTES = 15 * 1024 * 1024;
|
| 127 |
+
|
| 128 |
+
function isCustomVoice(i) {
|
| 129 |
+
return state.voiceSelections[i] === CUSTOM_VOICE_VALUE;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function anyCustomVoiceActive() {
|
| 133 |
+
return Array.from({ length: state.numSpeakers }, (_, i) => i).some(isCustomVoice);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function updateVoiceConsentVisibility() {
|
| 137 |
+
el.voiceConsentRow.hidden = !anyCustomVoiceActive();
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
function renderSidebar() {
|
| 141 |
el.modelSelect.innerHTML = state.models.map((m) => `<option value="${m}">${m}</option>`).join("");
|
| 142 |
|
|
|
|
| 154 |
dot.style.background = `var(--speaker-${i + 1})`;
|
| 155 |
|
| 156 |
const select = document.createElement("select");
|
| 157 |
+
select.innerHTML =
|
| 158 |
+
`<option value="${CUSTOM_VOICE_VALUE}">🎙️ Clone a voice…</option>` +
|
| 159 |
+
state.voices.map((v) => `<option value="${v.name}">${v.name} (${v.gender})</option>`).join("");
|
| 160 |
if (state.voiceSelections[i]) select.value = state.voiceSelections[i];
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
const playBtn = document.createElement("button");
|
| 163 |
playBtn.type = "button";
|
|
|
|
| 165 |
playBtn.textContent = "▶";
|
| 166 |
playBtn.title = "Preview voice";
|
| 167 |
playBtn.addEventListener("click", () => playVoicePreview(select.value, playBtn));
|
| 168 |
+
|
| 169 |
+
select.addEventListener("change", () => {
|
| 170 |
+
state.voiceSelections[i] = select.value;
|
| 171 |
+
if (select.value !== CUSTOM_VOICE_VALUE) state.customVoiceFiles[i] = null;
|
| 172 |
+
playBtn.textContent = "▶";
|
| 173 |
+
renderSidebar();
|
| 174 |
+
renderTurns();
|
| 175 |
+
});
|
| 176 |
|
| 177 |
row.append(dot, select, playBtn);
|
| 178 |
el.voiceRows.append(row);
|
| 179 |
+
|
| 180 |
+
if (isCustomVoice(i)) {
|
| 181 |
+
playBtn.hidden = true;
|
| 182 |
+
const uploadRow = document.createElement("div");
|
| 183 |
+
uploadRow.className = "custom-voice-row";
|
| 184 |
+
|
| 185 |
+
const fileLabel = document.createElement("label");
|
| 186 |
+
fileLabel.className = "btn btn-sm upload-mini-btn";
|
| 187 |
+
fileLabel.textContent = state.customVoiceFiles[i] ? state.customVoiceFiles[i].name : "Choose audio file…";
|
| 188 |
+
const fileInput = document.createElement("input");
|
| 189 |
+
fileInput.type = "file";
|
| 190 |
+
fileInput.accept = "audio/*";
|
| 191 |
+
fileInput.hidden = true;
|
| 192 |
+
fileInput.addEventListener("change", () => {
|
| 193 |
+
const file = fileInput.files[0];
|
| 194 |
+
if (!file) return;
|
| 195 |
+
if (file.size > MAX_CUSTOM_AUDIO_BYTES) {
|
| 196 |
+
alert(`That file is too large (max ${MAX_CUSTOM_AUDIO_BYTES / (1024 * 1024)} MB).`);
|
| 197 |
+
fileInput.value = "";
|
| 198 |
+
return;
|
| 199 |
+
}
|
| 200 |
+
state.customVoiceFiles[i] = file;
|
| 201 |
+
renderSidebar();
|
| 202 |
+
});
|
| 203 |
+
fileLabel.append(fileInput);
|
| 204 |
+
uploadRow.append(fileLabel);
|
| 205 |
+
|
| 206 |
+
if (state.customVoiceFiles[i]) {
|
| 207 |
+
const clearBtn = document.createElement("button");
|
| 208 |
+
clearBtn.type = "button";
|
| 209 |
+
clearBtn.className = "btn btn-sm btn-icon-only";
|
| 210 |
+
clearBtn.textContent = "✕";
|
| 211 |
+
clearBtn.title = "Remove file";
|
| 212 |
+
clearBtn.addEventListener("click", () => {
|
| 213 |
+
state.customVoiceFiles[i] = null;
|
| 214 |
+
renderSidebar();
|
| 215 |
+
});
|
| 216 |
+
uploadRow.append(clearBtn);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
el.voiceRows.append(uploadRow);
|
| 220 |
+
}
|
| 221 |
}
|
| 222 |
+
|
| 223 |
+
updateVoiceConsentVisibility();
|
| 224 |
}
|
| 225 |
|
| 226 |
el.speakerStepper.querySelectorAll("button").forEach((btn) => {
|
|
|
|
| 237 |
/* ---------------- Turn editor ---------------- */
|
| 238 |
function speakerChoiceLabel(i) {
|
| 239 |
const sel = state.voiceSelections[i];
|
| 240 |
+
if (sel === CUSTOM_VOICE_VALUE) return `Speaker ${i + 1} · Custom voice`;
|
| 241 |
return sel ? `Speaker ${i + 1} · ${sel} (${genderOf(sel)})` : `Speaker ${i + 1}`;
|
| 242 |
}
|
| 243 |
|
|
|
|
| 480 |
});
|
| 481 |
|
| 482 |
/* ---------------- Generate ---------------- */
|
| 483 |
+
function fileToBase64(file) {
|
| 484 |
+
return new Promise((resolve, reject) => {
|
| 485 |
+
const reader = new FileReader();
|
| 486 |
+
reader.onload = () => resolve(String(reader.result).split(",")[1] || "");
|
| 487 |
+
reader.onerror = () => reject(new Error(`Could not read ${file.name}`));
|
| 488 |
+
reader.readAsDataURL(file);
|
| 489 |
+
});
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
el.generateBtn.addEventListener("click", async () => {
|
| 493 |
const script = state.turns.map((t) => (t.text || "").trim()).filter(Boolean).join(" ");
|
| 494 |
if (!script) {
|
|
|
|
| 496 |
return;
|
| 497 |
}
|
| 498 |
|
| 499 |
+
for (let i = 0; i < state.numSpeakers; i += 1) {
|
| 500 |
+
if (isCustomVoice(i) && !state.customVoiceFiles[i]) {
|
| 501 |
+
alert(`Upload a voice clip for Speaker ${i + 1}, or pick a preset voice instead.`);
|
| 502 |
+
return;
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
if (anyCustomVoiceActive() && !el.voiceConsentCheckbox.checked) {
|
| 506 |
+
alert("Confirm you have the right to use each uploaded voice before generating.");
|
| 507 |
+
return;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
el.generateBtn.disabled = true;
|
| 511 |
el.generateBtn.textContent = "Generating...";
|
| 512 |
el.resultBlock.classList.remove("visible");
|
|
|
|
| 518 |
const started = performance.now();
|
| 519 |
setStatus("connecting", nextParodyLine() || "Provisioning GPU resources...");
|
| 520 |
|
| 521 |
+
let customAudio;
|
| 522 |
+
try {
|
| 523 |
+
customAudio = await Promise.all(
|
| 524 |
+
Array.from({ length: 4 }, (_, i) =>
|
| 525 |
+
i < state.numSpeakers && isCustomVoice(i) && state.customVoiceFiles[i]
|
| 526 |
+
? fileToBase64(state.customVoiceFiles[i])
|
| 527 |
+
: Promise.resolve(null)
|
| 528 |
+
)
|
| 529 |
+
);
|
| 530 |
+
} catch (error) {
|
| 531 |
+
setStatus("error", error.message);
|
| 532 |
+
el.generateBtn.disabled = false;
|
| 533 |
+
el.generateBtn.textContent = "Generate Audio";
|
| 534 |
+
return;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
const payload = {
|
| 538 |
model: el.modelSelect.value,
|
| 539 |
num_speakers: state.numSpeakers,
|
| 540 |
turns: state.turns,
|
| 541 |
+
speakers: state.voiceSelections.map((v) => (v === CUSTOM_VOICE_VALUE ? null : v)),
|
| 542 |
cfg_scale: Number(el.cfgScale.value),
|
| 543 |
+
custom_audio: customAudio,
|
| 544 |
+
voice_consent: el.voiceConsentCheckbox.checked,
|
| 545 |
};
|
| 546 |
|
| 547 |
try {
|
static/index.html
CHANGED
|
@@ -44,6 +44,12 @@
|
|
| 44 |
<button type="button" data-count="4">4</button>
|
| 45 |
</div>
|
| 46 |
<div id="voiceRows"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
</div>
|
| 48 |
|
| 49 |
<div class="side-section">
|
|
@@ -64,19 +70,24 @@
|
|
| 64 |
<main class="pane canvas">
|
| 65 |
<div class="canvas-inner">
|
| 66 |
<div class="composer">
|
| 67 |
-
<
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
<button id="generateScriptBtn" class="btn btn-ink" type="button">Write with AI</button>
|
| 75 |
</div>
|
| 76 |
-
<div class="example-chips" id="examplePills"></div>
|
| 77 |
-
<button class="chip-import" id="openImportBtn" type="button">or paste / upload a script →</button>
|
| 78 |
-
<div id="scriptGenStatus" class="transcript-meta" style="margin-top: 8px;"></div>
|
| 79 |
</div>
|
|
|
|
|
|
|
| 80 |
|
| 81 |
<div class="transcript-header">
|
| 82 |
<h2 id="scriptTitle">Untitled conversation</h2>
|
|
|
|
| 44 |
<button type="button" data-count="4">4</button>
|
| 45 |
</div>
|
| 46 |
<div id="voiceRows"></div>
|
| 47 |
+
<div id="voiceConsentRow" class="voice-consent-row" hidden>
|
| 48 |
+
<label>
|
| 49 |
+
<input type="checkbox" id="voiceConsentCheckbox" />
|
| 50 |
+
I have the right to use each uploaded voice and will disclose this audio as synthetic.
|
| 51 |
+
</label>
|
| 52 |
+
</div>
|
| 53 |
</div>
|
| 54 |
|
| 55 |
<div class="side-section">
|
|
|
|
| 70 |
<main class="pane canvas">
|
| 71 |
<div class="canvas-inner">
|
| 72 |
<div class="composer">
|
| 73 |
+
<textarea
|
| 74 |
+
id="scriptPrompt"
|
| 75 |
+
class="composer-input"
|
| 76 |
+
rows="3"
|
| 77 |
+
placeholder="Describe a scenario — a wizard and an orc debating battle strategy..."
|
| 78 |
+
></textarea>
|
| 79 |
+
<div class="composer-toolbar">
|
| 80 |
+
<div class="composer-toolbar-left">
|
| 81 |
+
<select id="durationSelect" class="toolbar-select" aria-label="Target length"></select>
|
| 82 |
+
<button class="toolbar-btn" id="openImportBtn" type="button">
|
| 83 |
+
<span aria-hidden="true">📋</span> Import script
|
| 84 |
+
</button>
|
| 85 |
+
</div>
|
| 86 |
<button id="generateScriptBtn" class="btn btn-ink" type="button">Write with AI</button>
|
| 87 |
</div>
|
|
|
|
|
|
|
|
|
|
| 88 |
</div>
|
| 89 |
+
<div class="example-chips" id="examplePills"></div>
|
| 90 |
+
<div id="scriptGenStatus" class="transcript-meta" style="margin: 6px 0 4px;"></div>
|
| 91 |
|
| 92 |
<div class="transcript-header">
|
| 93 |
<h2 id="scriptTitle">Untitled conversation</h2>
|
static/styles.css
CHANGED
|
@@ -199,6 +199,42 @@ select:focus { outline: none; border-color: var(--accent); }
|
|
| 199 |
.voice-play:hover { border-color: var(--accent); color: var(--accent); }
|
| 200 |
.voice-play.playing { background: var(--accent); border-color: var(--accent); color: #fff; }
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
.slider-row { display: flex; align-items: center; gap: 10px; }
|
| 203 |
input[type="range"] { flex: 1; accent-color: var(--accent); }
|
| 204 |
.slider-value { font-size: 0.8rem; color: var(--ink-dim); min-width: 32px; text-align: right; }
|
|
@@ -216,51 +252,88 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
|
|
| 216 |
.canvas { background: var(--cream); padding: 26px 32px 140px; position: relative; }
|
| 217 |
.canvas-inner { max-width: 720px; margin: 0 auto; }
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
.composer {
|
| 220 |
background: var(--paper);
|
| 221 |
border: 1px solid var(--border-strong);
|
| 222 |
border-radius: var(--radius);
|
| 223 |
-
padding: 16px 18px;
|
| 224 |
box-shadow: var(--shadow);
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
.composer-row { display: flex; gap: 10px; align-items: flex-end; }
|
| 228 |
-
.duration-select {
|
| 229 |
-
width: auto;
|
| 230 |
-
flex-shrink: 0;
|
| 231 |
-
background-color: var(--field);
|
| 232 |
-
background-position: right 9px center;
|
| 233 |
-
border: 1px solid var(--border-strong);
|
| 234 |
-
border-radius: var(--radius-sm);
|
| 235 |
-
padding: 10px 26px 10px 10px;
|
| 236 |
-
font-size: 0.82rem;
|
| 237 |
-
color: var(--ink-dim);
|
| 238 |
-
align-self: stretch;
|
| 239 |
}
|
| 240 |
-
.
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
border:
|
| 244 |
resize: none;
|
| 245 |
-
background:
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
font-size: 1rem;
|
| 250 |
-
line-height: 1.45;
|
| 251 |
font-family: var(--font);
|
| 252 |
color: var(--ink);
|
| 253 |
-
min-height:
|
| 254 |
max-height: 240px;
|
| 255 |
cursor: text;
|
| 256 |
}
|
| 257 |
-
.composer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
outline: none;
|
| 259 |
-
|
| 260 |
-
|
| 261 |
box-shadow: 0 0 0 3px var(--accent-soft);
|
| 262 |
}
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
.btn {
|
| 266 |
font-family: var(--font-ui);
|
|
@@ -302,16 +375,6 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
|
|
| 302 |
cursor: pointer;
|
| 303 |
}
|
| 304 |
.chip:hover { border-color: var(--accent); color: var(--accent); }
|
| 305 |
-
.chip-import {
|
| 306 |
-
font-size: 0.78rem;
|
| 307 |
-
color: var(--ink-faint);
|
| 308 |
-
background: none;
|
| 309 |
-
border: none;
|
| 310 |
-
cursor: pointer;
|
| 311 |
-
text-decoration: underline;
|
| 312 |
-
text-decoration-style: dotted;
|
| 313 |
-
padding: 5px 4px;
|
| 314 |
-
}
|
| 315 |
|
| 316 |
/* ---------------- Transcript ---------------- */
|
| 317 |
.transcript-header {
|
|
|
|
| 199 |
.voice-play:hover { border-color: var(--accent); color: var(--accent); }
|
| 200 |
.voice-play.playing { background: var(--accent); border-color: var(--accent); color: #fff; }
|
| 201 |
|
| 202 |
+
.custom-voice-row {
|
| 203 |
+
display: flex;
|
| 204 |
+
align-items: center;
|
| 205 |
+
gap: 6px;
|
| 206 |
+
margin: -4px 0 8px 17px;
|
| 207 |
+
}
|
| 208 |
+
.upload-mini-btn {
|
| 209 |
+
font-size: 0.76rem;
|
| 210 |
+
padding: 6px 10px;
|
| 211 |
+
color: var(--ink-dim);
|
| 212 |
+
cursor: pointer;
|
| 213 |
+
max-width: 190px;
|
| 214 |
+
overflow: hidden;
|
| 215 |
+
text-overflow: ellipsis;
|
| 216 |
+
white-space: nowrap;
|
| 217 |
+
display: inline-block;
|
| 218 |
+
}
|
| 219 |
+
.upload-mini-btn:hover { border-color: var(--accent); color: var(--accent); }
|
| 220 |
+
|
| 221 |
+
.voice-consent-row {
|
| 222 |
+
border: 1px solid var(--speaker-3);
|
| 223 |
+
background: rgba(184, 134, 43, 0.08);
|
| 224 |
+
border-radius: var(--radius-sm);
|
| 225 |
+
padding: 10px 12px;
|
| 226 |
+
margin-top: 4px;
|
| 227 |
+
}
|
| 228 |
+
.voice-consent-row label {
|
| 229 |
+
display: flex;
|
| 230 |
+
align-items: flex-start;
|
| 231 |
+
gap: 8px;
|
| 232 |
+
font-size: 0.78rem;
|
| 233 |
+
color: var(--ink-dim);
|
| 234 |
+
cursor: pointer;
|
| 235 |
+
}
|
| 236 |
+
.voice-consent-row input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; }
|
| 237 |
+
|
| 238 |
.slider-row { display: flex; align-items: center; gap: 10px; }
|
| 239 |
input[type="range"] { flex: 1; accent-color: var(--accent); }
|
| 240 |
.slider-value { font-size: 0.8rem; color: var(--ink-dim); min-width: 32px; text-align: right; }
|
|
|
|
| 252 |
.canvas { background: var(--cream); padding: 26px 32px 140px; position: relative; }
|
| 253 |
.canvas-inner { max-width: 720px; margin: 0 auto; }
|
| 254 |
|
| 255 |
+
/* ---- Composer: a single input "shell" (textarea + toolbar), the
|
| 256 |
+
chat-composer pattern from ChatGPT/Claude.ai — unmistakable as an
|
| 257 |
+
input at a glance, with secondary controls grouped in a footer strip
|
| 258 |
+
instead of competing with the textarea as loose sibling elements. ---- */
|
| 259 |
.composer {
|
| 260 |
background: var(--paper);
|
| 261 |
border: 1px solid var(--border-strong);
|
| 262 |
border-radius: var(--radius);
|
|
|
|
| 263 |
box-shadow: var(--shadow);
|
| 264 |
+
overflow: hidden;
|
| 265 |
+
margin-bottom: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
}
|
| 267 |
+
.composer-input {
|
| 268 |
+
display: block;
|
| 269 |
+
width: 100%;
|
| 270 |
+
border: none;
|
| 271 |
resize: none;
|
| 272 |
+
background: transparent;
|
| 273 |
+
padding: 18px 20px 8px;
|
| 274 |
+
font-size: 1.05rem;
|
| 275 |
+
line-height: 1.5;
|
|
|
|
|
|
|
| 276 |
font-family: var(--font);
|
| 277 |
color: var(--ink);
|
| 278 |
+
min-height: 100px;
|
| 279 |
max-height: 240px;
|
| 280 |
cursor: text;
|
| 281 |
}
|
| 282 |
+
.composer-input:focus { outline: none; }
|
| 283 |
+
.composer-input::placeholder { color: var(--ink-faint); font-style: italic; }
|
| 284 |
+
|
| 285 |
+
.composer-toolbar {
|
| 286 |
+
display: flex;
|
| 287 |
+
align-items: center;
|
| 288 |
+
justify-content: space-between;
|
| 289 |
+
gap: 10px;
|
| 290 |
+
padding: 9px 12px;
|
| 291 |
+
border-top: 1px solid var(--border);
|
| 292 |
+
background: var(--paper-soft);
|
| 293 |
+
}
|
| 294 |
+
.composer-toolbar-left { display: flex; align-items: center; gap: 2px; }
|
| 295 |
+
|
| 296 |
+
.toolbar-select {
|
| 297 |
+
width: auto;
|
| 298 |
+
background-color: transparent;
|
| 299 |
+
background-position: right 7px center;
|
| 300 |
+
background-size: 9px 6px;
|
| 301 |
+
border: 1px solid transparent;
|
| 302 |
+
border-radius: 999px;
|
| 303 |
+
color: var(--ink-dim);
|
| 304 |
+
font-size: 0.82rem;
|
| 305 |
+
font-weight: 600;
|
| 306 |
+
padding: 7px 24px 7px 11px;
|
| 307 |
+
}
|
| 308 |
+
.toolbar-select:hover { background-color: var(--paper); border-color: var(--border); }
|
| 309 |
+
.toolbar-select:focus {
|
| 310 |
outline: none;
|
| 311 |
+
background-color: var(--paper);
|
| 312 |
+
border-color: var(--border-strong);
|
| 313 |
box-shadow: 0 0 0 3px var(--accent-soft);
|
| 314 |
}
|
| 315 |
+
|
| 316 |
+
.toolbar-btn {
|
| 317 |
+
display: inline-flex;
|
| 318 |
+
align-items: center;
|
| 319 |
+
gap: 6px;
|
| 320 |
+
border: 1px solid transparent;
|
| 321 |
+
background: transparent;
|
| 322 |
+
color: var(--ink-dim);
|
| 323 |
+
font-size: 0.82rem;
|
| 324 |
+
font-weight: 600;
|
| 325 |
+
padding: 7px 12px;
|
| 326 |
+
border-radius: 999px;
|
| 327 |
+
cursor: pointer;
|
| 328 |
+
white-space: nowrap;
|
| 329 |
+
}
|
| 330 |
+
.toolbar-btn:hover { background: var(--paper); border-color: var(--border); color: var(--accent); }
|
| 331 |
+
|
| 332 |
+
.composer-toolbar .btn-ink {
|
| 333 |
+
border-radius: 999px;
|
| 334 |
+
padding: 9px 18px;
|
| 335 |
+
font-size: 0.84rem;
|
| 336 |
+
}
|
| 337 |
|
| 338 |
.btn {
|
| 339 |
font-family: var(--font-ui);
|
|
|
|
| 375 |
cursor: pointer;
|
| 376 |
}
|
| 377 |
.chip:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
|
| 379 |
/* ---------------- Transcript ---------------- */
|
| 380 |
.transcript-header {
|