diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs index 86c5a77..facaaf2 100644 --- a/apps/desktop/main.cjs +++ b/apps/desktop/main.cjs @@ -1640,11 +1640,13 @@ function resetHotkeyState() { const HOTKEY_KEY_CODES = { ...Object.fromEntries("ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").map((key) => [key, UiohookKey[key]])), ...Object.fromEntries("0123456789".split("").map((key) => [key, UiohookKey[key]])), + ...Object.fromEntries(Array.from({ length: 10 }, (_, index) => [`Numpad${index}`, UiohookKey[`Numpad${index}`]])), Space: UiohookKey.Space, Enter: UiohookKey.Enter, Escape: UiohookKey.Escape, Tab: UiohookKey.Tab, Backspace: UiohookKey.Backspace, + CapsLock: UiohookKey.CapsLock, Delete: UiohookKey.Delete, Insert: UiohookKey.Insert, Home: UiohookKey.Home, @@ -1666,6 +1668,12 @@ const HOTKEY_KEY_CODES = { Backquote: UiohookKey.Backquote, BracketLeft: UiohookKey.BracketLeft, BracketRight: UiohookKey.BracketRight, + NumpadMultiply: UiohookKey.NumpadMultiply, + NumpadAdd: UiohookKey.NumpadAdd, + NumpadSubtract: UiohookKey.NumpadSubtract, + NumpadDecimal: UiohookKey.NumpadDecimal, + NumpadDivide: UiohookKey.NumpadDivide, + NumpadEnter: UiohookKey.NumpadEnter, F1: UiohookKey.F1, F2: UiohookKey.F2, F3: UiohookKey.F3, @@ -1677,9 +1685,26 @@ const HOTKEY_KEY_CODES = { F9: UiohookKey.F9, F10: UiohookKey.F10, F11: UiohookKey.F11, - F12: UiohookKey.F12 + F12: UiohookKey.F12, + F13: UiohookKey.F13, + F14: UiohookKey.F14, + F15: UiohookKey.F15, + F16: UiohookKey.F16, + F17: UiohookKey.F17, + F18: UiohookKey.F18, + F19: UiohookKey.F19, + F20: UiohookKey.F20, + F21: UiohookKey.F21, + F22: UiohookKey.F22, + F23: UiohookKey.F23, + F24: UiohookKey.F24, + NumLock: UiohookKey.NumLock, + ScrollLock: UiohookKey.ScrollLock, + PrintScreen: UiohookKey.PrintScreen }; +const HOTKEY_CANONICAL_PARTS = Object.fromEntries(Object.keys(HOTKEY_KEY_CODES).map((key) => [key.toUpperCase(), key])); + const HOTKEY_ALIASES = { CTRL: "Control", CONTROL: "Control", @@ -1771,7 +1796,7 @@ function parseHotkey(value) { function normalizeHotkeyPart(part) { const upper = part.replace(/\s+/g, "").toUpperCase(); - if (/^F([1-9]|1[0-2])$/.test(upper)) return upper; + if (HOTKEY_CANONICAL_PARTS[upper]) return HOTKEY_CANONICAL_PARTS[upper]; if (/^[A-Z0-9]$/.test(upper)) return upper; return HOTKEY_ALIASES[upper] || ""; } diff --git a/apps/desktop/src/renderer/MainView.tsx b/apps/desktop/src/renderer/MainView.tsx index 399d537..d746cfb 100644 --- a/apps/desktop/src/renderer/MainView.tsx +++ b/apps/desktop/src/renderer/MainView.tsx @@ -981,52 +981,147 @@ function SectionHotkey({ onSetHotkey: (hotkey: string) => Promise; }) { const [draft, setDraft] = useState(rawHotkey); + const [capturing, setCapturing] = useState(false); + const [captureError, setCaptureError] = useState(""); + const pendingModifierHotkey = useRef(null); + const captureButtonRef = useRef(null); useEffect(() => { setDraft(rawHotkey); + setCapturing(false); + setCaptureError(""); + pendingModifierHotkey.current = null; }, [rawHotkey]); + const draftDisplay = displayHotkey(draft); + const draftParts = splitHotkey(draftDisplay); const unchanged = normalizeHotkeyDraft(draft) === normalizeHotkeyDraft(rawHotkey); + function beginCapture() { + if (recordingControlsDisabled) return; + pendingModifierHotkey.current = null; + setDraft(rawHotkey); + setCaptureError(""); + setCapturing(true); + window.requestAnimationFrame(() => captureButtonRef.current?.focus()); + } + + function cancelCapture() { + pendingModifierHotkey.current = null; + setDraft(rawHotkey); + setCaptureError(""); + setCapturing(false); + } + + async function commitCapture(nextHotkey: string) { + pendingModifierHotkey.current = null; + setDraft(nextHotkey); + setCaptureError(""); + setCapturing(false); + await onSetHotkey(nextHotkey); + } + + function handleCaptureKeyDown(event: React.KeyboardEvent) { + if (!capturing) return; + event.preventDefault(); + event.stopPropagation(); + + if (event.key === "Escape" && !hasKeyboardEventModifier(event)) { + cancelCapture(); + return; + } + + const captured = hotkeyFromKeyboardEvent(event); + if (!captured) { + pendingModifierHotkey.current = null; + setCaptureError("Press a supported shortcut key."); + return; + } + + setDraft(captured.hotkey); + if (!captured.valid) { + pendingModifierHotkey.current = null; + setCaptureError(captured.reason); + return; + } + + setCaptureError(""); + if (captured.hasKey) { + void commitCapture(captured.hotkey); + return; + } + + pendingModifierHotkey.current = captured.hotkey; + } + + function handleCaptureKeyUp(event: React.KeyboardEvent) { + if (!capturing) return; + event.preventDefault(); + event.stopPropagation(); + + const nextHotkey = pendingModifierHotkey.current; + if (nextHotkey && !hasKeyboardEventModifier(event)) { + void commitCapture(nextHotkey); + } + } + + const helperText = captureError || error; + return (
- - setDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && !unchanged) { - void onSetHotkey(draft); - } + aria-pressed={capturing} + aria-describedby={helperText ? "hotkey-helper" : undefined} + onBlur={() => { + if (capturing) cancelCapture(); }} - /> + onClick={capturing ? cancelCapture : beginCapture} + onKeyDown={handleCaptureKeyDown} + onKeyUp={handleCaptureKeyUp} + > + + + + {capturing ? "Press shortcut" : "Hold shortcut"} + + {draftParts.length > 0 ? ( + + ) : ( + + Waiting for keys + + )} + + + {capturing ? "Cancel" : unchanged ? "Change" : "Unsaved"} + +
Active: {hotkey} - + {capturing ? ( + + Listening + + ) : null}
- {error ?

{error}

: null} + {helperText ? ( +

+ {helperText} +

+ ) : null}
); @@ -1665,6 +1760,142 @@ function splitHotkey(hotkey: string): string[] { .filter(Boolean); } +type CapturedHotkey = { + hotkey: string; + hasKey: boolean; + reason: string; + valid: boolean; +}; + +const MODIFIER_EVENT_CODES = new Set([ + "ControlLeft", + "ControlRight", + "AltLeft", + "AltRight", + "ShiftLeft", + "ShiftRight", + "MetaLeft", + "MetaRight" +]); + +const KEYBOARD_CODE_TO_HOTKEY: Record = { + ...Object.fromEntries("ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").map((key) => [`Key${key}`, key])), + ...Object.fromEntries("0123456789".split("").map((key) => [`Digit${key}`, key])), + ...Object.fromEntries("0123456789".split("").map((key) => [`Numpad${key}`, `Numpad${key}`])), + ...Object.fromEntries(Array.from({ length: 24 }, (_, index) => { + const key = `F${index + 1}`; + return [key, key]; + })), + Backquote: "Backquote", + Backslash: "Backslash", + Backspace: "Backspace", + BracketLeft: "BracketLeft", + BracketRight: "BracketRight", + CapsLock: "CapsLock", + Comma: "Comma", + Delete: "Delete", + End: "End", + Enter: "Enter", + Equal: "Equal", + Escape: "Escape", + Home: "Home", + Insert: "Insert", + Minus: "Minus", + NumpadAdd: "NumpadAdd", + NumpadDecimal: "NumpadDecimal", + NumpadDivide: "NumpadDivide", + NumpadEnter: "NumpadEnter", + NumpadMultiply: "NumpadMultiply", + NumpadSubtract: "NumpadSubtract", + NumLock: "NumLock", + PageDown: "PageDown", + PageUp: "PageUp", + Period: "Period", + PrintScreen: "PrintScreen", + Quote: "Quote", + ScrollLock: "ScrollLock", + Semicolon: "Semicolon", + Slash: "Slash", + Space: "Space", + Tab: "Tab", + ArrowDown: "ArrowDown", + ArrowLeft: "ArrowLeft", + ArrowRight: "ArrowRight", + ArrowUp: "ArrowUp" +}; + +function hotkeyFromKeyboardEvent(event: React.KeyboardEvent): CapturedHotkey | null { + const modifiers = keyboardEventModifiers(event); + const key = keyboardEventKeyPart(event); + const isModifierKey = MODIFIER_EVENT_CODES.has(event.code); + + if (!isModifierKey && !key) { + return { + hotkey: modifiers.join("+"), + hasKey: false, + reason: "That key is not supported.", + valid: false + }; + } + + const hotkey = [...modifiers, key].filter(Boolean).join("+"); + if (!hotkey) return null; + + const hasKey = Boolean(key); + if (modifiers.length === 0) { + return { + hotkey, + hasKey, + reason: "Add Ctrl, Alt, Shift, or Win.", + valid: false + }; + } + + if (!hasKey && modifiers.length < 2) { + return { + hotkey, + hasKey, + reason: "Modifier-only shortcuts need two modifiers.", + valid: false + }; + } + + return { + hotkey, + hasKey, + reason: "", + valid: true + }; +} + +function keyboardEventModifiers(event: { + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; +}): string[] { + return [ + event.ctrlKey ? "Control" : "", + event.altKey ? "Alt" : "", + event.shiftKey ? "Shift" : "", + event.metaKey ? "Super" : "" + ].filter(Boolean); +} + +function keyboardEventKeyPart(event: React.KeyboardEvent): string { + if (MODIFIER_EVENT_CODES.has(event.code)) return ""; + return KEYBOARD_CODE_TO_HOTKEY[event.code] || ""; +} + +function hasKeyboardEventModifier(event: { + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; +}): boolean { + return event.ctrlKey || event.altKey || event.shiftKey || event.metaKey; +} + function normalizeHotkeyDraft(hotkey: string): string { return hotkey.replace(/\s+/g, "").toLowerCase(); } diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 443c086..0a00873 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -253,6 +253,28 @@ cursor: pointer; } + .hotkey-capture { + @apply app-no-drag flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm + disabled:cursor-not-allowed disabled:opacity-50; + min-height: 68px; + background: rgba(255, 255, 255, 0.04); + color: var(--color-text); + box-shadow: inset 0 0 0 1px var(--color-line-strong); + } + .hotkey-capture:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); + } + .hotkey-capture:focus { + outline: none; + box-shadow: inset 0 0 0 1px var(--color-brand); + } + .hotkey-capture[data-capturing="true"] { + background: rgba(79, 143, 255, 0.1); + box-shadow: + inset 0 0 0 1px rgba(79, 143, 255, 0.52), + 0 0 0 1px rgba(79, 143, 255, 0.08); + } + /* ----- Waveform ----- */ .wave { display: flex; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 93fd71b..ddf769b 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -2559,56 +2559,117 @@ function pricingBody(appUrl: string): string { } function downloadBody(appUrl: string): string { + const windowsHref = `${appUrl}/downloads/laryn-windows-latest.exe`; + const macHref = `${appUrl}/downloads/laryn-mac-latest.dmg`; return ` -
+
-
-

Download

-

Get Laryn for your desktop.

-

Install the latest release, sign in once, and start dictating into the app that already has your cursor.

+
+
+

Latest stable build

+

Install Laryn
in under a minute.

+

Download the desktop app, sign in once with Google, and Laryn becomes your hold-to-talk dictation in every window you have open.

+ +
    +
  • Code-signed & auto-updating
  • +
  • Pair unlimited computers per account
  • +
  • Free to install · Pro to dictate
  • +
+
+
-
-
-
-
- Recommended - Windows +
+
+
+

Platforms

+

Pick the build for your machine.

+

Both builds connect to the same Laryn account. Pair as many computers as you like — there is no per-seat fee.

+
+
+
+
+
+ +
+ Recommended +

Windows

+
-

Windows installer

-

Best-supported release path. Installs Laryn, creates shortcuts, and keeps background updates wired to the Windows update feed.

-
+

Best-supported release. Ships as a signed installer, drops shortcuts, and stays current via the Windows update feed.

+
OS
Windows 10 1903+ or Windows 11
Arch
x64
-
File
.exe installer
+
Format
.exe installer
- Download for Windows + Download for Windows +

Updates install silently in the background.

-
-
- Early build - macOS +
+
+ +
+ Early build +

macOS

+
-

Mac disk image

-

Apple Silicon build for testing. It is not notarized yet, so macOS may require approval in Privacy & Security the first time you open it.

-
+

Apple Silicon disk image for testers. Not notarized yet, so macOS may ask you to approve it once in Privacy & Security.

+
OS
macOS 12+
Arch
Apple Silicon
-
File
.dmg disk image
+
Format
.dmg disk image
- Download for macOS + Download for macOS +

Drag Laryn into Applications, then launch.

-
-

Download links always redirect to the latest published build.

-

Setup

-

From zero to dictating in three steps.

+

Three steps from installer to first transcript.

  1. @@ -2619,41 +2680,32 @@ function downloadBody(appUrl: string): string {
  2. 02

    Sign in & pair

    -

    Open Settings → Account → Sign in with Google. Approve the short code in your browser and you're paired.

    +

    Open Settings → Account, sign in with Google, and approve the short code that lands in your browser.

  3. 03

    Press, speak, paste

    -

    Hold Ctrl+Win, talk, release. Laryn pastes into whatever window had focus.

    +

    Hold Ctrl+Win, talk, release. Laryn pastes clean text into whatever window had focus.

-
-
-
-

System

-

Requirements

-
-
Windows
Windows 10 1903+ or Windows 11, x64
-
macOS
macOS 12+ on Apple Silicon
-
RAM
4 GB minimum, 8 GB recommended
-
Disk
Several hundred MB after install
-
Mic
Any system-recognized microphone
-
Network
Internet for transcription
-
-
-
-

Hotkey

-

Hold-to-talk shortcut

-
- Ctrl - + - Super +
+
+
+
+

Requirements

+

Light footprint, no surprises.

-

The default shortcut. On Windows this is Ctrl+Win; on macOS this maps to the system modifier Laryn can register globally.

-

Pick any combination you like — change it any time from Settings → Hotkey.

-
+
    +
  • RAM4 GB minimum, 8 GB recommended
  • +
  • DiskA few hundred MB after install
  • +
  • MicrophoneAny system-recognized mic, USB or built-in
  • +
  • NetworkInternet connection for transcription
  • +
  • HotkeyCtrl+Win by default — change in Settings
  • +
  • AccountFree Laryn account, Pro to dictate
  • +
+
@@ -2663,10 +2715,10 @@ function downloadBody(appUrl: string): string {

Already installed?

-

Open the desktop app, head to Settings → Account, and sign in to start dictating.

+

Open the desktop app, head to Settings → Account, and sign in to pair this computer in seconds.

@@ -2849,21 +2901,66 @@ function marketingCss(): string { .hero-meta li{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--text-soft)} .hero-meta-dot{width:7px;height:7px;border-radius:999px;background:var(--text-mute);flex:0 0 auto} .dot-good.hero-meta-dot,.hero-meta-dot.dot-good{background:var(--good);box-shadow:0 0 12px rgba(52,211,153,.5)} -.download-meta{margin-top:18px;color:var(--text-mute);font-size:13px;text-align:center} - -/* ---------- Download choices ---------- */ -.section-download-options{padding-top:0} -.download-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px;margin-bottom:18px} -.download-card{padding:30px;border-radius:var(--radius-lg);background:linear-gradient(180deg,rgba(22,34,58,.62),rgba(13,22,34,.78));box-shadow:inset 0 0 0 1px var(--line-strong);display:flex;flex-direction:column;gap:16px} -.download-card-primary{box-shadow:inset 0 0 0 1px rgba(79,143,255,.36),0 24px 70px -42px rgba(47,111,255,.55)} -.download-card-head{display:flex;align-items:center;justify-content:space-between;gap:12px} -.download-platform{font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-mute)} -.download-card h2{font-size:28px;line-height:1.1;font-weight:600;letter-spacing:-0.018em;color:var(--text)} -.download-card p{font-size:15px;line-height:1.55;color:var(--text-soft)} -.download-facts{display:grid;gap:1px;border-radius:var(--radius-md);overflow:hidden;background:var(--line);margin:4px 0 6px} -.download-facts div{display:grid;grid-template-columns:86px 1fr;gap:12px;background:rgba(255,255,255,.025);padding:11px 13px} -.download-facts dt{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-mute);font-weight:700} -.download-facts dd{font-size:13px;color:var(--text);min-width:0} + +/* ---------- Download hero ---------- */ +.hero-download{padding:88px 0 72px} +.hero-shell-download{grid-template-columns:minmax(0,1.05fr) minmax(360px,.95fr);gap:56px;align-items:center} +.hero-shell-download h1{font-size:clamp(40px,5.6vw,64px);line-height:1.04;max-width:16ch} +.hero-actions-download{gap:14px} +.hero-actions-download .btn svg{flex:0 0 auto;opacity:.92} + +.hero-installer{position:relative;display:grid;align-items:center;justify-items:end;min-width:0} +.installer-card{position:relative;width:100%;max-width:520px;border-radius:18px;padding:22px;display:grid;gap:18px;background:linear-gradient(180deg,rgba(19,30,48,.96),rgba(8,14,25,.98));box-shadow:inset 0 0 0 1px var(--line-bright),inset 0 1px 0 rgba(255,255,255,.06),0 36px 90px -28px rgba(0,0,0,.74);z-index:1;overflow:hidden} +.installer-card::before{content:"";position:absolute;inset:0;background:radial-gradient(520px 240px at 88% 0%,rgba(79,143,255,.18),transparent 62%);pointer-events:none} +.installer-head{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px} +.installer-icon{width:46px;height:46px;border-radius:12px;display:grid;place-items:center;color:#cdd9ff;background:linear-gradient(180deg,rgba(79,143,255,.22),rgba(30,78,216,.18));box-shadow:inset 0 0 0 1px rgba(79,143,255,.34),0 0 24px rgba(47,111,255,.32)} +.installer-title{display:grid;gap:3px;min-width:0} +.installer-title strong{font-size:15px;font-weight:650;color:#fff;font-family:ui-monospace,SFMono-Regular,"JetBrains Mono",Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.installer-title span{font-size:12px;color:var(--text-mute);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.installer-status{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.04em;color:var(--good);background:rgba(52,211,153,.12);box-shadow:inset 0 0 0 1px rgba(52,211,153,.32)} +.installer-status svg{flex:0 0 auto} +.installer-facts{position:relative;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;border-radius:var(--radius-md);overflow:hidden;background:var(--line)} +.installer-facts div{display:grid;gap:2px;padding:11px 13px;background:rgba(255,255,255,.025);min-width:0} +.installer-facts dt{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-mute);font-weight:700} +.installer-facts dd{font-size:13px;color:var(--text);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.installer-progress{position:relative;display:grid;gap:8px} +.installer-progress-track{height:6px;border-radius:999px;background:rgba(255,255,255,.05);box-shadow:inset 0 0 0 1px var(--line);overflow:hidden} +.installer-progress-bar{display:block;height:100%;width:100%;border-radius:inherit;background:linear-gradient(90deg,var(--brand) 0%,var(--accent) 100%);box-shadow:0 0 18px rgba(47,111,255,.45)} +.installer-progress-label{display:inline-flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--text-soft)} +.installer-glow{position:absolute;inset:auto -18% -36px auto;width:75%;height:60%;background:radial-gradient(closest-side,rgba(79,143,255,.32),transparent 70%);filter:blur(34px);z-index:0;pointer-events:none} + +/* ---------- Platform cards ---------- */ +.section-platforms{padding-top:0} +.platforms-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px} +.platform-card{position:relative;padding:30px;border-radius:var(--radius-lg);background:linear-gradient(180deg,rgba(22,34,58,.55),rgba(13,22,34,.7));box-shadow:inset 0 0 0 1px var(--line-strong);display:grid;gap:16px;align-content:start} +.platform-card-primary{background:linear-gradient(180deg,rgba(28,48,86,.7),rgba(13,22,34,.85));box-shadow:inset 0 0 0 1px rgba(79,143,255,.4),0 24px 70px -42px rgba(47,111,255,.55)} +.platform-card-primary::before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(closest-side at 100% 0%,rgba(79,143,255,.18),transparent 70%);pointer-events:none} +.platform-card > *{position:relative;z-index:1} +.platform-card-head{display:flex;align-items:center;gap:14px} +.platform-glyph{width:46px;height:46px;border-radius:12px;display:grid;place-items:center;flex:0 0 auto;background:rgba(255,255,255,.04);box-shadow:inset 0 0 0 1px var(--line-strong);color:var(--text)} +.platform-glyph-windows{color:#9bc1ff;background:linear-gradient(180deg,rgba(79,143,255,.22),rgba(30,78,216,.16));box-shadow:inset 0 0 0 1px rgba(79,143,255,.34)} +.platform-glyph-mac{color:var(--text-soft);background:linear-gradient(180deg,rgba(255,255,255,.06),rgba(255,255,255,.02));box-shadow:inset 0 0 0 1px var(--line-bright)} +.platform-card-title{display:grid;gap:6px;min-width:0} +.platform-card-title h3{font-size:24px;font-weight:600;letter-spacing:-0.016em;color:var(--text);line-height:1} +.platform-card-title .badge{justify-self:start} +.platform-card p{font-size:15px;line-height:1.55;color:var(--text-soft);max-width:46ch} +.platform-facts{display:grid;gap:1px;border-radius:var(--radius-md);overflow:hidden;background:var(--line);margin:2px 0 6px} +.platform-facts div{display:grid;grid-template-columns:86px 1fr;gap:12px;background:rgba(255,255,255,.025);padding:11px 14px} +.platform-facts dt{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-mute);font-weight:700} +.platform-facts dd{font-size:13px;color:var(--text);min-width:0} +.platform-foot{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--text-mute);margin-top:2px} +.platform-foot .dot{width:6px;height:6px} + +/* ---------- Requirements bar ---------- */ +.section-requirements{padding-top:0} +.requirements-bar{padding:36px clamp(24px,4vw,48px);border-radius:var(--radius-lg);background:var(--surface);box-shadow:inset 0 0 0 1px var(--line-strong);display:grid;grid-template-columns:minmax(0,260px) minmax(0,1fr);gap:36px;align-items:start} +.requirements-head{display:grid;gap:10px;max-width:280px} +.requirements-head h2{font-size:clamp(22px,2.6vw,28px);font-weight:600;letter-spacing:-0.016em;color:var(--text);line-height:1.15} +.requirements-list{list-style:none;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;background:var(--line);border-radius:var(--radius-md);overflow:hidden;box-shadow:inset 0 0 0 1px var(--line)} +.requirements-list li{display:grid;grid-template-columns:110px 1fr;gap:14px;align-items:start;padding:14px 16px;background:var(--surface-soft)} +.req-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;font-weight:700;color:var(--text-mute);padding-top:2px} +.req-value{font-size:14px;line-height:1.5;color:var(--text)} +.req-value kbd{font-size:11px;padding:2px 6px} /* ---------- Hero preview ---------- */ .hero-preview{position:relative;display:grid;align-items:center;justify-items:end;min-width:0;padding-bottom:54px} @@ -2999,21 +3096,6 @@ function marketingCss(): string { .faq-item[open] summary::after{transform:rotate(45deg);color:var(--brand)} .faq-item p{padding:0 20px 18px;color:var(--text-soft);font-size:15px;line-height:1.55;max-width:68ch} -/* ---------- Download specs ---------- */ -.section-specs{padding-top:0} -.specs-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px} -.spec-card{padding:32px;border-radius:var(--radius-lg);background:var(--surface);box-shadow:inset 0 0 0 1px var(--line-strong);display:grid;gap:14px;align-content:start} -.spec-card h3{font-size:22px;font-weight:600;letter-spacing:-0.014em;color:var(--text)} -.spec-list{display:grid;gap:1px;border-radius:var(--radius-md);overflow:hidden;background:var(--line);margin-top:4px} -.spec-row{background:var(--surface-soft);display:grid;grid-template-columns:140px 1fr;gap:16px;padding:12px 16px;align-items:center} -.spec-row dt{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-mute);font-weight:600} -.spec-row dd{font-size:14px;color:var(--text)} -.spec-card-hotkey{background:linear-gradient(180deg,rgba(22,34,58,.7),rgba(13,22,34,.8));align-items:start} -.hotkey-display{display:flex;align-items:center;gap:14px;padding:20px;justify-content:center;background:rgba(255,255,255,.025);border-radius:var(--radius-md);box-shadow:inset 0 0 0 1px var(--line);margin-top:4px} -.kbd-lg{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:18px;font-weight:600;padding:12px 18px;border-radius:10px;background:rgba(255,255,255,.05);box-shadow:inset 0 0 0 1px var(--line-bright),inset 0 -2px 0 rgba(0,0,0,.32);color:#fff;letter-spacing:.04em} -.hotkey-plus{font-size:18px;color:var(--text-mute);font-weight:300} -.spec-card-hotkey p{font-size:14px;line-height:1.55;color:var(--text-soft)} - /* ---------- CTA ---------- */ .section-cta{padding-top:32px;padding-bottom:64px} .cta-band{position:relative;padding:48px clamp(28px,4.5vw,56px);border-radius:var(--radius-xl);background:linear-gradient(135deg,rgba(79,143,255,.22) 0%,rgba(34,211,238,.16) 50%,rgba(167,139,250,.18) 100%);box-shadow:inset 0 0 0 1px var(--line-bright);display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:32px;overflow:hidden} @@ -3040,15 +3122,18 @@ function marketingCss(): string { /* ---------- Responsive ---------- */ @media(max-width:1080px){ - .hero-shell{grid-template-columns:1fr;gap:48px} - .hero-preview{justify-items:start} + .hero-shell,.hero-shell-download{grid-template-columns:1fr;gap:48px} + .hero-preview,.hero-installer{justify-items:start} + .installer-card{max-width:560px} .pricing-teaser{grid-template-columns:1fr;gap:32px} .pricing-grid{grid-template-columns:1fr} + .requirements-bar{grid-template-columns:1fr;gap:28px} } @media(max-width:860px){ .site-nav{display:none} .flow,.case-grid,.why-grid{grid-template-columns:1fr} - .download-options,.specs-grid,.faq-grid{grid-template-columns:1fr} + .platforms-grid,.faq-grid{grid-template-columns:1fr} + .requirements-list{grid-template-columns:1fr} .footer-cols{grid-template-columns:repeat(2,minmax(0,1fr))} .site-footer-inner{grid-template-columns:1fr;gap:32px} .cta-band{grid-template-columns:1fr;gap:24px;text-align:left} @@ -3078,6 +3163,13 @@ function marketingCss(): string { .preview-overlay-wave{display:none} .case-card{padding:18px} .pricing-card{padding:24px} + .platform-card{padding:24px} + .platform-facts div{grid-template-columns:74px 1fr} + .installer-card{padding:18px;gap:14px} + .installer-facts{grid-template-columns:1fr} + .requirements-bar{padding:24px} + .requirements-list li{grid-template-columns:1fr;gap:4px;padding:12px 14px} + .req-label{padding-top:0} .footer-cols{grid-template-columns:1fr} .compare-row{grid-template-columns:1fr;gap:4px} .pricing-price-num{font-size:40px}