docs(web): refresh screenshots for v0.15 (light/dark) (#630)
* docs(web): refresh screenshots for v0.15 sidebar settings UI Recapture product and settings gallery shots against the searchable sidebar settings dialog, keeping the previous asset filenames and exact pixel dimensions with wallpaper padding around each window. * fix(web): correct screenshot semantics after adversarial review Keep the existing transcription and tray images because the replacements did not show their claimed flows. Populate the Logs dialog and unlock Advanced settings so those refreshed images demonstrate the features described by the gallery. * fix(web): retake screenshots as square crops with uniform padding Resize settings dialogs to a near 1:1 window, capture with ~28px wallpaper padding on all sides, open About from the tray menu, and skip redoing the awkward XFCE system-tray asset. * docs(web): retake v0.15 screenshots on GNOME (light/dark) Window-only captures from Ubuntu/GNOME at 960x720 for settings and logs, with matching dark variants under screenshots/dark/. Replace the old Shortcuts shot with Performance to match the sidebar. * feat(web): theme-aware light/dark screenshot switching Serve paired GNOME captures and swap them with the site theme via dark:hidden / hidden dark:block. Replace Shortcuts gallery entry with Performance to match the v0.15 sidebar. * docs(web): refresh screenshots after About moves into Settings Rebase onto main brought the in-app update checker: About is now a sidebar settings page. Recapture light/dark GNOME panels at 960x720 and update gallery copy/metadata accordingly. * docs: use single screenshot source for README and website Point README images at web/public/screenshots so product shots live in one place, drop the unused resources/screenshots tree (including the old demo video and about-updates set), and replace the missing shortcuts shot with performance. * docs(web): refresh light/dark screenshots and product grid Replace the screenshot set with fresh captures for both themes, and lay out the three Product shots in a single equal-height row. * fix(web): align screenshot metadata, sizes, and theme loading Restore dictation copy for the 00-transcription asset, set width/height from the recaptured PNGs, preload only the light next/image twin when priority is set, and eager-load both theme variants by default so dark mode does not flash empty on first toggle. * fix(web): show full settings screenshot without crop Use object-contain so the homepage settings dialog fits the frame instead of object-cover clipping the sidebar.
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/56dabe5c-5c65-44d5-a36a-429c9fea0719" width="50" height="50" alt="Vocalinux">
|
||||
<img src="https://github.com/user-attachments/assets/56dabe5c-5c65-44d5-a36a-429c9fea0719" width="50" height="50" alt="Vocalinux">
|
||||
|
||||
# Vocalinux
|
||||
|
||||
@@ -93,21 +93,21 @@ Vocalinux in action. Settings gallery shots may lag the searchable sidebar UI fr
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<img src="resources/screenshots/00-transcription.png" alt="Transcription in Action" width="350"><br>
|
||||
<img src="web/public/screenshots/00-transcription.png" alt="Transcription in Action" width="350"><br>
|
||||
<em>Real-time voice-to-text transcription</em>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<img src="resources/screenshots/02-system-tray.png" alt="System Tray" width="350"><br>
|
||||
<img src="web/public/screenshots/02-system-tray.png" alt="System Tray" width="350"><br>
|
||||
<em>System tray with listening indicator</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="resources/screenshots/05-about-view.png" alt="About View" width="350"><br>
|
||||
<img src="web/public/screenshots/05-about-view.png" alt="About View" width="350"><br>
|
||||
<em>About & Updates in Settings</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="resources/screenshots/03-log-viewer.png" alt="Log Viewer" width="350"><br>
|
||||
<img src="web/public/screenshots/03-log-viewer.png" alt="Log Viewer" width="350"><br>
|
||||
<em>Log viewer for debugging</em>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -118,29 +118,29 @@ Vocalinux in action. Settings gallery shots may lag the searchable sidebar UI fr
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%">
|
||||
<img src="resources/screenshots/settings-speech-engine.png" alt="Speech Engine settings" width="260"><br>
|
||||
<img src="web/public/screenshots/settings-speech-engine.png" alt="Speech Engine settings" width="260"><br>
|
||||
<em>Speech Engine</em>
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<img src="resources/screenshots/settings-recognition.png" alt="Recognition settings" width="260"><br>
|
||||
<img src="web/public/screenshots/settings-recognition.png" alt="Recognition settings" width="260"><br>
|
||||
<em>Recognition</em>
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<img src="resources/screenshots/settings-audio.png" alt="Audio settings" width="260"><br>
|
||||
<img src="web/public/screenshots/settings-audio.png" alt="Audio settings" width="260"><br>
|
||||
<em>Audio</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="resources/screenshots/settings-shortcuts.png" alt="Shortcuts and hotkeys" width="260"><br>
|
||||
<em>Shortcuts & Hotkeys</em>
|
||||
<img src="web/public/screenshots/settings-performance.png" alt="Performance settings" width="260"><br>
|
||||
<em>Performance</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="resources/screenshots/settings-general.png" alt="General settings" width="260"><br>
|
||||
<img src="web/public/screenshots/settings-general.png" alt="General settings" width="260"><br>
|
||||
<em>General</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="resources/screenshots/settings-advanced.png" alt="Advanced tuning and settings" width="260"><br>
|
||||
<img src="web/public/screenshots/settings-advanced.png" alt="Advanced tuning and settings" width="260"><br>
|
||||
<em>Advanced</em>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 914 KiB After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 379 KiB After Width: | Height: | Size: 484 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 914 KiB After Width: | Height: | Size: 914 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 243 KiB |
@@ -12,6 +12,7 @@ describe("Screenshots page and assets", () => {
|
||||
const shellPath = path.join(webRoot, "src/components/seo-subpage-shell.tsx");
|
||||
const homePath = path.join(webRoot, "src/app/page.tsx");
|
||||
const publicScreenshotsDir = path.join(webRoot, "public/screenshots");
|
||||
const darkScreenshotsDir = path.join(publicScreenshotsDir, "dark");
|
||||
|
||||
const expectedAssets = [
|
||||
"00-transcription.png",
|
||||
@@ -21,7 +22,7 @@ describe("Screenshots page and assets", () => {
|
||||
"settings-speech-engine.png",
|
||||
"settings-recognition.png",
|
||||
"settings-audio.png",
|
||||
"settings-shortcuts.png",
|
||||
"settings-performance.png",
|
||||
"settings-general.png",
|
||||
"settings-advanced.png",
|
||||
];
|
||||
@@ -51,6 +52,7 @@ describe("Screenshots page and assets", () => {
|
||||
expect(gallerySource).toContain('role="dialog"');
|
||||
expect(gallerySource).toContain("aria-modal");
|
||||
expect(gallerySource).toContain("View larger");
|
||||
expect(gallerySource).toContain("ThemeScreenshotImg");
|
||||
});
|
||||
|
||||
it("references every public screenshot asset from the page source", () => {
|
||||
@@ -59,7 +61,15 @@ describe("Screenshots page and assets", () => {
|
||||
expect(fs.existsSync(assetPath)).toBe(true);
|
||||
expect(fs.statSync(assetPath).size).toBeGreaterThan(1000);
|
||||
expect(pageSource).toContain(`/screenshots/${file}`);
|
||||
|
||||
const darkPath = path.join(darkScreenshotsDir, file);
|
||||
expect(fs.existsSync(darkPath)).toBe(true);
|
||||
expect(fs.statSync(darkPath).size).toBeGreaterThan(1000);
|
||||
expect(pageSource).toContain(`/screenshots/dark/${file}`);
|
||||
}
|
||||
expect(fs.existsSync(path.join(publicScreenshotsDir, "settings-shortcuts.png"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("links to the gallery and only embeds a few product shots on home", () => {
|
||||
@@ -67,10 +77,11 @@ describe("Screenshots page and assets", () => {
|
||||
expect(homeSource).toContain('href: "/screenshots/"');
|
||||
expect(homeSource).toContain('href="/screenshots/"');
|
||||
expect(homeSource).toContain("/screenshots/00-transcription.png");
|
||||
expect(homeSource).toContain("/screenshots/dark/00-transcription.png");
|
||||
expect(homeSource).not.toContain("/screenshots/05-about-view.png");
|
||||
expect(homeSource).not.toContain("/screenshots/settings-recognition.png");
|
||||
expect(homeSource).not.toContain("/screenshots/settings-audio.png");
|
||||
expect(homeSource).not.toContain("/screenshots/settings-shortcuts.png");
|
||||
expect(homeSource).not.toContain("/screenshots/settings-performance.png");
|
||||
expect(homeSource).not.toContain("/screenshots/settings-advanced.png");
|
||||
});
|
||||
|
||||
@@ -80,13 +91,17 @@ describe("Screenshots page and assets", () => {
|
||||
});
|
||||
|
||||
it("includes captioned settings and product sections", () => {
|
||||
expect(pageSource).toContain("Speech Engine");
|
||||
expect(pageSource).toContain("Recognition");
|
||||
expect(pageSource).toContain("Speech Model");
|
||||
expect(pageSource).toContain("Dictation");
|
||||
expect(pageSource).toContain("Audio");
|
||||
expect(pageSource).toContain("Shortcuts");
|
||||
expect(pageSource).toContain("General");
|
||||
expect(pageSource).toContain("Performance");
|
||||
expect(pageSource).toContain("Application");
|
||||
expect(pageSource).toContain("Advanced");
|
||||
expect(pageSource).toContain("Transcription in action");
|
||||
expect(pageSource).toContain("Dictation in action");
|
||||
expect(pageSource).toContain("About");
|
||||
expect(pageSource).toContain("update checker");
|
||||
expect(pageSource).not.toContain("Shortcuts & Hotkeys");
|
||||
expect(pageSource).not.toContain("About dialog");
|
||||
expect(pageSource).not.toContain("Settings overview");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Check,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { VocalinuxLogo } from "@/components/optimized-image";
|
||||
import { ThemeScreenshotImage } from "@/components/theme-screenshot";
|
||||
|
||||
// Always uses main/install.sh. The installer dynamically resolves the latest release tag via GitHub API
|
||||
const installCommands = {
|
||||
@@ -516,16 +516,17 @@ export default function HomePage() {
|
||||
aria-hidden
|
||||
/>
|
||||
<figure className="shot-frame relative">
|
||||
<Image
|
||||
<ThemeScreenshotImage
|
||||
src="/screenshots/00-transcription.png"
|
||||
alt="Vocalinux tray overlay showing live speech transcription on Linux"
|
||||
width={1280}
|
||||
height={800}
|
||||
srcDark="/screenshots/dark/00-transcription.png"
|
||||
alt="Vocalinux dictating into a text editor with the tray menu open"
|
||||
width={2518}
|
||||
height={2057}
|
||||
priority
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
<figcaption className="flex items-center justify-between border-t border-border px-4 py-3 text-xs text-muted-foreground">
|
||||
<span>Live transcription overlay</span>
|
||||
<span>Dictation into any focused app</span>
|
||||
<Link
|
||||
href="/screenshots/"
|
||||
className="inline-flex items-center gap-1 font-medium text-foreground hover:text-primary"
|
||||
@@ -616,19 +617,19 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Paired layout: crop the tall settings shot to a fixed frame so it
|
||||
matches a full feature rail (all six items), instead of three short
|
||||
cards stranded beside a full dialog screenshot.
|
||||
Paired layout: settings shot stretches with the feature rail.
|
||||
object-contain keeps the full dialog visible (no side crop).
|
||||
*/}
|
||||
<div className="grid items-stretch gap-5 lg:grid-cols-12 lg:gap-6">
|
||||
<figure className="shot-frame flex h-full min-h-0 flex-col lg:col-span-7">
|
||||
<div className="relative min-h-[16rem] flex-1 overflow-hidden bg-zinc-950 sm:min-h-[18rem]">
|
||||
<Image
|
||||
<ThemeScreenshotImage
|
||||
src="/screenshots/settings-speech-engine.png"
|
||||
srcDark="/screenshots/dark/settings-speech-engine.png"
|
||||
alt="Vocalinux settings dialog showing speech engine options"
|
||||
width={1200}
|
||||
height={800}
|
||||
className="absolute inset-0 h-full w-full object-cover object-top"
|
||||
width={2128}
|
||||
height={1698}
|
||||
className="absolute inset-0 h-full w-full object-contain p-2 sm:p-3"
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="shrink-0 border-t border-border px-4 py-2.5 text-sm text-muted-foreground">
|
||||
@@ -664,12 +665,13 @@ export default function HomePage() {
|
||||
|
||||
<div className="mt-5 grid items-stretch gap-5 lg:grid-cols-12 lg:gap-6">
|
||||
<figure className="shot-frame lg:col-span-5">
|
||||
<Image
|
||||
<ThemeScreenshotImage
|
||||
src="/screenshots/02-system-tray.png"
|
||||
srcDark="/screenshots/dark/02-system-tray.png"
|
||||
alt="Vocalinux system tray icon and menu"
|
||||
width={900}
|
||||
height={700}
|
||||
className="h-auto max-h-[18rem] w-full object-cover object-top sm:max-h-[20rem]"
|
||||
width={315}
|
||||
height={466}
|
||||
className="mx-auto h-auto max-h-[18rem] w-auto object-contain sm:max-h-[20rem]"
|
||||
/>
|
||||
<figcaption className="border-t border-border px-4 py-2.5 text-sm text-muted-foreground">
|
||||
Tray status while you work
|
||||
|
||||
@@ -8,97 +8,104 @@ import {
|
||||
import { SeoSubpageShell } from "@/components/seo-subpage-shell";
|
||||
import { absoluteUrl, buildPageMetadata } from "@/lib/seo";
|
||||
|
||||
/** Pixel size of the captured settings/log dialog screenshots. */
|
||||
const SETTINGS_SIZE = { width: 2128, height: 1698 } as const;
|
||||
|
||||
const productShots: Screenshot[] = [
|
||||
{
|
||||
src: "/screenshots/00-transcription.png",
|
||||
alt: "Vocalinux real-time transcription in action",
|
||||
title: "Transcription in action",
|
||||
description: "Dictate into any Linux app with real-time voice-to-text.",
|
||||
width: 2565,
|
||||
height: 2018,
|
||||
srcDark: "/screenshots/dark/00-transcription.png",
|
||||
alt: "Vocalinux dictating into a text editor with the tray menu open",
|
||||
title: "Dictation in action",
|
||||
description:
|
||||
"Voice typing into a focused app while the tray menu shows listening state.",
|
||||
width: 2518,
|
||||
height: 2057,
|
||||
},
|
||||
{
|
||||
src: "/screenshots/02-system-tray.png",
|
||||
alt: "Vocalinux system tray listening indicator",
|
||||
srcDark: "/screenshots/dark/02-system-tray.png",
|
||||
alt: "Vocalinux system tray menu with voice typing controls",
|
||||
title: "System tray",
|
||||
description: "Tray icon shows idle, listening, and processing states.",
|
||||
width: 565,
|
||||
height: 453,
|
||||
},
|
||||
{
|
||||
src: "/screenshots/05-about-view.png",
|
||||
alt: "Vocalinux About dialog",
|
||||
title: "About",
|
||||
description: "Version info, credits, and project links.",
|
||||
width: 1012,
|
||||
height: 854,
|
||||
description: "Tray menu for start/stop, settings, logs, and about.",
|
||||
width: 315,
|
||||
height: 466,
|
||||
},
|
||||
{
|
||||
src: "/screenshots/03-log-viewer.png",
|
||||
srcDark: "/screenshots/dark/03-log-viewer.png",
|
||||
alt: "Vocalinux log viewer dialog",
|
||||
title: "Log viewer",
|
||||
description: "Inspect runtime logs when diagnosing dictation issues.",
|
||||
width: 1904,
|
||||
height: 1578,
|
||||
...SETTINGS_SIZE,
|
||||
},
|
||||
];
|
||||
|
||||
const settingsShots: Screenshot[] = [
|
||||
{
|
||||
src: "/screenshots/settings-speech-engine.png",
|
||||
alt: "Speech Engine settings tab",
|
||||
title: "Speech Engine",
|
||||
srcDark: "/screenshots/dark/settings-speech-engine.png",
|
||||
alt: "Vocalinux Speech Model settings with sidebar navigation",
|
||||
title: "Speech Model",
|
||||
description:
|
||||
"Choose whisper.cpp, Whisper, VOSK, or Remote API and pick a model.",
|
||||
width: 1504,
|
||||
height: 2146,
|
||||
...SETTINGS_SIZE,
|
||||
},
|
||||
{
|
||||
src: "/screenshots/settings-recognition.png",
|
||||
alt: "Recognition settings tab",
|
||||
title: "Recognition",
|
||||
description: "Language, voice commands, and recognition behavior.",
|
||||
width: 1504,
|
||||
height: 2146,
|
||||
srcDark: "/screenshots/dark/settings-recognition.png",
|
||||
alt: "Vocalinux Dictation settings page",
|
||||
title: "Dictation",
|
||||
description: "Shortcuts, listening controls, and dictation output options.",
|
||||
...SETTINGS_SIZE,
|
||||
},
|
||||
{
|
||||
src: "/screenshots/settings-audio.png",
|
||||
alt: "Audio settings tab",
|
||||
srcDark: "/screenshots/dark/settings-audio.png",
|
||||
alt: "Vocalinux Audio settings page",
|
||||
title: "Audio",
|
||||
description: "Input device, feedback sounds, and capture options.",
|
||||
width: 1504,
|
||||
height: 2146,
|
||||
...SETTINGS_SIZE,
|
||||
},
|
||||
{
|
||||
src: "/screenshots/settings-shortcuts.png",
|
||||
alt: "Shortcuts and hotkeys settings tab",
|
||||
title: "Shortcuts & Hotkeys",
|
||||
description: "Toggle mode, push-to-talk, and custom key bindings.",
|
||||
width: 1504,
|
||||
height: 2146,
|
||||
src: "/screenshots/settings-performance.png",
|
||||
srcDark: "/screenshots/dark/settings-performance.png",
|
||||
alt: "Vocalinux Performance settings page",
|
||||
title: "Performance",
|
||||
description: "Auto-pause, model keep-alive, and GPU device selection.",
|
||||
...SETTINGS_SIZE,
|
||||
},
|
||||
{
|
||||
src: "/screenshots/settings-general.png",
|
||||
alt: "General settings tab",
|
||||
title: "General",
|
||||
description: "Autostart, UI preferences, and everyday defaults.",
|
||||
width: 1504,
|
||||
height: 2146,
|
||||
srcDark: "/screenshots/dark/settings-general.png",
|
||||
alt: "Vocalinux Application settings page",
|
||||
title: "Application",
|
||||
description: "Autostart, start minimized, and everyday defaults.",
|
||||
...SETTINGS_SIZE,
|
||||
},
|
||||
{
|
||||
src: "/screenshots/settings-advanced.png",
|
||||
alt: "Advanced tuning and settings tab",
|
||||
srcDark: "/screenshots/dark/settings-advanced.png",
|
||||
alt: "Vocalinux Advanced settings with whisper.cpp decoding controls",
|
||||
title: "Advanced",
|
||||
description: "Power-user decoding controls and Remote Server options.",
|
||||
width: 1504,
|
||||
height: 2146,
|
||||
...SETTINGS_SIZE,
|
||||
},
|
||||
{
|
||||
src: "/screenshots/05-about-view.png",
|
||||
srcDark: "/screenshots/dark/05-about-view.png",
|
||||
alt: "Vocalinux About settings page with update checker",
|
||||
title: "About",
|
||||
description:
|
||||
"Version info plus Stable/Nightly update checks from GitHub Releases.",
|
||||
...SETTINGS_SIZE,
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
title: "Vocalinux Screenshots | Linux Voice Dictation UI",
|
||||
description:
|
||||
"See Vocalinux in action: system tray, about dialog, log viewer, and settings pages for speech engine, recognition, audio, shortcuts, general, and advanced options.",
|
||||
"See Vocalinux in action: system tray, log viewer, and settings pages for speech model, dictation, audio, performance, application, advanced options, and about/updates. Light and dark themes included.",
|
||||
path: "/screenshots",
|
||||
keywords: [
|
||||
"Vocalinux screenshots",
|
||||
@@ -116,7 +123,7 @@ export default function ScreenshotsPage() {
|
||||
name: "Vocalinux Screenshots",
|
||||
description:
|
||||
"Product and settings screenshots of Vocalinux, offline voice dictation for Linux.",
|
||||
dateModified: "2026-07-15",
|
||||
dateModified: "2026-08-03", // refreshed after About moved into Settings sidebar
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: "Jatin K Malik",
|
||||
@@ -143,17 +150,16 @@ export default function ScreenshotsPage() {
|
||||
<section>
|
||||
<p className="border-primary/30 bg-primary/10 mb-4 inline-flex items-center gap-2 rounded-full border px-4 py-1.5 text-sm font-medium text-primary">
|
||||
<Camera className="h-4 w-4" />
|
||||
Settings gallery (refresh pending)
|
||||
v0.15 UI
|
||||
</p>
|
||||
<h1 className="mb-5 font-display text-4xl font-semibold tracking-tight sm:text-5xl">
|
||||
Vocalinux Screenshots
|
||||
</h1>
|
||||
<p className="mb-8 max-w-4xl text-lg text-muted-foreground">
|
||||
A look at offline voice dictation on Linux: tray controls, debugging
|
||||
tools, and the settings dialog. Click any image to expand it and browse
|
||||
the gallery. Product shots are current; some settings images still show
|
||||
the older tabbed layout while the app uses a searchable sidebar in
|
||||
v0.15.0+.
|
||||
tools, and the searchable sidebar settings UI from v0.15. Toggle the
|
||||
site theme to switch between light and dark app shots. Click any image
|
||||
to expand it and browse the gallery.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -179,7 +185,7 @@ export default function ScreenshotsPage() {
|
||||
</Link>
|
||||
<Link
|
||||
href="/advanced-settings/"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-[12px] border border-border bg-background px-5 py-3 text-sm font-semibold transition-colors hover:bg-muted hover:bg-muted"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-[12px] border border-border bg-background px-5 py-3 text-sm font-semibold transition-colors hover:bg-muted"
|
||||
>
|
||||
Advanced settings guide
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
const productShots: Screenshot[] = [
|
||||
{
|
||||
src: "/screenshots/00-transcription.png",
|
||||
srcDark: "/screenshots/dark/00-transcription.png",
|
||||
alt: "Transcription alt",
|
||||
title: "Transcription in action",
|
||||
description: "Dictate into apps",
|
||||
@@ -17,6 +18,7 @@ const productShots: Screenshot[] = [
|
||||
},
|
||||
{
|
||||
src: "/screenshots/02-system-tray.png",
|
||||
srcDark: "/screenshots/dark/02-system-tray.png",
|
||||
alt: "Tray alt",
|
||||
title: "System tray",
|
||||
description: "Tray states",
|
||||
@@ -28,6 +30,7 @@ const productShots: Screenshot[] = [
|
||||
const settingsShots: Screenshot[] = [
|
||||
{
|
||||
src: "/screenshots/settings-speech-engine.png",
|
||||
srcDark: "/screenshots/dark/settings-speech-engine.png",
|
||||
alt: "Speech engine alt",
|
||||
title: "Speech Engine",
|
||||
description: "Pick a model",
|
||||
@@ -57,7 +60,16 @@ describe("ScreenshotGallery lightbox", () => {
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
// Settings shot is third overall (index 2 -> 3/3)
|
||||
expect(within(dialog).getByText(/3 \/ 3/)).toBeInTheDocument();
|
||||
expect(within(dialog).getByAltText("Speech engine alt")).toBeInTheDocument();
|
||||
const speechImgs = within(dialog).getAllByAltText("Speech engine alt");
|
||||
expect(speechImgs.length).toBe(2);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -78,19 +90,23 @@ describe("ScreenshotGallery lightbox", () => {
|
||||
|
||||
let dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText(/1 \/ 3/)).toBeInTheDocument();
|
||||
expect(within(dialog).getByAltText("Transcription alt")).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getAllByAltText("Transcription alt").length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Next screenshot" }));
|
||||
dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText(/2 \/ 3/)).toBeInTheDocument();
|
||||
expect(within(dialog).getByAltText("Tray alt")).toBeInTheDocument();
|
||||
expect(within(dialog).getAllByAltText("Tray alt").length).toBeGreaterThan(0);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Previous screenshot" }),
|
||||
);
|
||||
dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText(/1 \/ 3/)).toBeInTheDocument();
|
||||
expect(within(dialog).getByAltText("Transcription alt")).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getAllByAltText("Transcription alt").length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
@@ -113,17 +129,18 @@ describe("ScreenshotGallery lightbox", () => {
|
||||
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByAltText("Tray alt"),
|
||||
).toBeInTheDocument();
|
||||
within(screen.getByRole("dialog")).getAllByAltText("Tray alt").length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByAltText("Speech engine alt"),
|
||||
).toBeInTheDocument();
|
||||
within(screen.getByRole("dialog")).getAllByAltText("Speech engine alt")
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.keyDown(window, { key: "ArrowLeft" });
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByAltText("Tray alt"),
|
||||
).toBeInTheDocument();
|
||||
within(screen.getByRole("dialog")).getAllByAltText("Tray alt").length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,15 @@ 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;
|
||||
description: string;
|
||||
@@ -19,6 +25,10 @@ export type Screenshot = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
function shotDarkSrc(shot: Screenshot): string {
|
||||
return shot.srcDark ?? darkScreenshotPath(shot.src);
|
||||
}
|
||||
|
||||
type ScreenshotGalleryProps = {
|
||||
productShots: Screenshot[];
|
||||
settingsShots: Screenshot[];
|
||||
@@ -32,23 +42,20 @@ function ScreenshotCard({
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<figure className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="group relative block w-full cursor-zoom-in 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-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"
|
||||
aria-label={`View larger: ${shot.title}`}
|
||||
>
|
||||
{/* Plain img avoids flaky next/image HMR chunk errors in dev */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<ThemeScreenshotImg
|
||||
src={shot.src}
|
||||
srcDark={shotDarkSrc(shot)}
|
||||
alt={shot.alt}
|
||||
width={shot.width}
|
||||
height={shot.height}
|
||||
className="mx-auto h-auto w-full rounded-lg object-contain transition-transform duration-200 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="mx-auto h-auto max-h-64 w-full rounded-lg object-contain transition-transform duration-200 group-hover:scale-[1.02]"
|
||||
/>
|
||||
<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">
|
||||
<Expand className="h-3.5 w-3.5" />
|
||||
@@ -155,11 +162,14 @@ function Lightbox({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative flex max-h-[min(80vh,900px)] w-full items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- full-res lightbox; avoids Next Image constraints in overlay */}
|
||||
<img
|
||||
<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"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-2xl px-2 text-center text-white">
|
||||
@@ -219,7 +229,7 @@ export function ScreenshotGallery({
|
||||
<ImageIcon className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-2xl font-bold">Product</h2>
|
||||
</div>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 items-stretch gap-6 sm:grid-cols-3">
|
||||
{productShots.map((shot, index) => (
|
||||
<ScreenshotCard
|
||||
key={shot.src}
|
||||
@@ -236,8 +246,9 @@ export function ScreenshotGallery({
|
||||
<h2 className="text-2xl font-bold">Settings</h2>
|
||||
</div>
|
||||
<p className="mb-6 max-w-3xl text-muted-foreground">
|
||||
Every major settings tab, from speech engine selection through
|
||||
advanced whisper.cpp tuning. Click any shot to expand it.
|
||||
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.
|
||||
</p>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{settingsShots.map((shot, index) => (
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"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/");
|
||||
}
|
||||