Arslan1997 commited on
Commit
93dfca2
·
1 Parent(s): 36ef247

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
frontend/src/pages/VoiceProfile.tsx CHANGED
@@ -4,6 +4,7 @@ import type { Delivery, Job, LibraryVoice, Quota, Voice, VoiceProfileData, Voice
4
  import { DocPicker } from "../components/DocPicker";
5
  import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
6
  import { useJob } from "../hooks/useJob";
 
7
 
8
  type Host = "host_a" | "host_b";
9
 
@@ -460,11 +461,22 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
460
  );
461
  }
462
 
 
 
 
 
 
 
 
 
 
 
463
  export default function VoiceProfile() {
464
  const [state, setState] = useState<VoiceState | null>(null);
465
  const [draft, setDraft] = useState<VoiceProfileData | null>(null);
466
  const [voices, setVoices] = useState<Voice[]>([]);
467
  const [quota, setQuota] = useState<Quota>({});
 
468
  const [picking, setPicking] = useState(false);
469
  const [samples, setSamples] = useState<string[]>([]);
470
  const [buildJob, setBuildJob] = useState<Job | null>(null);
@@ -474,12 +486,14 @@ export default function VoiceProfile() {
474
  const [saved, setSaved] = useState(false);
475
  const [error, setError] = useState<string | null>(null);
476
  const { play, playing } = usePlayer();
 
477
 
478
  const load = () =>
479
  voiceApi.get().then((s) => {
480
  setState(s);
481
  setDraft(s.profile);
482
  setSamples(s.sample_doc_ids);
 
483
  });
484
  const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
485
 
@@ -495,9 +509,25 @@ export default function VoiceProfile() {
495
  loadVoices();
496
  });
497
 
498
- if (!state) return <Loading />;
499
  const building = build && (build.status === "queued" || build.status === "running");
500
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
 
502
  const saveProfile = async () => {
503
  setError(null);
@@ -510,143 +540,206 @@ export default function VoiceProfile() {
510
  }
511
  };
512
 
513
- const used = quota.character_count ?? 0;
514
- const limit = quota.character_limit ?? 0;
515
-
516
  return (
517
- <div className="page-wrap">
518
- <PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud">
519
- <button className="btn btn-primary" disabled={Boolean(building)} onClick={() => setPicking(true)}>
520
- {state.profile ? "Rebuild writing voice" : "Build my writing voice"}
521
- </button>
522
- </PageHeader>
523
- {build && (building || build.status === "failed") && <JobProgress job={build} />}
 
 
 
 
 
 
 
 
 
524
  {error && <p className="error-text">{error}</p>}
525
 
526
- <div className="two-col">
527
- <section className="card stack">
528
- <h2>Writing voice</h2>
529
- <p className="muted small">Launch Kits, carousels, hooks and scripts are written in this voice. Edit anything that does not sound like you.</p>
530
- {!draft && <p className="muted">No profile yet. Build one from 5 to 10 posts that feel most like you.</p>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  {draft && (
532
  <>
533
  <label className="field">
534
- <span>Summary</span>
535
  <textarea className="textarea" rows={3} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
536
  </label>
537
- <label className="field">
538
- <span>Sentence length</span>
539
- <select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}>
540
- {["short", "medium", "long", "varied"].map((v) => (
541
- <option key={v}>{v}</option>
542
- ))}
543
- </select>
544
- </label>
545
- {LIST_FIELDS.map((f) => (
546
- <label key={f.key} className="field">
547
- <span>{f.label}</span>
548
- <input
549
- className="input input-sm"
550
- placeholder={f.hint}
551
- value={(draft[f.key] as string[]).join(", ")}
552
- onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })}
553
- />
554
  </label>
555
- ))}
556
- <div className="row between">
557
- <span className="mono muted small">
558
- Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts
559
- </span>
560
- <button className="btn btn-primary btn-small" onClick={saveProfile}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  {saved ? "Saved" : "Save changes"}
562
  </button>
 
 
 
563
  </div>
564
  </>
565
  )}
