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.
This commit is contained in:
Jatin K Malik
2026-08-02 23:48:17 -07:00
committed by GitHub
parent fe9584e21e
commit a320801d30
43 changed files with 277 additions and 112 deletions
+12 -12
View File
@@ -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 &amp; 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 &amp; 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>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 KiB

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Before

Width:  |  Height:  |  Size: 914 KiB

After

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 243 KiB

+22 -7
View File
@@ -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");
});
});
+19 -17
View File
@@ -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
+59 -53
View File
@@ -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);
});
});
+24 -13
View File
@@ -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) => (
+114
View File
@@ -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/");
}