web: restyle vocalinux.com to the Voca family workbench (#728)

* web: restyle vocalinux.com to the Voca family workbench

Bring the Linux marketing site onto the same paper, ink, and teal language
as VocaHQ, VocaMac, VocaPhone, and VocaGateway. Drop the site-wide dark
mode so the canvas stays paper-only, matching the other product sites.

* web: loop the home ribbon without a gap or jump

The ticker used a stretched full-width track, so a -50% slide left an empty
right edge before snapping back. Two equal groups, each wider than the
viewport, scroll by exactly one group width.

* web: match vocahq.com ribbon speed and teal band

The looped ticker was covering six item cycles in 32s, so it raced.
It now spends 28s per cycle like vocahq.com, uses the same dark-teal
band, and pauses when the tab is hidden.

* web: drive the hero panel clock from the visitor's local time

Replace the static 11:24 mock with a 24-hour HH:MM clock in the client
timezone, updated on the minute.

* web: add official platform marks on the family cards

Use the .github brand platform SVGs (Linux, Apple, Android, Windows)
plus the HQ server mark for VocaGateway, matching vocahq.com's product row.

* web: restore a Light/Dark flip on the screenshots page

The captured GTK shots exist in both appearances. A page-local switch
defaults to prefers-color-scheme and stores a tab override. The rest of
the site stays paper-only.

* web: make the homepage CTA say Install, not Get

The button already jumps to the one-line installer. Name that action,
note that there is no account, and keep the same copy in the closer
and the mobile menu.

* web: style install commands as GTK terminals

Command boxes use a headerbar with copy, a centered title, and
window controls on the right. The prompt is user@linux:~$, not
Mac traffic-light chrome.

* web: stop repeating the engine list in the hero

The proof line, the notes window, and the tray terminal all said
the same whisper.cpp / Whisper / VOSK / Remote API line. Engines
stay on the window chips. Proof is now desktop and license facts.
The terminal shows launching into the tray.

* web: reflow copy, color prompts, and add install paths

Drop pretty/balance wrapping so titles and ledes follow the
shell width. Color the user@linux prompt. Hero terminal now
says audio stays on this machine. Ribbon adds project words.
AppImage and source-build sit under the install command.

* web: put X, Discord, and GitHub by the footer mark

Official family social marks sit next to the Vocalinux logo.
Footer columns are Product, Install, Guides, and Project.
The bottom line is Part of VocaHQ, without X11 or whisper.cpp.

* web: add hello@vocahq.com to the footer social row

The official mail mark sits with X, Discord, and GitHub and opens
the family hello@vocahq.com address.

* web: use the circular mark in the header and footer

Header and footer now use the official teal circle lockup instead
of the square app icon.

* web: ship a circular favicon and a family OG card

Favicons use the teal circle lockup. The Open Graph image is redone
in the paper workbench language with the current homepage line.

* web: set the hero to Dictate locally / In any app

Shorter couplet in the VocaHQ display size, with a matching
lede and Open Graph card.

* web: gray the hero terminal note as a comment

The second line was reading as part of the vocalinux command.
It is now a dim # comment under the prompt.
This commit is contained in:
Jatin K Malik
2026-08-22 18:12:08 -07:00
committed by GitHub
parent 4532b32f48
commit 25ceec017d
76 changed files with 3767 additions and 2266 deletions
@@ -98,21 +98,10 @@ describe('VocalinuxLogo', () => {
expect(img).toBeInTheDocument();
});
it('renders a picture element with WebP source for logo', () => {
const { container } = render(<VocalinuxLogo />);
const picture = container.querySelector('picture');
expect(picture).toBeInTheDocument();
const source = container.querySelector('source');
expect(source).toBeInTheDocument();
expect(source).toHaveAttribute('srcset', '/vocalinux.webp');
expect(source).toHaveAttribute('type', 'image/webp');
});
it('provides PNG fallback for the logo', () => {
it('uses the circular mark', () => {
render(<VocalinuxLogo />);
const img = screen.getByAltText('Vocalinux');
expect(img).toHaveAttribute('src', '/vocalinux.png');
expect(img).toHaveAttribute('src', '/brand/vocalinux-mark-circle.svg');
});
it('uses default dimensions when not specified', () => {
@@ -130,14 +119,14 @@ describe('VocalinuxLogo', () => {
});
it('applies custom className', () => {
const { container } = render(<VocalinuxLogo className="logo-class" />);
const picture = container.querySelector('picture');
expect(picture).toHaveClass('logo-class');
render(<VocalinuxLogo className="logo-class" />);
const img = screen.getByAltText('Vocalinux');
expect(img).toHaveClass('logo-class');
});
it('passes priority prop for eager loading', () => {
render(<VocalinuxLogo priority />);
const img = screen.getByAltText('Vocalinux');
expect(img).not.toHaveAttribute('loading', 'lazy');
expect(img).toHaveAttribute('loading', 'eager');
});
});
@@ -1,5 +1,5 @@
import React from "react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
ScreenshotGallery,
@@ -39,7 +39,145 @@ const settingsShots: Screenshot[] = [
},
];
function mockColorScheme(dark: boolean) {
const listeners = new Set<(event: MediaQueryListEvent) => void>();
const media = {
matches: dark,
media: "(prefers-color-scheme: dark)",
onchange: null,
addEventListener: (
_type: string,
listener: (event: MediaQueryListEvent) => void,
) => {
listeners.add(listener);
},
removeEventListener: (
_type: string,
listener: (event: MediaQueryListEvent) => void,
) => {
listeners.delete(listener);
},
addListener: jest.fn(),
removeListener: jest.fn(),
dispatchEvent: jest.fn(),
emit(next: boolean) {
media.matches = next;
const event = { matches: next } as MediaQueryListEvent;
listeners.forEach((listener) => listener(event));
},
};
window.matchMedia = jest.fn().mockImplementation((query: string) => {
if (query.includes("prefers-color-scheme: dark")) return media;
return { ...media, matches: false, addEventListener: jest.fn() };
});
return media;
}
describe("ScreenshotGallery lightbox", () => {
beforeEach(() => {
sessionStorage.clear();
mockColorScheme(false);
});
it("defaults to the browser color scheme before any override", async () => {
mockColorScheme(true);
render(
<ScreenshotGallery
productShots={productShots}
settingsShots={settingsShots}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
expect(
document.querySelector(
'img.is-on[src="/screenshots/dark/00-transcription.png"]',
),
).not.toBeNull();
});
it("follows the browser scheme until the visitor flips the switch", async () => {
const media = mockColorScheme(true);
render(
<ScreenshotGallery
productShots={productShots}
settingsShots={settingsShots}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
act(() => {
media.emit(false);
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
await userEvent.click(screen.getByRole("button", { name: "Dark" }));
act(() => {
media.emit(false);
});
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(sessionStorage.getItem("vocalinux-shot-appearance")).toBe("dark");
});
it("restores a tab override from sessionStorage", async () => {
sessionStorage.setItem("vocalinux-shot-appearance", "dark");
render(
<ScreenshotGallery
productShots={productShots}
settingsShots={settingsShots}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
});
it("flips between light and dark app shots without changing the page theme", async () => {
const user = userEvent.setup();
render(
<ScreenshotGallery
productShots={productShots}
settingsShots={settingsShots}
/>,
);
const lightBtn = screen.getByRole("button", { name: "Light" });
const darkBtn = screen.getByRole("button", { name: "Dark" });
expect(lightBtn).toHaveAttribute("aria-pressed", "true");
expect(darkBtn).toHaveAttribute("aria-pressed", "false");
await user.click(darkBtn);
expect(darkBtn).toHaveAttribute("aria-pressed", "true");
expect(lightBtn).toHaveAttribute("aria-pressed", "false");
expect(
document.querySelector(
'img.is-on[src="/screenshots/dark/settings-speech-engine.png"]',
),
).not.toBeNull();
expect(document.documentElement).not.toHaveClass("dark");
});
it("opens an expanded gallery view when a screenshot is clicked", async () => {
const user = userEvent.setup();
render(
@@ -61,15 +199,11 @@ describe("ScreenshotGallery lightbox", () => {
// Settings shot is third overall (index 2 -> 3/3)
expect(within(dialog).getByText(/3 \/ 3/)).toBeInTheDocument();
const speechImgs = within(dialog).getAllByAltText("Speech engine alt");
expect(speechImgs.length).toBe(2);
expect(speechImgs.length).toBe(1);
expect(speechImgs[0]).toHaveAttribute(
"src",
"/screenshots/settings-speech-engine.png",
);
expect(speechImgs[1]).toHaveAttribute(
"src",
"/screenshots/dark/settings-speech-engine.png",
);
expect(within(dialog).getByText("Speech Engine")).toBeInTheDocument();
});
+3 -3
View File
@@ -23,8 +23,8 @@ const SyntaxHighlighter = dynamic<SyntaxHighlighterProps>(
{
ssr: false,
loading: () => (
<div className="rounded-lg bg-zinc-900 p-4 text-sm">
<pre className="whitespace-pre-wrap text-green-400">Loading...</pre>
<div className="subpage-terminal p-4 text-sm">
<pre className="whitespace-pre-wrap">Loading...</pre>
</div>
),
},
@@ -57,7 +57,7 @@ export function CodeBlock({
if (!style) {
return (
<div
className={`rounded-lg bg-zinc-900 p-4 text-sm ${className}`}
className={`subpage-terminal p-4 text-sm ${className}`}
style={customStyle}
>
<pre className="whitespace-pre-wrap text-green-400">{children}</pre>
+11 -11
View File
@@ -55,16 +55,16 @@ export const VocalinuxLogo: React.FC<{
priority?: boolean;
}> = ({ className = "", width = 32, height = 32, priority = false }) => {
return (
<picture className={className}>
<source srcSet="/vocalinux.webp" type="image/webp" />
<Image
src="/vocalinux.png"
alt="Vocalinux"
width={width}
height={height}
className={className}
priority={priority}
/>
</picture>
// SVG lockup; next/image is for raster.
// eslint-disable-next-line @next/next/no-img-element
<img
src="/brand/vocalinux-mark-circle.svg"
alt="Vocalinux"
width={width}
height={height}
className={className}
decoding="async"
loading={priority ? "eager" : "lazy"}
/>
);
};
+161 -27
View File
@@ -9,14 +9,9 @@ import {
Settings,
X,
} from "lucide-react";
import {
darkScreenshotPath,
ThemeScreenshotImg,
} from "@/components/theme-screenshot";
export type Screenshot = {
src: string;
/** Dark-theme variant; defaults to `/screenshots/dark/<file>`. */
srcDark?: string;
alt: string;
title: string;
@@ -25,44 +20,109 @@ export type Screenshot = {
height: number;
};
type ShotTheme = "light" | "dark";
const THEME_STORAGE_KEY = "vocalinux-shot-appearance";
export function darkScreenshotPath(src: string): string {
if (src.includes("/dark/")) return src;
return src.replace("/screenshots/", "/screenshots/dark/");
}
function shotDarkSrc(shot: Screenshot): string {
return shot.srcDark ?? darkScreenshotPath(shot.src);
}
function systemShotTheme(): ShotTheme {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function readStoredTheme(): ShotTheme | null {
try {
const stored = sessionStorage.getItem(THEME_STORAGE_KEY);
if (stored === "light" || stored === "dark") return stored;
} catch {
/* private mode */
}
return null;
}
type ScreenshotGalleryProps = {
productShots: Screenshot[];
settingsShots: Screenshot[];
};
function ShotPair({
shot,
theme,
className,
loading = "lazy",
}: {
shot: Screenshot;
theme: ShotTheme;
className?: string;
loading?: "lazy" | "eager";
}) {
const darkSrc = shotDarkSrc(shot);
return (
<div className="shot-pair">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={theme === "light" ? shot.alt : ""}
width={shot.width}
height={shot.height}
className={`${className ?? ""} ${theme === "light" ? "is-on" : ""}`.trim()}
loading={loading}
decoding="async"
aria-hidden={theme !== "light"}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={darkSrc}
alt={theme === "dark" ? shot.alt : ""}
width={shot.width}
height={shot.height}
className={`${className ?? ""} ${theme === "dark" ? "is-on" : ""}`.trim()}
loading={loading}
decoding="async"
aria-hidden={theme !== "dark"}
/>
</div>
);
}
function ScreenshotCard({
shot,
theme,
onOpen,
}: {
shot: Screenshot;
theme: ShotTheme;
onOpen: () => void;
}) {
return (
<figure className="flex h-full flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<figure className="gallery-card">
<button
type="button"
onClick={onOpen}
className="group relative flex min-h-0 w-full flex-1 cursor-zoom-in items-center justify-center bg-zinc-50 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 dark:bg-zinc-900/50"
className="group relative flex min-h-0 w-full flex-1 cursor-zoom-in items-center justify-center bg-muted p-3 text-left"
aria-label={`View larger: ${shot.title}`}
>
<ThemeScreenshotImg
src={shot.src}
srcDark={shotDarkSrc(shot)}
alt={shot.alt}
width={shot.width}
height={shot.height}
className="mx-auto h-auto max-h-64 w-full rounded-lg object-contain transition-transform duration-200 group-hover:scale-[1.02]"
<ShotPair
shot={shot}
theme={theme}
className="mx-auto h-auto max-h-64 w-full rounded-lg object-contain"
/>
<span className="pointer-events-none absolute bottom-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-zinc-900/80 px-2.5 py-1 text-xs font-medium text-white opacity-0 shadow transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 dark:bg-zinc-100/90 dark:text-zinc-900">
<span className="pointer-events-none absolute bottom-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-[color:var(--dark-ink)] px-2.5 py-1 text-xs font-medium text-[color:var(--paper-bright)] opacity-0 shadow transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<Expand className="h-3.5 w-3.5" />
Expand
</span>
</button>
<figcaption className="space-y-1 border-t border-zinc-200 p-4 dark:border-zinc-700">
<figcaption className="space-y-1">
<h3 className="text-lg font-semibold">{shot.title}</h3>
<p className="text-sm text-muted-foreground">{shot.description}</p>
</figcaption>
@@ -73,6 +133,7 @@ function ScreenshotCard({
function Lightbox({
shots,
index,
theme,
onClose,
onPrev,
onNext,
@@ -80,6 +141,7 @@ function Lightbox({
}: {
shots: Screenshot[];
index: number;
theme: ShotTheme;
onClose: () => void;
onPrev: () => void;
onNext: () => void;
@@ -162,22 +224,19 @@ function Lightbox({
onClick={(event) => event.stopPropagation()}
>
<div className="relative flex max-h-[min(80vh,900px)] w-full items-center justify-center">
<ThemeScreenshotImg
src={shot.src}
srcDark={shotDarkSrc(shot)}
alt={shot.alt}
width={shot.width}
height={shot.height}
className="max-h-[min(80vh,900px)] w-auto max-w-full rounded-lg object-contain shadow-2xl"
<ShotPair
shot={shot}
theme={theme}
loading="eager"
className="max-h-[min(80vh,900px)] w-auto max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>
<div className="max-w-2xl px-2 text-center text-white">
<p id={titleId} className="text-lg font-semibold">
{shot.title}
</p>
<p className="mt-1 text-sm text-zinc-300">{shot.description}</p>
<p className="mt-2 text-xs text-zinc-400">
<p className="mt-1 text-sm text-white/80">{shot.description}</p>
<p className="mt-2 text-xs text-white/60">
{index + 1} / {shots.length}
<span className="mx-2">·</span>
Esc to close · arrow keys to browse
@@ -188,14 +247,85 @@ function Lightbox({
);
}
function AppearanceSwitch({
theme,
onChange,
}: {
theme: ShotTheme;
onChange: (theme: ShotTheme) => void;
}) {
return (
<div className="shot-theme-bar">
<div>
<p className="shot-theme-kicker">App appearance</p>
<p className="shot-theme-copy">
Flip the captured GTK theme. Starts from your system setting.
</p>
</div>
<div
className="shot-switch"
data-mode={theme}
role="group"
aria-label="App appearance"
>
<span className="shot-switch-knob" aria-hidden="true" />
<button
type="button"
aria-pressed={theme === "light"}
onClick={() => onChange("light")}
>
Light
</button>
<button
type="button"
aria-pressed={theme === "dark"}
onClick={() => onChange("dark")}
>
Dark
</button>
</div>
</div>
);
}
export function ScreenshotGallery({
productShots,
settingsShots,
}: ScreenshotGalleryProps) {
const allShots = [...productShots, ...settingsShots];
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [theme, setTheme] = useState<ShotTheme>("light");
const [userOverride, setUserOverride] = useState(false);
const titleId = useId();
useEffect(() => {
const stored = readStoredTheme();
if (stored) {
setTheme(stored);
setUserOverride(true);
return;
}
setTheme(systemShotTheme());
}, []);
useEffect(() => {
if (userOverride) return undefined;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => setTheme(media.matches ? "dark" : "light");
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, [userOverride]);
const chooseTheme = useCallback((next: ShotTheme) => {
setTheme(next);
setUserOverride(true);
try {
sessionStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
/* private mode */
}
}, []);
const openAt = useCallback((index: number) => {
setActiveIndex(index);
}, []);
@@ -224,6 +354,8 @@ export function ScreenshotGallery({
return (
<>
<AppearanceSwitch theme={theme} onChange={chooseTheme} />
<section className="mb-14">
<div className="mb-6 flex items-center gap-2">
<ImageIcon className="h-5 w-5 text-primary" />
@@ -234,6 +366,7 @@ export function ScreenshotGallery({
<ScreenshotCard
key={shot.src}
shot={shot}
theme={theme}
onOpen={() => openAt(index)}
/>
))}
@@ -247,14 +380,14 @@ export function ScreenshotGallery({
</div>
<p className="mb-6 max-w-3xl text-muted-foreground">
Every sidebar settings page, from speech model selection through
advanced whisper.cpp tuning. Click any shot to expand it — images
follow the site light/dark theme.
advanced whisper.cpp tuning. Click any shot to expand it.
</p>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{settingsShots.map((shot, index) => (
<ScreenshotCard
key={shot.src}
shot={shot}
theme={theme}
onOpen={() => openAt(productShots.length + index)}
/>
))}
@@ -265,6 +398,7 @@ export function ScreenshotGallery({
<Lightbox
shots={allShots}
index={activeIndex}
theme={theme}
onClose={close}
onPrev={showPrev}
onNext={showNext}
+57 -412
View File
@@ -1,195 +1,71 @@
"use client";
import React, { useState, useEffect } from "react";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
BookOpen,
Activity,
Camera,
ChevronDown,
ChevronRight,
Code2,
Cpu,
Globe,
Heart,
HelpCircle,
Home,
Menu,
Monitor,
Power,
Server,
ShieldCheck,
SlidersHorizontal,
Shield,
X,
Zap,
Github,
} from "lucide-react";
import { VocalinuxLogo } from "@/components/optimized-image";
import { ThemeToggle } from "@/components/theme-toggle";
import { SiteChrome } from "@/components/site-chrome";
interface SeoSubpageShellProps {
children: React.ReactNode;
}
const pageTitles: Record<string, string> = {
"/install": "Install Guides",
"/install/ubuntu": "Ubuntu",
"/install/fedora": "Fedora",
"/install/arch": "Arch",
"/screenshots": "Screenshots",
"/changelog": "Changelog",
"/compare": "Engine Comparison",
"/remote-api": "Remote API",
"/languages": "Languages",
"/wayland": "Wayland",
"/gpu-acceleration": "GPU Acceleration",
"/voice-activity-detection": "Silero VAD",
"/advanced-settings": "Advanced Settings",
"/desktop-reliability": "Desktop Reliability",
"/autostart": "Autostart",
"/for-developers": "For Developers",
"/rsi-prevention": "RSI Prevention",
"/writers": "For Writers",
"/gnome-kde": "GNOME vs KDE",
"/use-cases": "Use Cases",
"/vs-nerd-dictation": "vs Nerd Dictation",
"/whisper-model-guide": "Whisper Models",
"/voice-typing-vscode": "VS Code",
"/offline": "Offline",
"/open-source": "Open Source",
"/privacy": "Privacy Policy",
"/faq": "FAQ",
"/troubleshooting": "Troubleshooting",
"/shortcuts": "Voice Commands",
"/alternatives": "Alternatives",
};
const navCategories = [
{
label: "Get Started",
items: [
{ href: "/", label: "Home", icon: Home },
{ href: "/install/", label: "Install Guides", icon: BookOpen },
{ href: "/screenshots/", label: "Screenshots", icon: Camera },
{ href: "/changelog/", label: "Changelog", icon: Zap },
],
},
{
label: "Features",
items: [
{ href: "/compare/", label: "Engine Comparison", icon: Cpu },
{ href: "/remote-api/", label: "Remote API", icon: Server },
{ href: "/languages/", label: "Multilingual", icon: Globe },
{ href: "/wayland/", label: "Wayland Support", icon: Monitor },
{ href: "/gpu-acceleration/", label: "GPU Acceleration", icon: Cpu },
{
href: "/voice-activity-detection/",
label: "Silero VAD",
icon: Activity,
},
{
href: "/advanced-settings/",
label: "Advanced Settings",
icon: SlidersHorizontal,
},
{
href: "/desktop-reliability/",
label: "Desktop Reliability",
icon: ShieldCheck,
},
{ href: "/autostart/", label: "Autostart", icon: Power },
],
},
{
label: "Use Cases",
items: [
{ href: "/for-developers/", label: "For Developers", icon: Code2 },
{ href: "/rsi-prevention/", label: "RSI Prevention", icon: Heart },
{ href: "/writers/", label: "For Writers", icon: Heart },
{ href: "/gnome-kde/", label: "GNOME vs KDE", icon: Monitor },
{ href: "/use-cases/", label: "All Use Cases", icon: Zap },
],
},
{
label: "Comparisons",
items: [
{
href: "/vs-nerd-dictation/",
label: "vs Nerd Dictation",
icon: BookOpen,
},
{ href: "/whisper-model-guide/", label: "Whisper Models", icon: Cpu },
{ href: "/voice-typing-vscode/", label: "VS Code", icon: Code2 },
],
},
{
label: "Privacy & Ethics",
items: [
{ href: "/offline/", label: "Offline", icon: Shield },
{ href: "/open-source/", label: "Open Source", icon: Code2 },
{ href: "/privacy/", label: "Privacy Policy", icon: Shield },
],
},
{
label: "Support",
items: [
{ href: "/faq/", label: "FAQ", icon: HelpCircle },
{ href: "/troubleshooting/", label: "Troubleshooting", icon: Zap },
{ href: "/shortcuts/", label: "Voice Commands", icon: BookOpen },
{ href: "/alternatives/", label: "Alternatives", icon: BookOpen },
],
},
];
function DropdownMenu({ category }: { category: (typeof navCategories)[0] }) {
return (
<div className="group relative">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:bg-muted focus:text-foreground focus:outline-none"
>
{category.label}
<ChevronDown className="h-3.5 w-3.5 transition-transform group-focus-within:rotate-180 group-hover:rotate-180" />
</button>
<div className="pointer-events-none invisible absolute left-0 top-full z-50 mt-0 w-56 rounded-[12px] border border-border bg-background p-1.5 opacity-0 shadow-[0_12px_32px_-12px_rgba(0,0,0,0.35)] transition-all duration-150 group-focus-within:pointer-events-auto group-focus-within:visible group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100">
{category.items.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Icon className="h-4 w-4 text-primary" />
{item.label}
</Link>
);
})}
</div>
</div>
);
function titleFromSegment(segment: string): string {
return segment.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function Breadcrumbs() {
const pathname = usePathname();
if (pathname === "/") return null;
if (!pathname || pathname === "/") return null;
const segments = pathname.split("/").filter(Boolean);
const breadcrumbs = segments.map((segment, index) => {
const crumbs = segments.map((segment, index) => {
const href = "/" + segments.slice(0, index + 1).join("/");
const label = segment
.replace(/-/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase());
return { href, label };
const key = href.replace(/\/$/, "");
return {
href: href.endsWith("/") ? href : `${href}/`,
label: pageTitles[key] ?? titleFromSegment(segment),
};
});
const getPageTitle = (path: string): string => {
for (const category of navCategories) {
for (const item of category.items) {
if (item.href === path || item.href === path + "/") {
return item.label;
}
}
}
return (
path
.split("/")
.pop()
?.replace(/-/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase()) || ""
);
};
return (
<nav className="mb-8 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link href="/" className="transition-colors hover:text-foreground">
Home
</Link>
{breadcrumbs.map((crumb, index) => (
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link href="/">Home</Link>
{crumbs.map((crumb, index) => (
<React.Fragment key={crumb.href}>
<ChevronRight className="h-4 w-4 shrink-0" />
{index === breadcrumbs.length - 1 ? (
<span className="font-medium text-foreground">
{getPageTitle(crumb.href)}
</span>
<span aria-hidden="true">/</span>
{index === crumbs.length - 1 ? (
<span>{crumb.label}</span>
) : (
<Link
href={crumb.href}
className="transition-colors hover:text-foreground"
>
{crumb.label}
</Link>
<Link href={crumb.href}>{crumb.label}</Link>
)}
</React.Fragment>
))}
@@ -197,246 +73,15 @@ function Breadcrumbs() {
);
}
export function SeoSubpageShell({ children }: SeoSubpageShellProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
const onResize = () => {
if (window.innerWidth >= 1024) setMobileMenuOpen(false);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
const mainNavCategories = [
{
label: "Get Started",
items: [
{ href: "/", label: "Home", icon: Home },
{ href: "/install/", label: "Install", icon: BookOpen },
{ href: "/screenshots/", label: "Screenshots", icon: Camera },
{ href: "/changelog/", label: "Changelog", icon: Zap },
],
},
{
label: "Features",
items: [
{ href: "/compare/", label: "Engine Comparison", icon: Cpu },
{ href: "/remote-api/", label: "Remote API", icon: Server },
{ href: "/whisper-model-guide/", label: "Whisper Models", icon: Cpu },
{ href: "/wayland/", label: "Wayland", icon: Monitor },
{ href: "/gpu-acceleration/", label: "GPU Acceleration", icon: Cpu },
{
href: "/voice-activity-detection/",
label: "Silero VAD",
icon: Activity,
},
{
href: "/advanced-settings/",
label: "Advanced Settings",
icon: SlidersHorizontal,
},
{
href: "/desktop-reliability/",
label: "Reliability",
icon: ShieldCheck,
},
],
},
{
label: "Use Cases",
items: [
{ href: "/for-developers/", label: "For Developers", icon: Code2 },
{ href: "/writers/", label: "For Writers", icon: Heart },
{ href: "/voice-typing-vscode/", label: "VS Code", icon: Code2 },
{ href: "/gnome-kde/", label: "GNOME vs KDE", icon: Monitor },
{ href: "/rsi-prevention/", label: "RSI Prevention", icon: Heart },
],
},
{
label: "Compare",
items: [
{
href: "/vs-nerd-dictation/",
label: "vs Nerd Dictation",
icon: BookOpen,
},
{ href: "/alternatives/", label: "Alternatives", icon: BookOpen },
{ href: "/offline/", label: "Offline", icon: Shield },
{ href: "/open-source/", label: "Open Source", icon: Code2 },
],
},
{
label: "Support",
items: [
{ href: "/faq/", label: "FAQ", icon: HelpCircle },
{ href: "/troubleshooting/", label: "Troubleshooting", icon: Zap },
{ href: "/shortcuts/", label: "Commands", icon: BookOpen },
{ href: "/privacy/", label: "Privacy Policy", icon: Shield },
],
},
];
export function SeoSubpageShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen max-w-[100vw] overflow-x-clip bg-background text-foreground">
<header className="fixed inset-x-0 top-0 z-50 border-b border-border/80 bg-background/85 backdrop-blur-md">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:h-16 sm:px-6">
<Link href="/" className="flex items-center gap-2.5">
<VocalinuxLogo width={28} height={28} className="h-7 w-7" />
<span className="font-display text-[15px] font-semibold tracking-tight">
Vocalinux
</span>
</Link>
<nav className="hidden items-center gap-1 lg:flex">
{mainNavCategories.map((category) => (
<DropdownMenu key={category.label} category={category} />
))}
</nav>
<div className="flex items-center gap-2">
<ThemeToggle />
<Link
href="/#install"
className="hidden h-9 items-center rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground sm:inline-flex"
>
Install
</Link>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMobileMenuOpen(!mobileMenuOpen);
}}
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden"
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
aria-expanded={mobileMenuOpen}
>
{mobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</button>
</div>
<SiteChrome variant="page">
<div className="page-main">
<div className="shell">
<Breadcrumbs />
<div className="subpage-content min-w-0">{children}</div>
</div>
{mobileMenuOpen ? (
<div className="border-t border-border bg-background lg:hidden">
<nav className="max-h-[calc(100vh-4rem)] overflow-y-auto px-4 py-4">
{mainNavCategories.map((category) => (
<div key={category.label} className="mb-4">
<h3 className="mb-2 font-mono text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{category.label}
</h3>
<div className="space-y-0.5">
{category.items.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Icon className="h-4 w-4 text-primary" />
{item.label}
</Link>
);
})}
</div>
</div>
))}
<Link
href="/#install"
onClick={() => setMobileMenuOpen(false)}
className="mt-2 flex items-center justify-center rounded-full bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground"
>
Install Vocalinux
</Link>
</nav>
</div>
) : null}
</header>
<div className="mx-auto w-full max-w-6xl px-4 pb-16 pt-24 sm:px-6 sm:pt-28">
<Breadcrumbs />
<div className="subpage-content min-w-0">{children}</div>
</div>
<footer className="border-t border-border bg-[#0a0a0c] text-zinc-300">
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6">
<div className="mb-8 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div>
<p className="text-sm font-semibold text-zinc-100">
Want the full product overview?
</p>
<p className="mt-1 text-sm text-zinc-400">
Homepage has the install command, app screenshots, and engine
guide.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href="/"
className="inline-flex items-center gap-1.5 rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
>
Home
<ChevronRight className="h-4 w-4" />
</Link>
<Link
href="/#install"
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-500"
>
Install
</Link>
<a
href="https://github.com/VocaHQ/vocalinux"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-500"
>
<Github className="h-4 w-4" />
GitHub
</a>
</div>
</div>
<div className="grid gap-6 border-t border-zinc-800 pt-8 sm:grid-cols-2 lg:grid-cols-5">
{mainNavCategories.map((category) => (
<div key={category.label}>
<h4 className="mb-2 font-mono text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
{category.label}
</h4>
<ul className="space-y-1.5">
{category.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="text-sm text-zinc-400 transition-colors hover:text-white"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
<p className="mt-8 border-t border-zinc-800 pt-6 text-sm text-zinc-500">
Vocalinux is part of{" "}
<a
href="https://vocahq.com"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-400 transition-colors hover:text-white"
>
VocaHQ
</a>
.
</p>
</div>
</footer>
</div>
</SiteChrome>
);
}
+260
View File
@@ -0,0 +1,260 @@
"use client";
import React, { useEffect, useId, useState } from "react";
import Link from "next/link";
import { Star } from "lucide-react";
import { VocalinuxLogo } from "@/components/optimized-image";
import { GITHUB_REPO_URL, VOCAHQ_URL } from "@/lib/seo";
import {
footerGroups,
homeNav,
mobileExtraNav,
pageNav,
socialLinks,
type NavLink,
} from "@/lib/site-nav";
const GITHUB_API_REPO_URL = `https://api.github.com/repos/${GITHUB_REPO_URL.replace("https://github.com/", "")}`;
function SmartLink({
href,
className,
children,
onClick,
}: {
href: string;
className?: string;
children: React.ReactNode;
onClick?: () => void;
}) {
const external = href.startsWith("http");
if (external) {
return (
<a
href={href}
className={className}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
}
if (href.startsWith("#")) {
return (
<a href={href} className={className} onClick={onClick}>
{children}
</a>
);
}
return (
<Link href={href} className={className} onClick={onClick}>
{children}
</Link>
);
}
export function SiteChrome({
children,
variant = "page",
}: {
children: React.ReactNode;
variant?: "home" | "page";
}) {
const [open, setOpen] = useState(false);
const [stars, setStars] = useState<number | null>(null);
const navId = useId();
const nav = variant === "home" ? homeNav : pageNav;
const ctaHref = variant === "home" ? "#install" : "/#install";
const extras: NavLink[] =
variant === "home"
? [
{ href: "/screenshots/", label: "Screenshots" },
{ href: "/install/", label: "Distro guides" },
...mobileExtraNav,
]
: mobileExtraNav;
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const onResize = () => {
if (window.innerWidth >= 921) setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onResize);
};
}, []);
useEffect(() => {
fetch(GITHUB_API_REPO_URL)
.then((res) => res.json())
.then((data: { stargazers_count?: number }) => {
if (data.stargazers_count) setStars(data.stargazers_count);
})
.catch(() => setStars(null));
}, []);
return (
<div className="min-h-[100dvh] max-w-[100vw] overflow-x-clip">
<a className="skip-link" href="#main">
Skip to content
</a>
<header className="site-header">
<div className="shell header-inner">
<Link href="/" className="brand" aria-label="Vocalinux home">
<VocalinuxLogo width={28} height={28} className="h-7 w-7" priority />
<span>Vocalinux</span>
</Link>
<nav className="nav-links" aria-label="Primary">
{nav.map((item) => (
<SmartLink key={item.href + item.label} href={item.href}>
{item.label}
</SmartLink>
))}
</nav>
<div className="header-actions">
<a
className="header-stars"
href={GITHUB_REPO_URL}
rel="noopener noreferrer"
aria-label={
stars !== null
? `Vocalinux on GitHub, ${stars.toLocaleString()} stars`
: "Vocalinux on GitHub"
}
>
<Star
className="h-4 w-4"
strokeWidth={1.75}
fill="currentColor"
aria-hidden="true"
/>
<span>{stars !== null ? stars.toLocaleString() : "GitHub"}</span>
</a>
<button
className="nav-toggle"
type="button"
aria-expanded={open}
aria-controls={navId}
aria-label="Toggle menu"
onClick={() => setOpen((value) => !value)}
>
<span className="sr-only">
{open ? "Close navigation" : "Open navigation"}
</span>
<span className="nav-toggle-bars" aria-hidden="true">
<i></i>
<i></i>
<i></i>
</span>
</button>
</div>
</div>
<nav
id={navId}
className="mobile-nav shell"
aria-label="Site"
hidden={!open}
>
{[...nav, ...extras].map((item) => (
<SmartLink
key={`m-${item.href}-${item.label}`}
href={item.href}
onClick={() => setOpen(false)}
>
{item.label}
</SmartLink>
))}
<a
className="btn btn-primary"
href={ctaHref}
onClick={() => setOpen(false)}
>
Install Vocalinux
</a>
</nav>
</header>
<main id="main">{children}</main>
<footer className="site-footer">
<div className="shell">
<div className="footer-inner">
<div className="footer-brand">
<Link href="/" className="brand">
<VocalinuxLogo width={28} height={28} className="h-7 w-7" />
<span>Vocalinux</span>
</Link>
<p>
Offline voice typing for Linux. Local engines by default, no
required account.
</p>
<ul className="footer-social">
{socialLinks.map((item) => (
<li key={item.href}>
<a
href={item.href}
{...(item.href.startsWith("http")
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
aria-label={item.label}
title={item.label}
>
<span
className="footer-social-icon"
style={{
maskImage: `url(${item.icon})`,
WebkitMaskImage: `url(${item.icon})`,
}}
/>
</a>
</li>
))}
</ul>
</div>
{footerGroups.map((group) => (
<div className="footer-col" key={group.title}>
<h2>{group.title}</h2>
<ul>
{group.links.map((link) => (
<li key={link.href + link.label}>
<SmartLink href={link.href}>{link.label}</SmartLink>
</li>
))}
</ul>
</div>
))}
</div>
<div className="footer-base">
<span>
AGPL-3.0. Built in public by{" "}
<a
href="https://github.com/jatinkrmalik"
rel="noopener noreferrer"
>
Jatin K Malik
</a>
.
</span>
<span>
Part of{" "}
<a href={VOCAHQ_URL} rel="noopener noreferrer">
VocaHQ
</a>
</span>
</div>
</div>
</footer>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import React, { useCallback, useState } from "react";
import { Check, Copy } from "lucide-react";
export function TerminalPrompt() {
return (
<span className="terminal-prompt">
<span className="terminal-user">user</span>
<span className="terminal-at">@</span>
<span className="terminal-host">linux</span>
<span className="terminal-path">:~</span>
<span className="terminal-hash">$ </span>
</span>
);
}
export function TerminalBlock({
command,
displayCommand,
label = "terminal",
}: {
command: string;
displayCommand: string;
label?: string;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(command);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
}, [command]);
return (
<div className="terminal-panel min-w-0">
<div className="terminal-chrome">
<button
type="button"
onClick={handleCopy}
title={copied ? "Copied" : "Copy"}
className={`terminal-copy${copied ? " is-copied" : ""}`}
aria-label={copied ? "Copied to clipboard" : "Copy to clipboard"}
>
{copied ? (
<Check className="h-4 w-4" strokeWidth={2} />
) : (
<Copy className="h-4 w-4" strokeWidth={1.75} />
)}
</button>
<span className="window-title">{label}</span>
<span className="linux-controls" aria-hidden="true">
<i></i>
<i></i>
<i className="close"></i>
</span>
</div>
<pre className="max-w-full min-w-0 overflow-x-auto break-all">
<TerminalPrompt />
{displayCommand}
</pre>
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import React from "react";
type Attribute = "class" | "data-theme" | "data-mode";
// Define the props type with correct attribute type
interface ThemeProviderProps {
children: React.ReactNode;
attribute?: Attribute | Attribute[];
defaultTheme?: string;
enableSystem?: boolean;
disableTransitionOnChange?: boolean;
storageKey?: string;
forcedTheme?: string;
themes?: string[];
}
export function ThemeProvider({
children,
...props
}: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
-114
View File
@@ -1,114 +0,0 @@
"use client";
import Image, { type ImageProps } from "next/image";
import React from "react";
type CommonProps = {
src: string;
srcDark: string;
alt: string;
className?: string;
};
/**
* Renders light and dark screenshot variants. Visibility follows the
* `dark` class on `<html>` from next-themes, so the active theme's image
* shows without a hydration flash.
*
* Default loading is "eager" for both siblings so the inactive theme is
* already fetched when the user toggles. Callers can still pass "lazy"
* for below-the-fold galleries (both siblings share the value).
*/
export function ThemeScreenshotImg({
src,
srcDark,
alt,
width,
height,
className = "",
loading = "eager",
}: CommonProps & {
width: number;
height: number;
loading?: "lazy" | "eager";
}) {
const shared = `${className}`.trim();
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
width={width}
height={height}
className={`${shared} dark:hidden`.trim()}
loading={loading}
decoding="async"
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={srcDark}
alt={alt}
width={width}
height={height}
className={`${shared} hidden dark:block`.trim()}
loading={loading}
decoding="async"
/>
</>
);
}
type ThemeScreenshotNextProps = CommonProps &
Omit<ImageProps, "src" | "alt"> & {
src: string;
srcDark: string;
alt: string;
};
/**
* next/image twin for homepage embeds that already use Image.
*
* `priority` only applies to the light (default) image so Next preloads a
* single LCP candidate. The dark twin uses eager loading so theme toggles
* still have the asset ready without a second priority preload.
*/
export function ThemeScreenshotImage({
src,
srcDark,
alt,
className = "",
priority,
loading,
...rest
}: ThemeScreenshotNextProps) {
const shared = `${className}`.trim();
const darkLoading = priority ? "eager" : loading;
return (
<>
<Image
src={src}
alt={alt}
className={`${shared} dark:hidden`.trim()}
priority={priority}
loading={priority ? undefined : loading}
{...rest}
/>
<Image
src={srcDark}
alt={alt}
className={`${shared} hidden dark:block`.trim()}
loading={darkLoading}
{...rest}
/>
</>
);
}
/** Derive `/screenshots/dark/<file>` from a light `/screenshots/<file>` path. */
export function darkScreenshotPath(src: string): string {
if (src.includes("/dark/")) {
return src;
}
return src.replace("/screenshots/", "/screenshots/dark/");
}
-29
View File
@@ -1,29 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { motion } from "framer-motion";
export function ThemeToggle() {
const [mounted, setMounted] = useState(false);
const {
theme,
setTheme
} = useTheme();
// Wait for component to be mounted to avoid hydration mismatch
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="w-9 h-9 bg-muted rounded-md flex items-center justify-center">
<span className="sr-only">Loading theme toggle</span>
</div>;
}
return <motion.button whileTap={{
scale: 0.95
}} onClick={() => setTheme(theme === "dark" ? "light" : "dark")} className="w-8 h-8 sm:w-9 sm:h-9 bg-muted hover:bg-muted/80 rounded-md flex items-center justify-center transition-all" aria-label="Toggle theme">
{theme === "dark" ? <Sun className="h-4 w-4 sm:h-[1.2rem] sm:w-[1.2rem] text-yellow-400" /> : <Moon className="h-4 w-4 sm:h-[1.2rem] sm:w-[1.2rem] text-slate-700" />}
<span className="sr-only">Toggle theme</span>
</motion.button>;
}