566
  </section>
 
567
 
568
- <div className="stack">
569
- <section className="card stack">
570
- <div className="row between">
571
- <h2>Speaking voices</h2>
572
- {limit > 0 && (
573
- <span className="mono muted small" title="ElevenLabs characters this billing period">
574
- {used.toLocaleString()} / {limit.toLocaleString()} chars
575
- </span>
576
- )}
577
- </div>
578
- {!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
579
- <p className="muted small">
580
- Audio overviews use both hosts; videos are narrated by Host A. Model: {state.models[state.model] ?? state.model}.
581
- </p>
582
  {(["host_a", "host_b"] as Host[]).map((h) => (
583
  <HostCard key={h} host={h} state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} />
584
  ))}
585
- </section>
586
-
587
- <section className="card stack">
588
- <h2>Your own voice</h2>
589
- {!state.clone.allowed && <p className="muted">Voice cloning is on the Writer and Studio plans.</p>}
590
- {quota.can_use_instant_voice_cloning === false && (
591
- <p className="muted small">Your ElevenLabs plan does not include instant voice cloning. Upgrade it at elevenlabs.io.</p>
592
- )}
593
- {state.clone.allowed && (state.clone.status === "none" || state.clone.status === "failed") && !cloneRunning && (
594
- <>
595
- <p className="muted">
596
- Record one to three minutes of your own writing, read aloud. Your voice becomes Host A, so audio overviews and videos sound like you.
597
- </p>
598
- {state.clone.status === "failed" && state.clone.error && <p className="error-text">{state.clone.error}</p>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  <button className="btn btn-primary" onClick={() => setCloning(true)} disabled={!state.tts_configured}>
600
  Clone my voice
601
  </button>
602
- </>
603
- )}
604
- {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} />}
605
- {state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>}
606
- {state.clone.status === "ready" && (
607
- <>
608
- <p className="muted">Your voice is live and set as Host A. Adjust its delivery above.</p>
609
- <div className="row">
610
- {state.clone.preview_url && (
611
- <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
612
- {playing === "clone" ? "Stop" : "Hear my voice"}
613
- </button>
614
- )}
615
- <button className="btn btn-small" onClick={() => setCloning(true)}>
616
- Record again
617
  </button>
618
- <ConfirmButton
619
- confirmLabel="Revoke and delete my voice?"
620
- onConfirm={async () => {
621
- setState(await voiceApi.revoke());
622
- loadVoices();
623
- }}
624
- >
625
- Revoke consent
626
- </ConfirmButton>
627
- </div>
628
- </>
629
- )}
630
- </section>
631
- </div>
632
- </div>
 
 
 
 
 
 
 
 
633
 
634
  {picking && (
635
  <Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
636
  <DocPicker selected={samples} onChange={setSamples} max={10} />
637
  <p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
638
  <div className="row end">
639
- <button
640
- className="btn btn-primary"
641
- onClick={async () => {
642
- setPicking(false);
643
- try {
644
- setBuildJob(await voiceApi.build(samples));
645
- } catch (e) {
646
- setError(errorMessage(e));
647
- }
648
- }}
649
- >
650
  Build profile
651
  </button>
652
  </div>
 
4
  import { DocPicker } from "../components/DocPicker";
5
  import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
6
  import { useJob } from "../hooks/useJob";
7
+ import { useUpgrade } from "../hooks/useUpgrade";
8
 
9
  type Host = "host_a" | "host_b";
10
 
 
461
  );
462
  }
463
 
464
+ // The page: a three step flow. Writing voice first, then the two hosts, and cloning last.
465
+
466
+ type Step = "writing" | "hosts" | "clone";
467
+
468
+ const STEPS: { id: Step; label: string }[] = [
469
+ { id: "writing", label: "Writing voice" },
470
+ { id: "hosts", label: "Speaking voices" },
471
+ { id: "clone", label: "Your own voice" },
472
+ ];
473
+
474
  export default function VoiceProfile() {
475
  const [state, setState] = useState<VoiceState | null>(null);
476
  const [draft, setDraft] = useState<VoiceProfileData | null>(null);
477
  const [voices, setVoices] = useState<Voice[]>([]);
478
  const [quota, setQuota] = useState<Quota>({});
479
+ const [step, setStep] = useState<Step | null>(null);
480
  const [picking, setPicking] = useState(false);
481
  const [samples, setSamples] = useState<string[]>([]);
482
  const [buildJob, setBuildJob] = useState<Job | null>(null);
 
486
  const [saved, setSaved] = useState(false);
487
  const [error, setError] = useState<string | null>(null);
488
  const { play, playing } = usePlayer();
489
+ const { openUpgrade } = useUpgrade();
490
 
491
  const load = () =>
492
  voiceApi.get().then((s) => {
493
  setState(s);
494
  setDraft(s.profile);
495
  setSamples(s.sample_doc_ids);
496
+ setStep((cur) => cur ?? (s.profile ? "hosts" : "writing"));
497
  });
498
  const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
499
 
 
509
  loadVoices();
510
  });
511
 
512
+ if (!state || !step) return <Loading />;
513
  const building = build && (build.status === "queued" || build.status === "running");
514
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
515
+ const stepIndex = STEPS.findIndex((s) => s.id === step);
516
+ const done: Record<Step, boolean> = {
517
+ writing: Boolean(state.profile),
518
+ hosts: Boolean(state.profile) && stepIndex > 1,
519
+ clone: state.clone.status === "ready",
520
+ };
521
+
522
+ const startBuild = async (ids: string[]) => {
523
+ setPicking(false);
524
+ setError(null);
525
+ try {
526
+ setBuildJob(await voiceApi.build(ids));
527
+ } catch (e) {
528
+ setError(errorMessage(e));
529
+ }
530
+ };
531
 
532
  const saveProfile = async () => {
533
  setError(null);
 
540
  }
541
  };
542
 
 
 
 
543
  return (
544
+ <div className="page-wrap vp">
545
+ <PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud" />
546
+
547
+ <ol className="vp-steps mono" aria-label="Voice profile steps">
548
+ {STEPS.map((s, i) => (
549
+ <li key={s.id} className={s.id === step ? "current" : done[s.id] ? "done" : ""}>
550
+ <button type="button" onClick={() => setStep(s.id)} aria-current={s.id === step ? "step" : undefined}>
551
+ <span className="vp-dot" />
552
+ <span>
553
+ {i + 1}. {s.label}
554
+ {done[s.id] && s.id !== step ? " ✓" : ""}
555
+ </span>
556
+ </button>
557
+ </li>
558
+ ))}
559
+ </ol>
560
  {error && <p className="error-text">{error}</p>}
561
 
562
+ {step === "writing" && (
563
+ <section className="card stack vp-card">
564
+ <p className="eyebrow">Step 1 of 3</p>
565
+ <h2>Teach Notestack how you write</h2>
566
+ <p className="muted">Launch Kits, carousels, hooks and scripts are all written in this voice, so they read like you wrote them.</p>
567
+ {build && (building || build.status === "failed") && <JobProgress job={build} />}
568
+
569
+ {!draft && !building && (
570
+ <div className="vp-choices">
571
+ <button className="vp-choice" onClick={() => startBuild([])}>
572
+ <strong>Let Notestack choose</strong>
573
+ <span className="muted small">We read your longest posts. One click, about a minute.</span>
574
+ </button>
575
+ <button className="vp-choice" onClick={() => setPicking(true)}>
576
+ <strong>I'll pick my posts</strong>
577
+ <span className="muted small">Choose 5 to 10 posts that sound most like you.</span>
578
+ </button>
579
+ </div>
580
+ )}
581
+
582
  {draft && (
583
  <>
584
  <label className="field">
585
+ <span>In a sentence, you sound like this</span>
586
  <textarea className="textarea" rows={3} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
587
  </label>
588
+ <details className="delivery">
589
+ <summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
590
+ <label className="field">
591
+ <span>Sentence length</span>
592
+ <select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}>
593
+ {["short", "medium", "long", "varied"].map((v) => (
594
+ <option key={v}>{v}</option>
595
+ ))}
596
+ </select>
 
 
 
 
 
 
 
 
597
  </label>
598
+ {LIST_FIELDS.map((f) => (
599
+ <label key={f.key} className="field">
600
+ <span>{f.label}</span>
601
+ <input
602
+ className="input input-sm"
603
+ placeholder={f.hint}
604
+ value={(draft[f.key] as string[]).join(", ")}
605
+ onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })}
606
+ />
607
+ </label>
608
+ ))}
609
+ </details>
610
+ <p className="mono muted small">
611
+ Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts ·{" "}
612
+ <button className="link-btn small" disabled={Boolean(building)} onClick={() => setPicking(true)}>
613
+ Rebuild from other posts
614
+ </button>
615
+ </p>
616
+ <div className="row end">
617
+ <button className="btn btn-small" onClick={saveProfile}>
618
  {saved ? "Saved" : "Save changes"}
619
  </button>
620
+ <button className="btn btn-primary" onClick={() => setStep("hosts")}>
621
+ Next: pick your hosts
622
+ </button>
623
  </div>
624
  </>
625
  )}
626
  </section>
627
+ )}
628
 
629
+ {step === "hosts" && (
630
+ <section className="card stack vp-card">
631
+ <p className="eyebrow">Step 2 of 3</p>
632
+ <h2>Pick who reads your work aloud</h2>
633
+ <p className="muted">
634
+ Audio overviews are a conversation between two hosts. Videos are narrated by Host A. Press Preview to hear each one, or browse the library for more voices.
635
+ </p>
636
+ {!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
637
+ <div className="vp-hosts">
 
 
 
 
 
638
  {(["host_a", "host_b"] as Host[]).map((h) => (
639
  <HostCard key={h} host={h} state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} />
640
  ))}
641
+ </div>
642
+ <div className="row between">
643
+ <button className="btn btn-small" onClick={() => setStep("writing")}>
644
+ Back
645
+ </button>
646
+ <button className="btn btn-primary" onClick={() => setStep("clone")}>
647
+ Last step: use your own voice
648
+ </button>
649
+ </div>
650
+ </section>
651
+ )}
652
+
653
+ {step === "clone" && (
654
+ <section className="card stack vp-card">
655
+ <p className="eyebrow">Step 3 of 3</p>
656
+ <h2>Make Host A sound exactly like you</h2>
657
+
658
+ {!state.clone.allowed && (
659
+ <>
660
+ <p className="muted">Clone your voice and every audio overview and video you make is narrated by you, not a stock voice.</p>
661
+ <ol className="vp-how">
662
+ <li>
663
+ <strong>Read for 1 to 3 minutes</strong>
664
+ <span className="muted small">We give you a passage from your own writing.</span>
665
+ </li>
666
+ <li>
667
+ <strong>We build your voice</strong>
668
+ <span className="muted small">Takes a few minutes. Only you can use it.</span>
669
+ </li>
670
+ <li>
671
+ <strong>Everything sounds like you</strong>
672
+ <span className="muted small">Your voice becomes Host A across overviews and videos.</span>
673
+ </li>
674
+ </ol>
675
+ <div className="vp-upgrade">
676
+ <div>
677
+ <strong>Voice cloning is on the Writer and Studio plans</strong>
678
+ <p className="muted small">Your writing voice and hosts are all set. Upgrade whenever you want to add your own.</p>
679
+ </div>
680
+ <button className="btn btn-primary" onClick={() => openUpgrade("writer")}>
681
+ Upgrade to clone my voice
682
+ </button>
683
+ </div>
684
+ </>
685
+ )}
686
+
687
+ {state.clone.allowed && quota.can_use_instant_voice_cloning === false && (
688
+ <p className="muted small">Your ElevenLabs plan does not include instant voice cloning. Upgrade it at elevenlabs.io.</p>
689
+ )}
690
+ {state.clone.allowed && (state.clone.status === "none" || state.clone.status === "failed") && !cloneRunning && (
691
+ <>
692
+ <p className="muted">
693
+ Record one to three minutes of your own writing, read aloud. Your voice becomes Host A, so audio overviews and videos sound like you.
694
+ </p>
695
+ {state.clone.status === "failed" && state.clone.error && <p className="error-text">{state.clone.error}</p>}
696
+ <div className="row">
697
  <button className="btn btn-primary" onClick={() => setCloning(true)} disabled={!state.tts_configured}>
698
  Clone my voice
699
  </button>
700
+ </div>
701
+ </>
702
+ )}
703
+ {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} />}
704
+ {state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>}
705
+ {state.clone.status === "ready" && (
706
+ <>
707
+ <p className="muted">Your voice is live and set as Host A. Adjust its delivery in step 2.</p>
708
+ <div className="row">
709
+ {state.clone.preview_url && (
710
+ <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
711
+ {playing === "clone" ? "Stop" : "Hear my voice"}
 
 
 
712
  </button>
713
+ )}
714
+ <button className="btn btn-small" onClick={() => setCloning(true)}>
715
+ Record again
716
+ </button>
717
+ <ConfirmButton
718
+ confirmLabel="Revoke and delete my voice?"
719
+ onConfirm={async () => {
720
+ setState(await voiceApi.revoke());
721
+ loadVoices();
722
+ }}
723
+ >
724
+ Revoke consent
725
+ </ConfirmButton>
726
+ </div>
727
+ </>
728
+ )}
729
+ <div className="row">
730
+ <button className="btn btn-small" onClick={() => setStep("hosts")}>
731
+ Back
732
+ </button>
733
+ </div>
734
+ </section>
735
+ )}
736
 
737
  {picking && (
738
  <Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
739
  <DocPicker selected={samples} onChange={setSamples} max={10} />
740
  <p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
741
  <div className="row end">
742
+ <button className="btn btn-primary" onClick={() => startBuild(samples)}>
 
 
 
 
 
 
 
 
 
 
743
  Build profile
744
  </button>
745
  </div>
frontend/src/styles/pages.css CHANGED
@@ -1867,8 +1867,16 @@ select.input option {
1867
  color: var(--white);
1868
  }
1869
 
 
 
 
 
 
 
1870
  .map-controls .icon-btn.mono {
1871
  font-size: 0.65rem;
 
 
1872
  }
1873
 
1874
  .map-hint {
@@ -1881,6 +1889,132 @@ select.input option {
1881
  }
1882
 
1883
  /* Voice page */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1884
  .host-card {
1885
  display: grid;
1886
  gap: 10px;
 
1867
  color: var(--white);
1868
  }
1869
 
1870
+ .map-controls .icon-btn {
1871
+ padding: 0;
1872
+ line-height: 1;
1873
+ }
1874
+
1875
+ /* Trailing letter-spacing from .mono pushes "1:1" off centre in the round button. */
1876
  .map-controls .icon-btn.mono {
1877
  font-size: 0.65rem;
1878
+ letter-spacing: 0;
1879
+ font-variant-numeric: tabular-nums;
1880
  }
1881
 
1882
  .map-hint {
 
1889
  }
1890
 
1891
  /* Voice page */
1892
+ .vp .vp-card {
1893
+ max-width: 760px;
1894
+ animation: welcome-in 0.35s ease both;
1895
+ }
1896
+
1897
+ .vp-steps {
1898
+ list-style: none;
1899
+ margin: 0 0 20px;
1900
+ padding: 0;
1901
+ max-width: 760px;
1902
+ display: grid;
1903
+ grid-template-columns: repeat(3, 1fr);
1904
+ gap: 8px;
1905
+ font-size: 0.75rem;
1906
+ }
1907
+
1908
+ .vp-steps button {
1909
+ display: grid;
1910
+ gap: 8px;
1911
+ width: 100%;
1912
+ padding: 0;
1913
+ border: 0;
1914
+ background: none;
1915
+ color: var(--text-3);
1916
+ font: inherit;
1917
+ text-align: left;
1918
+ cursor: pointer;
1919
+ }
1920
+
1921
+ .vp-steps li.done button {
1922
+ color: var(--text-2);
1923
+ }
1924
+
1925
+ .vp-steps li.current button {
1926
+ color: var(--white);
1927
+ }
1928
+
1929
+ .vp-dot {
1930
+ height: 3px;
1931
+ border-radius: 999px;
1932
+ background: var(--line-strong);
1933
+ transition: background 0.3s, box-shadow 0.3s;
1934
+ }
1935
+
1936
+ .vp-steps li.done .vp-dot {
1937
+ background: var(--blue-60);
1938
+ }
1939
+
1940
+ .vp-steps li.current .vp-dot {
1941
+ background: var(--blue);
1942
+ box-shadow: var(--glow-sm);
1943
+ }
1944
+
1945
+ .vp-choices {
1946
+ display: grid;
1947
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1948
+ gap: 12px;
1949
+ }
1950
+
1951
+ .vp-choice {
1952
+ display: grid;
1953
+ gap: 6px;
1954
+ padding: 18px;
1955
+ border: 1px solid var(--line-strong);
1956
+ border-radius: var(--radius-sm);
1957
+ background: transparent;
1958
+ color: var(--white);
1959
+ font: inherit;
1960
+ text-align: left;
1961
+ cursor: pointer;
1962
+ transition: border-color 0.2s, box-shadow 0.2s;
1963
+ }
1964
+
1965
+ .vp-choice:hover,
1966
+ .vp-choice:focus-visible {
1967
+ border-color: var(--blue-60);
1968
+ box-shadow: var(--glow-sm);
1969
+ }
1970
+
1971
+ .vp-hosts {
1972
+ display: grid;
1973
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
1974
+ gap: 12px;
1975
+ }
1976
+
1977
+ .vp-how {
1978
+ list-style: none;
1979
+ margin: 0;
1980
+ padding: 0;
1981
+ display: grid;
1982
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1983
+ gap: 12px;
1984
+ counter-reset: vp-how;
1985
+ }
1986
+
1987
+ .vp-how li {
1988
+ display: grid;
1989
+ gap: 4px;
1990
+ padding: 14px;
1991
+ border: 1px solid var(--line);
1992
+ border-radius: var(--radius-sm);
1993
+ counter-increment: vp-how;
1994
+ }
1995
+
1996
+ .vp-how li::before {
1997
+ content: counter(vp-how);
1998
+ font: 600 0.75rem var(--font-mono);
1999
+ color: var(--blue);
2000
+ }
2001
+
2002
+ .vp-upgrade {
2003
+ display: flex;
2004
+ flex-wrap: wrap;
2005
+ align-items: center;
2006
+ justify-content: space-between;
2007
+ gap: 12px;
2008
+ padding: 16px;
2009
+ border: 1px solid var(--blue-35);
2010
+ border-radius: var(--radius-sm);
2011
+ background: var(--blue-05);
2012
+ }
2013
+
2014
+ .vp-upgrade p {
2015
+ margin: 4px 0 0;
2016
+ }
2017
+
2018
  .host-card {
2019
  display: grid;
2020
  gap: 10px;