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
+1 -1
View File
@@ -43,7 +43,7 @@ npm run test:viewport
- Home: `src/app/page.tsx`
- Shared SEO shell: `src/components/seo-subpage-shell.tsx`
- Tokens / utilities: `src/styles/globals.css`, `tailwind.config.ts`
- Fonts: Bricolage Grotesque + Source Sans 3 in `src/app/layout.tsx`
- Fonts: system display + body stacks in `src/styles/globals.css` (no remote webfonts)
## Maintaining this file
+107 -111
View File
@@ -1,166 +1,162 @@
---
name: Vocalinux website
description: Offline Linux voice dictation marketing site — workstation craft, not SaaS chrome
description: Linux voice dictation marketing site in the Voca family workbench language
colors:
ink: "#09090b"
paper: "#ffffff"
paper-raised: "#f4f4f5"
surface-muted: "#f4f4f5"
primary: "#1a7f4e"
primary-deep: "#14663e"
primary-soft: "#ecfdf3"
terminal: "#0a0a0c"
terminal-fg: "#6ee7a8"
muted: "#52525b"
border: "#e4e4e7"
status-no: "#c45c5c"
status-no-dark: "#e08b8b"
dark-bg: "#0c0c0e"
dark-raised: "#16161a"
dark-muted: "#a1a1aa"
paper: "#f4f1e8"
paper-deep: "#ebe5d8"
paper-bright: "#fffdf7"
ink: "#14231c"
muted-copy: "#58625c"
faint: "#5f6861"
line: "#c9c8bd"
brand: "#0f6b57"
brand-dark: "#0b493d"
brand-soft: "#cfe9dc"
brand-softer: "#e5f2eb"
dark-ink: "#0b1a15"
sun: "#e9b949"
red: "#de6a57"
terminal: "#0b1a15"
terminal-fg: "#cfe9dc"
typography:
display:
fontFamily: "var(--font-display), ui-sans-serif, system-ui, sans-serif"
fontSize: "clamp(2.25rem, 5vw, 3.75rem)"
fontWeight: 600
lineHeight: 1.08
letterSpacing: "-0.03em"
headline:
fontFamily: "var(--font-display), ui-sans-serif, system-ui, sans-serif"
fontSize: "clamp(1.75rem, 3vw, 2.5rem)"
fontWeight: 600
lineHeight: 1.15
letterSpacing: "-0.025em"
body:
fontFamily: "var(--font-body), ui-sans-serif, system-ui, sans-serif"
fontSize: "1.0625rem"
fontWeight: 400
lineHeight: 1.65
label:
fontFamily: "var(--font-body), ui-sans-serif, system-ui, sans-serif"
fontSize: "0.8125rem"
fontWeight: 600
lineHeight: 1.4
letterSpacing: "0.02em"
mono:
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
fontSize: "0.875rem"
display: '"Avenir Next", "Helvetica Neue", ui-sans-serif, system-ui, "Segoe UI", Arial, sans-serif'
body: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
rounded:
sm: "6px"
md: "10px"
lg: "14px"
sm: "0.45rem"
md: "1rem"
lg: "1.4rem"
spacing:
section-y: "5rem"
gutter: "1.5rem"
content-max: "72rem"
shell: "min(1180px, calc(100% - 2rem))"
section-y: "5.5rem"
---
# Design system: vocalinux.com
## Overview
**Creative north star: "The Linux desk utility"**
**Creative north star: the Linux desk in the Voca family**
Pure white / near-black iron surfaces, committed emerald, real app screenshots as proof, full-bleed install strip, asymmetric home hero. Not generic AI SaaS (purple mesh, gradient text, icon-card grids, browser-fake demos).
Vocalinux.com is a Voca product site. It should feel related to VocaHQ, VocaMac, VocaPhone, and VocaGateway before anyone reads the logo: warm paper, deep green ink, one teal accent, editorial type, flat surfaces, and real product proof.
Home is a persuade surface: first viewport shows product + screenshot; conversion lives in the install strip; then desktop proof. Child pages share the same shell and tokens via `SeoSubpageShell`.
The Linux-specific job is to show local desktop dictation with distribution and display-server truth. Pair a GTK-style window (not macOS traffic lights) with a terminal. Explain the user outcome before the install command.
**Key characteristics**
- Pure white / zinc iron surfaces (not cream, not purple)
- Display (Bricolage Grotesque) + body (Source Sans 3); mono for install only
- Real `public/screenshots/` on the home feature section
- Full-bleed dark install strip as primary conversion
- Feature rail + dense engine list instead of rainbow icon cards
Child pages share the same shell, tokens, and paper canvas. Paper is the only
marketing theme. There is no site-wide dark mode. The screenshots gallery is
the exception: a page-local Light/Dark flip shows the captured GTK shots in
both appearances, defaulting to `prefers-color-scheme` until the visitor
overrides it for that tab.
## Colors
Committed emerald on pure iron neutrals.
Warm paper with one product teal. No second chromatic brand color.
### Primary
- **Signal green** (`#1a7f4e` light / `#50c878` dark): buttons, focus, success marks, key links
- **Deep canopy** (`#14663e`): hover/pressed primary
### Surfaces
- **Paper** `#f4f1e8`: page canvas
- **Deep paper** `#ebe5d8`: recessed bands (install, engines)
- **Bright paper** `#fffdf7`: windows and cards
- **Dark ink** `#0b1a15`: panel bar, terminal, optional dark band
### Neutral
- **Ink** (`#09090b` / `#fafafa` dark): primary text
- **Iron white** (`#ffffff`): page background (light)
- **Surface** (`#f4f4f5` / `#16161a`): elevated panels
- **Muted** (`#52525b` / `#a1a1aa`): secondary text
- **Seam** (`#e4e4e7` / `#27272a`): borders
- **Terminal night** (`#0a0a0c`) / **Terminal leaf** (`#6ee7a8`): install command surfaces
### Ink
- **Ink** `#14231c`: headings and strong borders
- **Muted copy** `#58625c`: body
- **Faint** `#5f6861`: metadata
- **Line** `#c9c8bd`: quiet borders
- **Line dark** `#9ea59f`: window outlines
### Status (comparison tables)
- **Yes:** primary emerald
- **No:** soft rose `#c45c5c` light / `#e08b8b` dark (distinct without form-error noise)
### Accent
- **Brand teal** `#0f6b57`: primary buttons, links, live marks
- **Dark teal** `#0b493d`: hover / pressed
- **Mint** `#cfe9dc` / **mint soft** `#e5f2eb`: tags and chips
### Annotations only
- **Sun** `#e9b949`: focus rings
- **Red** `#de6a57`: recording dot, never as a brand fill
### Named rules
**The one signal rule.** Emerald is the only chromatic accent on marketing pages (except status-no rose on comparison marks). No purple, cyan glow pairs, multi-colored metric chips, or rainbow icon tiles.
**The family paper rule.** Backgrounds stay warm paper, with dark ink only for the tray bar, terminal, and ribbon. Not zinc white, not a second site-wide dark theme, not purple mesh.
**The iron surface rule.** Backgrounds stay pure white or near-black iron. Never warm cream/beige as the default, and never purple-to-blue gradients.
**The one signal rule.** Teal is the only chromatic accent on marketing pages. Yellow and red are physical details (focus, recording), not competing brands.
**The no-gradient rule.** Solid fills only. No linear, radial, or conic gradients.
## Typography
**Display:** Bricolage Grotesque
**Body:** Source Sans 3
**Mono:** system UI monospace for install commands only
System-first, same stacks as VocaHQ / VocaMac.
No Geist/Inter as the whole-page face.
- **Display:** Avenir Next / Helvetica Neue / Segoe UI / Arial
- **Body:** system UI sans
- **Mono:** system UI monospace for commands, tags, and metadata
No remote webfonts. No Geist, Inter, or Bricolage as the page face.
### Hierarchy
- **Display** (600, clamp 2.25–3.75rem): home hero
- **Headline** (600, clamp 1.75–2.5rem): section titles
- **Title** (600, ~1.125rem): card/row titles
- **Body** (400, ~17px, 1.65 lh, max ~70ch)
- **Label** (600, ~0.8125rem): nav, meta; mono uppercase only for short kickers
- **Hero display:** clamp ~3.7–7.4rem, weight ~780, tracking -0.075em, line-height ~0.92
- **Section title:** clamp ~2.2–4.7rem, tracking -0.065em
- **Body:** 16–18px, line-height ~1.65. Reflow with the shell; do not pretty-wrap or cap titles at 20ch
- **Mono tags:** ~0.7rem, used sparingly
### Named rules
**The no costume mono rule.** Monospace is for shell commands, paths, and config keys, not marketing labels.
Accent words in a headline use the same family, `font-style: normal`, brand color. Do not mix a second family into a heading.
## Layout
- Content max width ~72rem
- Section vertical rhythm ~4–6rem; more space above a heading than below it
- Home first viewport: asymmetric offer + screenshot when wide; stacked on small screens
- Install strip full-bleed dark band
- Features: settings shot paired with a stretched feature rail (matched heights)
- Child pages: breadcrumbs + content in `max-w-6xl`, shared shell chrome
- Shell: `min(1180px, calc(100% - 2rem))`
- Sticky paper header, ~70px, one line of nav on desktop
- Home first viewport: split offer + Linux workbench when wide; stacked on small screens
- Section padding ~5.5–9rem desktop, ~4–6rem mobile
- Alternate paper / deep paper for rhythm
- Child pages: breadcrumbs + content in the same shell
## Elevation and shapes
Flat by default. Soft shadows only on terminal / primary elevated panels, not hairline + wide glow together.
Flat fills, 1px borders, paper-window shadows. Small rotations only on decorative notes, never on long copy or controls.
- Panels and cards: 6–14px radius (use 12px as the common surface radius)
- Pills only for small controls (Install nav chip, default engine badge)
- Controls: 0.45rem radius
- Windows: ~10–14px radius
- Pills only for tiny tags (engine badge, section tag)
## Components
### Buttons
- Primary: solid signal green, white text, full-pill on marketing CTAs
- Secondary/ghost: border + background, no scale-bounce hover
- Focus: 2px ring in primary
- Primary: brand teal, paper-bright text, 44px min height
- Homepage / closing CTA: "Install Vocalinux", jumps to `#install`
- Secondary: transparent with a 3:1 control outline
- Hover: 2px lift, no bounce scale as the only affordance
- Focus: 3px sun outline, 3px offset
### Feature rail
- Single bordered panel, divided rows, link affordance on the row
- No oversized icon tiles stacked above headings
### Linux window
- GTK-style headerbar: title, then window controls on the **right**
- Grey control circles, not red/yellow/green traffic lights
- Bright paper body, dark ink panel bar above (tray analogue)
### Terminal panel
- Near-black surface, leaf green command text, copy in chrome
### Terminal
- Dark ink surface, mint command text, copy in the chrome
- GTK headerbar: copy on the left, title centered, grey window controls on the **right**
- Prompt is `user@linux:~$` with colored user, host, and path, not a Mac `$`
- Long URLs wrap (`break-all` / `overflow-wrap`) so mobile never overflows
### Navigation
- Fixed translucent bar, seam border
- Home: simple links + Install pill
- Child: category dropdowns + Install pill + theme toggle
- Sticky paper bar, mark + name, 3–5 text links, GitHub stars on desktop
- Mobile menu keeps Install Vocalinux as the conversion action
- Real mobile disclosure with `aria-expanded`
- Resource-rich footer, not a second mega-menu in the header
### Screenshot gallery
- Paper chrome stays paper; do not put `html.dark` on this page
- Light/Dark is a sliding switch over stacked light and dark PNG pairs
- Default from the browser color scheme; `sessionStorage` holds a tab override
## Do's and don'ts
### Do
- Lead with install and real product proof (screenshots)
- Lead with the dictation outcome, then the install command
- Use real screenshots and a faithful Linux window, not a fake SaaS dashboard
- Keep product claims accurate (offline local engines; remote only when configured)
- Keep marketing copy short and specific
- Honor `prefers-reduced-motion`
### Don't
- Gradient text, animated mesh/orb backgrounds, or cyan+green dual glows
- Repeated icon-in-rounded-square feature cards as the page structure
- CSS gradients, gradient text, mesh/orbs, or glass panels
- macOS traffic-light chrome on Linux surfaces
- Tracked uppercase eyebrows on every section
- Scatter identical fade-up motion on every block
- Invent metrics, testimonials, or "blazing" claims
- Invent metrics, testimonials, or "AI-powered" claims
- Recolor the Vocalinux mark
+1 -1
View File
@@ -44,7 +44,7 @@ Offline-first Linux voice typing with real desktop integration (system tray, X11
- Name: Vocalinux (part of [VocaHQ](https://vocahq.com) with VocaMac / VocaWin)
- Mark: microphone logo assets under `public/`
- Emerald accent in the project green family (not purple SaaS defaults)
- Warm paper surfaces and Voca teal, matching the family web standard
- Voice: practical, specific, Linux-native; not hype-first SaaS copy
## Evidence on hand
+3 -4
View File
@@ -70,8 +70,7 @@ web/
│ │ ├── layout.tsx # Root layout
│ │ └── page.tsx # Homepage
│ ├── components/
│ │ ├── dictation-overlay.tsx
│ │ ├── theme-toggle.tsx
│ │ ├── site-chrome.tsx
│ │ └── ui/ # Reusable UI components
│ ├── hooks/
│ └── lib/
@@ -83,8 +82,8 @@ web/
## 🎨 Features
- **Responsive Design** - Works on desktop and mobile
- **Dark/Light Theme** - Automatic theme switching
- **Modern UI** - Built with Tailwind CSS and Framer Motion
- **Family workbench UI** - Warm paper, system type, same language as VocaHQ
- **Modern UI** - Built with Tailwind CSS
- **SEO Optimized** - Proper meta tags and structure
- **Fast Loading** - Optimized static generation
+16
View File
@@ -1,2 +1,18 @@
import '@testing-library/jest-dom'
import '@testing-library/jest-dom/jest-globals'
if (typeof window !== "undefined") {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
-23
View File
@@ -16,7 +16,6 @@
"framer-motion": "^12.9.4",
"lucide-react": "^0.507.0",
"next": "^15.5.21",
"next-themes": "^0.4.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-syntax-highlighter": "^15.6.1",
@@ -2214,9 +2213,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2233,9 +2229,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2252,9 +2245,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2271,9 +2261,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8371,16 +8358,6 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+1 -1
View File
@@ -19,6 +19,7 @@
"test:types": "tsc --noEmit --pretty",
"test:all": "npm run test && npm run test:types",
"test:viewport": "node scripts/viewport-qa.mjs http://127.0.0.1:3456",
"render:social": "node scripts/render-social-assets.mjs",
"deploy": "next build && touch out/.nojekyll && echo 'vocalinux.com' > out/CNAME"
},
"dependencies": {
@@ -30,7 +31,6 @@
"framer-motion": "^12.9.4",
"lucide-react": "^0.507.0",
"next": "^15.5.21",
"next-themes": "^0.4.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-syntax-highlighter": "^15.6.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#f4f1e8"/>
<circle cx="3" cy="3" r="1" fill="#d8d1c3"/>
<circle cx="19" cy="19" r="1" fill="#d8d1c3"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

+1
View File
@@ -0,0 +1 @@
<svg aria-hidden="true" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Android</title><path fill="currentColor" d="M18.4395 5.5586c-.675 1.1664-1.352 2.3318-2.0274 3.498-.0366-.0155-.0742-.0286-.1113-.043-1.8249-.6957-3.484-.8-4.42-.787-1.8551.0185-3.3544.4643-4.2597.8203-.084-.1494-1.7526-3.021-2.0215-3.4864a1.1451 1.1451 0 0 0-.1406-.1914c-.3312-.364-.9054-.4859-1.379-.203-.475.282-.7136.9361-.3886 1.5019 1.9466 3.3696-.0966-.2158 1.9473 3.3593.0172.031-.4946.2642-1.3926 1.0177C2.8987 12.176.452 14.772 0 18.9902h24c-.119-1.1108-.3686-2.099-.7461-3.0683-.7438-1.9118-1.8435-3.2928-2.7402-4.1836a12.1048 12.1048 0 0 0-2.1309-1.6875c.6594-1.122 1.312-2.2559 1.9649-3.3848.2077-.3615.1886-.7956-.0079-1.1191a1.1001 1.1001 0 0 0-.8515-.5332c-.5225-.0536-.9392.3128-1.0488.5449zm-.0391 8.461c.3944.5926.324 1.3306-.1563 1.6503-.4799.3197-1.188.0985-1.582-.4941-.3944-.5927-.324-1.3307.1563-1.6504.4727-.315 1.1812-.1086 1.582.4941zM7.207 13.5273c.4803.3197.5506 1.0577.1563 1.6504-.394.5926-1.1038.8138-1.584.4941-.48-.3197-.5503-1.0577-.1563-1.6504.4008-.6021 1.1087-.8106 1.584-.4941z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg aria-hidden="true" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apple</title><path fill="currentColor" d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/></svg>

After

Width:  |  Height:  |  Size: 678 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg aria-hidden="true" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Server</title><path fill="currentColor" fill-rule="evenodd" d="M4 5h16a1.5 1.5 0 0 1 1.5 1.5v2A1.5 1.5 0 0 1 20 10H4A1.5 1.5 0 0 1 2.5 8.5v-2A1.5 1.5 0 0 1 4 5zm1.25 2.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zM4 14h16a1.5 1.5 0 0 1 1.5 1.5v2a1.5 1.5 0 0 1-1.5 1.5H4a1.5 1.5 0 0 1-1.5-1.5v-2A1.5 1.5 0 0 1 4 14zm1.25 2.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5z"/></svg>

After

Width:  |  Height:  |  Size: 453 B

+1
View File
@@ -0,0 +1 @@
<svg aria-hidden="true" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Windows</title><path fill="currentColor" d="M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623"/></svg>

After

Width:  |  Height:  |  Size: 231 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-hidden="true">
<title>Discord</title>
<path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-hidden="true">
<title>GitHub</title>
<path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
</svg>

After

Width:  |  Height:  |  Size: 869 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-hidden="true">
<title>Email</title>
<path fill="currentColor" d="M3 5h18a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm0 2v.2l9 6.3 9-6.3V7H3zm18 2.4-8.4 5.9a1 1 0 0 1-1.2 0L3 9.4V17h18V9.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-hidden="true">
<title>X</title>
<path fill="currentColor" d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460 460" width="460" height="460" role="img" aria-label="Vocalinux">
<circle cx="229" cy="231.5" r="221.5" fill="#0F6B57"/>
<path d="M 164 127 A 65 65 0 0 1 294 127 L 294 224 A 65 65 0 0 1 164 224 Z" fill="#F2F6F2"/>
<path d="M 144.87 202.02 A 87 87 0 1 0 312.13 202.02" fill="none" stroke="#F2F6F2" stroke-width="18" stroke-linecap="round"/>
<path d="M 228.5 313 L 228.5 382" fill="none" stroke="#F2F6F2" stroke-width="18" stroke-linecap="round"/>
<path d="M 170 382 L 287 382" fill="none" stroke="#F2F6F2" stroke-width="18" stroke-linecap="round"/>
<path d="M 123.12 168.78 A 129.0 99.2 0 0 0 123.12 283.22" fill="none" stroke="#F2F6F2" stroke-width="18" stroke-linecap="round"/>
<path d="M 333.88 168.78 A 129.0 99.2 0 0 1 333.88 283.22" fill="none" stroke="#F2F6F2" stroke-width="18" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 887 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 B

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 62 KiB

-179
View File
@@ -1,179 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="brandGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#49BE68;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2FBFAA;stop-opacity:1" />
</linearGradient>
<radialGradient id="logoGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" style="stop-color:#2FBFAA;stop-opacity:0.08" />
<stop offset="100%" style="stop-color:#2FBFAA;stop-opacity:0" />
</radialGradient>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#e4e4e7" stroke-width="0.5" opacity="0.4"/>
</pattern>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="#fafafa"/>
<rect width="1200" height="630" fill="url(#grid)"/>
<!-- Top accent line -->
<rect x="0" y="0" width="1200" height="4" fill="url(#brandGrad)"/>
<!-- Bottom accent line -->
<rect x="0" y="626" width="1200" height="4" fill="url(#brandGrad)"/>
<!-- ==================== LEFT SIDE ==================== -->
<!-- Vertically centered in ~630px: content block is ~420px tall, starts at ~105 -->
<!-- Logo microphone icon + App name -->
<g transform="translate(80, 100)">
<circle cx="30" cy="32" r="55" fill="url(#logoGlow)"/>
<!-- Mic body -->
<rect x="16" y="8" width="28" height="38" rx="14" fill="url(#brandGrad)"/>
<!-- Mic lines -->
<line x1="24" y1="22" x2="36" y2="22" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<line x1="24" y1="30" x2="36" y2="30" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<!-- Mic stand arc -->
<path d="M 10,38 Q 10,58 30,58 Q 50,58 50,38" stroke="url(#brandGrad)" stroke-width="3.5" fill="none" stroke-linecap="round"/>
<!-- Mic stand -->
<line x1="30" y1="58" x2="30" y2="68" stroke="url(#brandGrad)" stroke-width="3.5" stroke-linecap="round"/>
<line x1="20" y1="68" x2="40" y2="68" stroke="url(#brandGrad)" stroke-width="3.5" stroke-linecap="round"/>
</g>
<text x="150" y="148" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="52" font-weight="800" fill="#18181b" letter-spacing="-1.5">Vocalinux</text>
<!-- Tagline -->
<text x="80" y="220" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="30" font-weight="700" fill="#18181b" letter-spacing="-0.5">Dictate Privately. Locally. Instantly.</text>
<!-- Feature bullets (no em-dashes) -->
<g font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="17">
<g transform="translate(80, 270)">
<text x="0" y="0" fill="#2FBFAA" font-size="16" font-weight="700">&#x2022;</text>
<text x="18" y="0" fill="#27272a"><tspan font-weight="600">Local engines</tspan><tspan fill="#71717a" font-weight="400" dx="6">default path stays on the machine</tspan></text>
</g>
<g transform="translate(80, 302)">
<text x="0" y="0" fill="#2FBFAA" font-size="16" font-weight="700">&#x2022;</text>
<text x="18" y="0" fill="#27272a"><tspan font-weight="600">Any App</tspan><tspan fill="#71717a" font-weight="400" dx="6">types wherever your cursor is</tspan></text>
</g>
<g transform="translate(80, 334)">
<text x="0" y="0" fill="#2FBFAA" font-size="16" font-weight="700">&#x2022;</text>
<text x="18" y="0" fill="#27272a"><tspan font-weight="600">whisper.cpp + VOSK</tspan><tspan fill="#71717a" font-weight="400" dx="6">powered</tspan></text>
</g>
<g transform="translate(80, 366)">
<text x="0" y="0" fill="#2FBFAA" font-size="16" font-weight="700">&#x2022;</text>
<text x="18" y="0" fill="#27272a"><tspan font-weight="600">Free &amp; Open Source</tspan><tspan fill="#71717a" font-weight="400" dx="6">(AGPL-3.0)</tspan></text>
</g>
</g>
<!-- GitHub CTA button - icon and text vertically centered -->
<g transform="translate(80, 400)">
<rect width="280" height="50" rx="12" fill="#18181b"/>
<!-- GitHub icon + text centered as a group: total content ~200px wide, button 280px, offset=(280-200)/2=40 -->
<g transform="translate(48, 14)">
<path d="M11 0C4.925 0 0 4.925 0 11c0 4.86 3.15 8.985 7.52 10.44.55.1.756-.244.756-.546 0-.27-.01-1.17-.016-2.12-3.06.665-3.706-.736-3.706-.736-.5-1.27-1.222-1.608-1.222-1.608-1-.682.076-.668.076-.668 1.105.078 1.686 1.135 1.686 1.135.982 1.682 2.576 1.196 3.204.914.1-.71.384-1.196.698-1.47-2.442-.278-5.01-1.222-5.01-5.438 0-1.202.429-2.184 1.132-2.952-.114-.278-.49-1.397.106-2.914 0 0 .923-.296 3.024 1.128A10.546 10.546 0 0111 5.344c.935.004 1.876.126 2.754.37 2.1-1.424 3.022-1.128 3.022-1.128.598 1.517.222 2.636.108 2.914.705.768 1.13 1.75 1.13 2.952 0 4.226-2.572 5.156-5.022 5.428.396.34.744 1.012.744 2.04 0 1.472-.014 2.662-.014 3.024 0 .304.204.654.76.544C18.854 19.98 22 15.856 22 11 22 4.925 17.075 0 11 0z" fill="white"/>
</g>
<text x="80" y="32" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="16" font-weight="600" fill="white">Download on GitHub</text>
</g>
<!-- Distro compatibility pills -->
<g transform="translate(80, 476)">
<rect width="84" height="26" rx="13" fill="#f0fdf4" stroke="#bbf7d0" stroke-width="1"/>
<text x="42" y="17" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="11.5" font-weight="500" fill="#166534" text-anchor="middle">Ubuntu</text>
<rect x="94" width="84" height="26" rx="13" fill="#f0fdf4" stroke="#bbf7d0" stroke-width="1"/>
<text x="136" y="17" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="11.5" font-weight="500" fill="#166534" text-anchor="middle">Fedora</text>
<rect x="188" width="100" height="26" rx="13" fill="#f0fdf4" stroke="#bbf7d0" stroke-width="1"/>
<text x="238" y="17" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="11.5" font-weight="500" fill="#166534" text-anchor="middle">Arch Linux</text>
<rect x="298" width="84" height="26" rx="13" fill="#f0fdf4" stroke="#bbf7d0" stroke-width="1"/>
<text x="340" y="17" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="11.5" font-weight="500" fill="#166534" text-anchor="middle">Wayland</text>
<rect x="392" width="58" height="26" rx="13" fill="#f0fdf4" stroke="#bbf7d0" stroke-width="1"/>
<text x="421" y="17" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="11.5" font-weight="500" fill="#166534" text-anchor="middle">X11</text>
</g>
<!-- URL branding -->
<text x="80" y="545" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="13" fill="#a1a1aa">vocalinux.com</text>
<!-- ==================== RIGHT SIDE - FEATURE CARDS ==================== -->
<!-- 4 cards, each 118px tall, with 20px gap between them -->
<!-- Total cards height: 4*118 + 3*20 = 532. Canvas usable = 630 - 8 (accent lines) = 622 -->
<!-- Top offset: (622 - 532) / 2 + 4 = 49 -->
<!-- Card 1: Local Processing -->
<g transform="translate(620, 49)">
<rect width="510" height="118" rx="16" fill="white" stroke="#e4e4e7" stroke-width="1.2"/>
<!-- Icon - vertically centered: (118-50)/2 = 34 -->
<g transform="translate(24, 34)">
<rect width="50" height="50" rx="13" fill="#f0fdf4"/>
<g transform="translate(13, 10)">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z" fill="none" stroke="#16a34a" stroke-width="2" stroke-linejoin="round"/>
<path d="M9 12l2 2 4-4" fill="none" stroke="#16a34a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
<!-- Text block - vertically centered: icon center at 34+25=59, text block ~40px tall, starts at 59-20+6=45 -->
<text x="90" y="49" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="19" font-weight="700" fill="#18181b">Local Processing</text>
<text x="90" y="72" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="14" fill="#71717a">Local engines keep audio on the</text>
<text x="90" y="89" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="14" fill="#71717a">machine. Remote API is optional.</text>
<!-- Badge - vertically centered alongside title -->
<rect x="418" y="37" width="72" height="26" rx="6" fill="#f0fdf4" stroke="#bbf7d0" stroke-width="1"/>
<text x="454" y="55" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="11" font-weight="700" fill="#166534" text-anchor="middle">PRIVATE</text>
</g>
<!-- Card 2: System-Wide Dictation -->
<g transform="translate(620, 187)">
<rect width="510" height="118" rx="16" fill="white" stroke="#e4e4e7" stroke-width="1.2"/>
<g transform="translate(24, 34)">
<rect width="50" height="50" rx="13" fill="#eff6ff"/>
<g transform="translate(11, 11)">
<rect x="2" y="4" width="24" height="16" rx="2" fill="none" stroke="#2563eb" stroke-width="2"/>
<line x1="6" y1="10" x2="10" y2="10" stroke="#2563eb" stroke-width="2" stroke-linecap="round"/>
<line x1="18" y1="10" x2="22" y2="10" stroke="#2563eb" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="10" x2="16" y2="10" stroke="#2563eb" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="14" x2="20" y2="14" stroke="#2563eb" stroke-width="2" stroke-linecap="round"/>
<line x1="14" y1="20" x2="14" y2="26" stroke="#2563eb" stroke-width="2" stroke-linecap="round"/>
<path d="M14 26l-3 3h6l-3-3z" fill="#2563eb"/>
</g>
</g>
<text x="90" y="49" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="19" font-weight="700" fill="#18181b">System-Wide Dictation</text>
<text x="90" y="72" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="14" fill="#71717a">Types wherever your cursor is,</text>
<text x="90" y="89" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="14" fill="#71717a">in every app</text>
<rect x="418" y="37" width="72" height="26" rx="6" fill="#eff6ff" stroke="#bfdbfe" stroke-width="1"/>
<text x="454" y="55" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="11" font-weight="700" fill="#1e40af" text-anchor="middle">ANY APP</text>
</g>
<!-- Card 3: whisper.cpp + VOSK -->
<g transform="translate(620, 325)">
<rect width="510" height="118" rx="16" fill="white" stroke="#e4e4e7" stroke-width="1.2"/>
<g transform="translate(24, 34)">
<rect width="50" height="50" rx="13" fill="#fefce8"/>
<g transform="translate(14, 9)">
<path d="M13 2L4 14h6l-1 10 9-12h-6l1-10z" fill="none" stroke="#ca8a04" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
</g>
</g>
<text x="90" y="49" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="19" font-weight="700" fill="#18181b">whisper.cpp + VOSK</text>
<text x="90" y="72" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="14" fill="#71717a">Dual engine support for speed</text>
<text x="90" y="89" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="14" fill="#71717a">and accuracy</text>
<rect x="402" y="37" width="88" height="26" rx="6" fill="#fefce8" stroke="#fde68a" stroke-width="1"/>
<text x="446" y="55" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="11" font-weight="700" fill="#92400e" text-anchor="middle">DUAL ENGINE</text>
</g>
<!-- Card 4: Push-to-Talk -->
<g transform="translate(620, 463)">
<rect width="510" height="118" rx="16" fill="white" stroke="#e4e4e7" stroke-width="1.2"/>
<g transform="translate(24, 34)">
<rect width="50" height="50" rx="13" fill="#fdf2f8"/>
<g transform="translate(14, 8)">
<rect x="4" y="2" width="14" height="20" rx="7" fill="none" stroke="#db2777" stroke-width="2"/>
<path d="M1 16c0 6 5 10 10 10s10-4 10-10" fill="none" stroke="#db2777" stroke-width="2" stroke-linecap="round"/>
<line x1="11" y1="26" x2="11" y2="30" stroke="#db2777" stroke-width="2" stroke-linecap="round"/>
<line x1="7" y1="30" x2="15" y2="30" stroke="#db2777" stroke-width="2" stroke-linecap="round"/>
</g>
</g>
<text x="90" y="49" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="19" font-weight="700" fill="#18181b">Push-to-Talk</text>
<text x="90" y="72" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="14" fill="#71717a">Hold hotkey, speak, release.</text>
<text x="90" y="89" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="14" fill="#71717a">Instant results.</text>
<rect x="418" y="37" width="72" height="26" rx="6" fill="#fff7ed" stroke="#fed7aa" stroke-width="1"/>
<text x="454" y="55" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="11" font-weight="700" fill="#c2410c" text-anchor="middle">INSTANT</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 26 KiB

+18 -8
View File
@@ -4,40 +4,50 @@
"description": "Voice dictation for Linux with local engines and an optional Remote API.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3CB371",
"background_color": "#f4f1e8",
"theme_color": "#0f6b57",
"icons": [
{
"src": "/brand/vocalinux-mark-circle.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicon-96x96.png",
"sizes": "96x96",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 648 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 788 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+151
View File
@@ -0,0 +1,151 @@
/**
* Rasterize the circular mark into favicons and render the family OG image.
* Usage: node scripts/render-social-assets.mjs
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { chromium } from "playwright-core";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const pub = join(root, "public");
const mark = join(pub, "brand/vocalinux-mark-circle.svg");
const chrome =
process.env.CHROME_PATH ||
["/usr/bin/chromium-browser", "/usr/bin/chromium"].find(Boolean);
function convertPng(size, dest, { paper } = {}) {
const args = ["-background", paper ? "#f4f1e8" : "none", "-resize", `${size}x${size}`, mark];
if (paper) {
args.push("-gravity", "center", "-extent", `${size}x${size}`);
}
args.push("-depth", "8", `png32:${dest}`);
execFileSync("convert", args);
}
function toWebp(src, dest) {
execFileSync("convert", [src, dest]);
}
const sizes = [
[16, join(pub, "favicon-16x16.png")],
[32, join(pub, "favicon-32x32.png")],
[32, join(pub, "vocalinux-32x32.png")],
[64, join(pub, "vocalinux-64x64.png")],
[96, join(pub, "favicon-96x96.png")],
[96, join(pub, "vocalinux-96x96.png")],
[192, join(pub, "vocalinux-192x192.png")],
[192, join(pub, "icon-192x192.png")],
[512, join(pub, "vocalinux-512x512.png")],
[512, join(pub, "icon-512x512.png")],
[512, join(pub, "vocalinux.png")],
];
for (const [size, dest] of sizes) {
convertPng(size, dest);
if (dest.endsWith(".png") && !dest.includes("favicon-")) {
const webp = dest.replace(/\.png$/i, ".webp");
if (
dest.includes("vocalinux-") ||
dest.endsWith("/vocalinux.png")
) {
toWebp(dest, webp);
}
}
}
convertPng(180, join(pub, "apple-touch-icon.png"), { paper: true });
execFileSync("convert", [
join(pub, "favicon-16x16.png"),
join(pub, "favicon-32x32.png"),
join(pub, "favicon.ico"),
]);
const markSvg = readFileSync(mark, "utf8");
const markData = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markSvg)}`;
const dotsData = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
readFileSync(join(pub, "brand/paper-dots.svg"), "utf8"),
)}`;
const ogHtml = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<style>
html, body { margin: 0; width: 1200px; height: 630px; overflow: hidden; }
body {
color: #14231c;
background-color: #f4f1e8;
background-image: url("${dotsData}");
font-family: "Avenir Next", "Helvetica Neue", ui-sans-serif, system-ui, "Segoe UI", Arial, sans-serif;
}
.frame { display: flex; height: 630px; padding: 56px 64px; box-sizing: border-box; gap: 48px; }
.copy { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center; }
.brand { display: flex; align-items: center; gap: 14px; font-size: 28px; font-weight: 600; letter-spacing: -0.03em; }
.brand img { width: 56px; height: 56px; border-radius: 50%; }
h1 { margin: 28px 0 0; font-size: 72px; font-weight: 780; letter-spacing: -0.075em; line-height: 0.92; }
h1 em { font-style: normal; color: #0f6b57; }
.lede { margin: 22px 0 0; max-width: 22em; color: #58625c; font-size: 22px; line-height: 1.45; }
.proof { display: flex; flex-wrap: wrap; gap: 10px 18px; margin-top: 28px; padding-top: 22px; border-top: 1px solid #c9c8bd; color: #5f6861; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 15px; }
.proof span + span { padding-left: 18px; border-left: 1px solid #c9c8bd; }
.url { margin-top: 26px; color: #5f6861; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 14px; }
.workbench { width: 420px; flex: none; align-self: center; }
.panel { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; color: #fffdf7; background: #0b1a15; border-radius: 10px; font-size: 14px; }
.rec { width: 8px; height: 8px; margin-right: 6px; border-radius: 50%; background: #de6a57; display: inline-block; }
.window { margin-top: 10px; background: #fffdf7; border: 1px solid #9ea59f; border-radius: 12px; overflow: hidden; }
.titlebar { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #ebe5d8; border-bottom: 1px solid #c9c8bd; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; color: #5f6861; }
.dots { display: flex; gap: 5px; }
.dots i { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #5c7d71; background: #fffdf7; display: block; }
.dots i.close { background: #cfe9dc; }
.body { padding: 18px 18px 16px; }
.ready { font-size: 18px; font-weight: 650; }
.hint { margin-top: 4px; color: #58625c; font-size: 13px; }
.line { margin-top: 16px; padding: 12px 14px; background: #f4f1e8; border-radius: 8px; font-size: 16px; }
.caret { display: inline-block; width: 2px; height: 1em; margin-left: 3px; background: #0f6b57; vertical-align: -2px; }
</style>
</head>
<body>
<div class="frame">
<div class="copy">
<div class="brand">
<img src="${markData}" alt="" />
Vocalinux
</div>
<h1>Dictate locally.<br><em>In any app.</em></h1>
<p class="lede">Hold Right Alt. Text lands in the focused window. Local engines by default.</p>
<div class="proof">
<span>X11 and Wayland</span>
<span>No telemetry</span>
<span>AGPL-3.0</span>
</div>
<p class="url">vocalinux.com</p>
</div>
<div class="workbench">
<div class="panel"><span>Vocalinux</span><span><i class="rec"></i>listening</span></div>
<div class="window">
<div class="titlebar"><span>notes.txt</span><span class="dots" aria-hidden="true"><i></i><i></i><i class="close"></i></span></div>
<div class="body">
<div class="ready">Ready to dictate</div>
<div class="hint">Hold Right Alt to record</div>
<div class="line">the words go where you are already working<i class="caret"></i></div>
</div>
</div>
</div>
</div>
</body>
</html>
`;
const browser = await chromium.launch({
executablePath: chrome,
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
});
const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });
await page.setContent(ogHtml, { waitUntil: "load" });
await page.screenshot({ path: join(pub, "og-image.png"), type: "png" });
await browser.close();
execFileSync("convert", [join(pub, "og-image.png"), join(pub, "og-image.webp")]);
console.log("rendered circular favicons and family OG image");
+2 -2
View File
@@ -109,7 +109,7 @@ try {
});
}
// Mobile menu path check (header only; footer also has Features)
// Mobile menu path check (header only)
let mobileMenuOk = true;
if (vp.width < 768) {
const toggle = page.getByRole("button", { name: /toggle menu/i });
@@ -118,7 +118,7 @@ try {
await page.waitForTimeout(150);
mobileMenuOk = await page
.locator("header")
.getByRole("link", { name: "Features" })
.getByRole("link", { name: "How it works" })
.isVisible();
await toggle.click();
} else {
+12 -36
View File
@@ -62,24 +62,17 @@ describe('Lazy Loading Behavior', () => {
});
describe('VocalinuxLogo Component', () => {
it('lazy loads logo by default', () => {
it('lazy loads the circular mark by default', () => {
render(<VocalinuxLogo />);
expect(mockImage).toHaveBeenCalledWith(
expect.objectContaining({
priority: false,
})
);
const img = screen.getByAltText('Vocalinux');
expect(img).toHaveAttribute('src', '/brand/vocalinux-mark-circle.svg');
expect(img).toHaveAttribute('loading', 'lazy');
});
it('eager loads logo when priority is set', () => {
it('eager loads the mark when priority is set', () => {
render(<VocalinuxLogo priority />);
expect(mockImage).toHaveBeenCalledWith(
expect.objectContaining({
priority: true,
})
);
const img = screen.getByAltText('Vocalinux');
expect(img).toHaveAttribute('loading', 'eager');
});
});
@@ -93,14 +86,7 @@ describe('Lazy Loading Behavior', () => {
});
it('prevents layout shift with explicit dimensions', () => {
const { container } = render(
<VocalinuxLogo width={64} height={64} />
);
const picture = container.querySelector('picture');
expect(picture).toBeInTheDocument();
// Picture element helps with responsive images and prevents layout shift
render(<VocalinuxLogo width={64} height={64} />);
const img = screen.getByAltText('Vocalinux');
expect(img).toHaveAttribute('width', '64');
expect(img).toHaveAttribute('height', '64');
@@ -119,20 +105,10 @@ describe('Lazy Loading Behavior', () => {
expect(source).toHaveAttribute('type', 'image/webp');
});
it('browser selects WebP source before loading image', () => {
const { container } = render(
<VocalinuxLogo width={32} height={32} />
);
const picture = container.querySelector('picture');
const source = picture?.querySelector('source');
const img = picture?.querySelector('img');
// picture > source should come before img in DOM order
if (source && img) {
const children = Array.from(picture?.children || []);
expect(children.indexOf(source)).toBeLessThan(children.indexOf(img));
}
it('ships the circular mark as an SVG', () => {
render(<VocalinuxLogo width={32} height={32} />);
const img = screen.getByAltText('Vocalinux');
expect(img).toHaveAttribute('src', '/brand/vocalinux-mark-circle.svg');
});
});
});
@@ -52,7 +52,6 @@ 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", () => {
@@ -74,10 +73,8 @@ describe("Screenshots page and assets", () => {
it("links to the gallery and only embeds a few product shots on home", () => {
// Craft home shows a handful of real app shots, then /screenshots/ for the rest.
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");
@@ -85,9 +82,14 @@ describe("Screenshots page and assets", () => {
expect(homeSource).not.toContain("/screenshots/settings-advanced.png");
});
it("exposes Screenshots in the shared subpage shell nav", () => {
expect(shellSource).toContain('href: "/screenshots/"');
expect(shellSource).toContain('label: "Screenshots"');
it("exposes Screenshots in the shared site nav", () => {
const navSource = fs.readFileSync(
path.join(webRoot, "src/lib/site-nav.ts"),
"utf-8",
);
expect(navSource).toContain('href: "/screenshots/"');
expect(navSource).toContain('label: "Screenshots"');
expect(shellSource).toContain("SiteChrome");
});
it("includes captioned settings and product sections", () => {
+22 -6
View File
@@ -15,10 +15,19 @@ import path from "node:path";
const root = path.resolve(__dirname, "../../..");
const pageTsx = fs.readFileSync(path.join(root, "src/app/page.tsx"), "utf8");
const terminalTsx = fs.readFileSync(
path.join(root, "src/components/terminal-block.tsx"),
"utf8",
);
const chromeTsx = fs.readFileSync(
path.join(root, "src/components/site-chrome.tsx"),
"utf8",
);
const globalsCss = fs.readFileSync(
path.join(root, "src/styles/globals.css"),
"utf8",
);
const homeSource = `${pageTsx}\n${terminalTsx}\n${chromeTsx}`;
const CHROME =
process.env.CHROME_PATH ||
@@ -42,9 +51,9 @@ describe("marketing home layout guards (shipped source)", () => {
expect(globalsCss).toMatch(/overflow-x:\s*clip/);
expect(globalsCss).toMatch(/\.terminal-panel pre/);
expect(globalsCss).toMatch(/overflow-wrap:\s*anywhere|break-all|word-break/);
expect(pageTsx).toMatch(/break-all/);
expect(pageTsx).toMatch(/min-w-0/);
expect(pageTsx).toMatch(/overflow-x-clip|overflow-x-auto/);
expect(homeSource).toMatch(/break-all/);
expect(homeSource).toMatch(/min-w-0/);
expect(homeSource).toMatch(/overflow-x-clip|overflow-x-auto/);
});
it("hero uses solid display type (no gradient text slop)", () => {
@@ -53,6 +62,13 @@ describe("marketing home layout guards (shipped source)", () => {
expect(pageTsx).toMatch(/font-display/);
expect(pageTsx).toMatch(/TerminalBlock|terminal-panel/);
});
it("uses the Voca family paper workbench tokens", () => {
expect(globalsCss).toMatch(/--paper:\s*#f4f1e8/);
expect(globalsCss).toMatch(/--brand:\s*#0f6b57/);
expect(globalsCss).not.toMatch(/linear-gradient|radial-gradient|conic-gradient/);
expect(pageTsx).not.toMatch(/linear-gradient|radial-gradient|conic-gradient/);
});
});
describe("marketing home three-viewport overflow (live browser)", () => {
@@ -127,11 +143,11 @@ describe("marketing home three-viewport overflow (live browser)", () => {
if (vp.width < 768) {
await page.getByRole("button", { name: /toggle menu/i }).click();
const featuresVisible = await page
const howVisible = await page
.locator("header")
.getByRole("link", { name: "Features" })
.getByRole("link", { name: "How it works" })
.isVisible();
expect(featuresVisible).toBe(true);
expect(howVisible).toBe(true);
}
await context.close();
+6 -6
View File
@@ -157,7 +157,7 @@ export default function AlternativesPage() {
<CheckCircle2 className="h-5 w-5 text-primary" aria-label="Yes" />
) : (
<XCircle
className="h-5 w-5 text-[#c45c5c] dark:text-[#e08b8b]"
className="h-5 w-5 text-[#c45c5c]"
aria-label="No"
/>
)}
@@ -167,7 +167,7 @@ export default function AlternativesPage() {
<CheckCircle2 className="h-5 w-5 text-primary" aria-label="Yes" />
) : (
<XCircle
className="h-5 w-5 text-[#c45c5c] dark:text-[#e08b8b]"
className="h-5 w-5 text-[#c45c5c]"
aria-label="No"
/>
)}
@@ -177,7 +177,7 @@ export default function AlternativesPage() {
<CheckCircle2 className="h-5 w-5 text-primary" aria-label="Yes" />
) : (
<XCircle
className="h-5 w-5 text-[#c45c5c] dark:text-[#e08b8b]"
className="h-5 w-5 text-[#c45c5c]"
aria-label="No"
/>
)}
@@ -187,7 +187,7 @@ export default function AlternativesPage() {
<CheckCircle2 className="h-5 w-5 text-primary" aria-label="Yes" />
) : (
<XCircle
className="h-5 w-5 text-[#c45c5c] dark:text-[#e08b8b]"
className="h-5 w-5 text-[#c45c5c]"
aria-label="No"
/>
)}
@@ -232,7 +232,7 @@ export default function AlternativesPage() {
</div>
<div className="rounded-[12px] border border-border bg-muted/40 p-4">
<p className="mb-2 text-sm font-semibold text-[#c45c5c] dark:text-[#e08b8b]">
<p className="mb-2 text-sm font-semibold text-[#c45c5c]">
Cons
</p>
<ul className="space-y-1.5">
@@ -241,7 +241,7 @@ export default function AlternativesPage() {
key={con}
className="flex items-start gap-2 text-sm text-muted-foreground"
>
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#c45c5c] dark:text-[#e08b8b]" />
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#c45c5c]" />
{con}
</li>
))}
+1 -1
View File
@@ -219,7 +219,7 @@ export default function AutostartPage() {
<td className="py-3 pr-4 text-muted-foreground">{de.autostartMethod}</td>
<td className="py-3 pr-4">
{de.supported ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary dark:text-[color:var(--terminal-fg)]">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<CheckCircle2 className="h-3 w-3" />
Yes
</span>
+4 -4
View File
@@ -312,24 +312,24 @@ const getTypeStyles = (type: string) => {
case "stable":
return {
badge:
"bg-primary/10 text-primary dark:text-[color:var(--terminal-fg)]",
"bg-primary/10 text-primary",
label: "Stable",
};
case "beta":
return {
badge:
"bg-primary/10 text-primary dark:text-primary",
"bg-primary/10 text-primary",
label: "Beta",
};
case "alpha":
return {
badge:
"bg-muted text-muted-foreground dark:text-muted-foreground",
"bg-muted text-muted-foreground",
label: "Alpha",
};
default:
return {
badge: "bg-muted text-zinc-700",
badge: "bg-muted text-muted-foreground",
label: type,
};
}
+1 -1
View File
@@ -304,7 +304,7 @@ export default async function DistroInstallPage({
className={`rounded-[12px] border border-border p-6 ${styles.panelClass}`}
>
<h2 className="mb-3 inline-flex items-center gap-2 text-xl font-semibold">
<Terminal className="h-5 w-5 text-primary dark:text-[color:var(--terminal-fg)]" />
<Terminal className="h-5 w-5 text-primary" />
Install command
</h2>
<div className="rounded-[12px] bg-[color:var(--terminal)] p-4">
+38 -17
View File
@@ -2,10 +2,15 @@ import Link from "next/link";
import { type Metadata } from "next";
import { ChevronRight, Cpu, Laptop, Terminal } from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell";
import { TerminalBlock } from "@/components/terminal-block";
import { buildPageMetadata } from "@/lib/seo";
const installCommand =
"curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh -o /tmp/vl.sh && bash /tmp/vl.sh --interactive";
const installDisplayCommand = `curl -fsSL \\
https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh \\
-o /tmp/vl.sh && \\
bash /tmp/vl.sh --interactive`;
const distroGuides = [
{
@@ -56,26 +61,42 @@ export default function InstallGuidesPage() {
speech-to-text on Linux. Every guide is written for real desktop usage.
</p>
<div className="subpage-terminal mt-8 p-5 sm:p-6">
<p className="mb-3 inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-wider text-zinc-500">
<Terminal className="h-3.5 w-3.5 text-[color:var(--terminal-fg)]" />
install.sh --interactive
</p>
<code className="block overflow-x-auto break-all text-sm sm:text-base">
{installCommand}
</code>
<div className="mt-8">
<TerminalBlock
command={installCommand}
displayCommand={installDisplayCommand}
label="install.sh --interactive"
/>
</div>
<p className="mt-4 text-sm text-muted-foreground">
Prefer no installer? Download an{" "}
<a
href="https://github.com/VocaHQ/vocalinux/releases"
className="font-semibold text-primary hover:underline"
>
AppImage
</a>{" "}
for x86_64 or aarch64 from GitHub Releases (host text-injection tools
still required).
Then launch <code>vocalinux</code>.
</p>
<div className="install-alts">
<a
className="install-alt"
href="https://github.com/VocaHQ/vocalinux/releases"
rel="noopener noreferrer"
>
<p className="install-alt-kicker">No installer</p>
<h3>AppImage</h3>
<p>
x86_64 or aarch64 from GitHub Releases. No root. Host
text-injection tools are still required.
</p>
</a>
<a
className="install-alt"
href="https://github.com/VocaHQ/vocalinux/blob/main/docs/INSTALL.md#from-source"
rel="noopener noreferrer"
>
<p className="install-alt-kicker">From source</p>
<h3>Build it yourself</h3>
<p>
Clone the repo and run <code>./install.sh</code>. The install
guide has the full source path.
</p>
</a>
</div>
</section>
<section className="mt-12 grid gap-4 md:grid-cols-3">
+7 -30
View File
@@ -1,9 +1,7 @@
import "@/styles/globals.css";
import React from "react";
import { Bricolage_Grotesque, Source_Sans_3 } from "next/font/google";
import { type Metadata } from "next";
import Script from "next/script";
import { ThemeProvider } from "@/components/theme-provider";
import {
absoluteUrl,
DEFAULT_OG_IMAGE_ALT,
@@ -15,26 +13,11 @@ import {
VOCAHQ_URL,
} from "@/lib/seo";
const display = Bricolage_Grotesque({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
});
const body = Source_Sans_3({
subsets: ["latin"],
variable: "--font-body",
display: "swap",
});
export const viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 5,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0c0c0e" },
],
themeColor: "#f4f1e8",
};
export const metadata: Metadata = {
@@ -69,15 +52,16 @@ export const metadata: Metadata = {
publisher: SITE_NAME,
icons: {
icon: [
{ url: "/vocalinux.png", type: "image/png" },
{ url: "/brand/vocalinux-mark-circle.svg", type: "image/svg+xml" },
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/favicon-96x96.png", sizes: "96x96", type: "image/png" },
{ url: "/vocalinux.png", type: "image/png", sizes: "512x512" },
],
apple: [
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
],
shortcut: "/vocalinux.png",
shortcut: "/favicon-32x32.png",
},
manifest: "/site.webmanifest",
appleWebApp: {
@@ -174,8 +158,7 @@ const webSiteJsonLd = {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate:
`${GITHUB_REPO_URL}/issues?q={search_term_string}`,
urlTemplate: `${GITHUB_REPO_URL}/issues?q={search_term_string}`,
},
"query-input": {
"@type": "PropertyValueSpecification",
@@ -193,11 +176,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${display.variable} ${body.variable} scroll-smooth`}
suppressHydrationWarning
>
<html lang="en" className="scroll-smooth" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://api.github.com" />
<script
@@ -218,9 +197,7 @@ export default function RootLayout({
gtag('config', 'G-7NBBNJNNQ7');
`}
</Script>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
{children}
</body>
</html>
);
+3 -3
View File
@@ -264,13 +264,13 @@ export default function OfflinePage() {
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0" />
Run with <code className="rounded bg-muted px-1 dark:bg-muted">--debug</code> and
Run with <code className="rounded bg-muted px-1">--debug</code> and
watch network activity. Local engines should stay quiet during dictation.
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0" />
Monitor with <code className="rounded bg-muted px-1 dark:bg-muted">tcpdump</code> or{" "}
<code className="rounded bg-muted px-1 dark:bg-muted">wireshark</code>. Local engines
Monitor with <code className="rounded bg-muted px-1">tcpdump</code> or{" "}
<code className="rounded bg-muted px-1">wireshark</code>. Local engines
should stay quiet during dictation.
</li>
<li className="flex items-start gap-2">
+1 -1
View File
@@ -219,7 +219,7 @@ export default function OpenSourcePage() {
{vsProprietary.map((row) => (
<tr key={row.feature} className="border-b border-border">
<td className="py-3 pr-4 font-medium">{row.feature}</td>
<td className="py-3 pr-4 text-primary dark:text-[color:var(--terminal-fg)]">{row.vocalinux}</td>
<td className="py-3 pr-4 text-primary">{row.vocalinux}</td>
<td className="py-3 text-muted-foreground">{row.proprietary}</td>
</tr>
))}
+714 -962
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -49,7 +49,7 @@ export default function PrivacyPage() {
/>
<section>
<p className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary dark:text-[color:var(--terminal-fg)]">
<p className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary">
<Shield className="h-4 w-4" />
Privacy First
</p>
@@ -65,8 +65,8 @@ export default function PrivacyPage() {
{/* Key Privacy Points */}
<section className="mb-12 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<article className="rounded-[12px] border border-border bg-background p-6">
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3 dark:bg-primary/10">
<Lock className="h-6 w-6 text-primary dark:text-[color:var(--terminal-fg)]" />
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3">
<Lock className="h-6 w-6 text-primary" />
</div>
<h3 className="mb-2 text-xl font-semibold">Local engines</h3>
<p className="text-sm text-muted-foreground">
@@ -76,7 +76,7 @@ export default function PrivacyPage() {
</article>
<article className="rounded-[12px] border border-border bg-background p-6">
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3 dark:bg-primary/10">
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3">
<Server className="h-6 w-6 text-primary" />
</div>
<h3 className="mb-2 text-xl font-semibold">No Cloud Processing</h3>
@@ -87,7 +87,7 @@ export default function PrivacyPage() {
</article>
<article className="rounded-[12px] border border-border bg-background p-6">
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3 dark:bg-primary/10">
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3">
<Eye className="h-6 w-6 text-primary" />
</div>
<h3 className="mb-2 text-xl font-semibold">Open Source</h3>
@@ -160,7 +160,7 @@ export default function PrivacyPage() {
discarded immediately after processing
</li>
</ol>
<p className="mt-4 rounded-lg bg-muted p-4 text-sm dark:bg-muted">
<p className="mt-4 rounded-lg bg-muted p-4 text-sm">
<strong>Note:</strong> Local engines do not write audio to disk or send it over the
network. Remote API sends audio to the server you configure.
</p>
@@ -213,31 +213,31 @@ export default function PrivacyPage() {
<tbody className="text-sm text-muted-foreground">
<tr className="border-b border-border">
<td className="py-3 font-medium text-foreground">Data leaves device</td>
<td className="py-3 text-primary dark:text-[color:var(--terminal-fg)]">
<td className="py-3 text-primary">
Only with Remote API
</td>
<td className="py-3 text-primary">Always</td>
</tr>
<tr className="border-b border-border">
<td className="py-3 font-medium text-foreground">Internet required</td>
<td className="py-3 text-primary dark:text-[color:var(--terminal-fg)]">
<td className="py-3 text-primary">
Only for Remote API or a model download
</td>
<td className="py-3 text-primary">Yes</td>
</tr>
<tr className="border-b border-border">
<td className="py-3 font-medium text-foreground">Source code available</td>
<td className="py-3 text-primary dark:text-[color:var(--terminal-fg)]">Yes (AGPL-3.0)</td>
<td className="py-3 text-primary">Yes (AGPL-3.0)</td>
<td className="py-3 text-primary">No</td>
</tr>
<tr className="border-b border-border">
<td className="py-3 font-medium text-foreground">Voice recordings stored</td>
<td className="py-3 text-primary dark:text-[color:var(--terminal-fg)]">No</td>
<td className="py-3 text-primary">No</td>
<td className="py-3">Varies by service</td>
</tr>
<tr>
<td className="py-3 font-medium text-foreground">Cost</td>
<td className="py-3 text-primary dark:text-[color:var(--terminal-fg)]">Free forever</td>
<td className="py-3 text-primary">Free forever</td>
<td className="py-3">Subscription</td>
</tr>
</tbody>
+4 -4
View File
@@ -196,8 +196,8 @@ export default function RsiPreventionPage() {
>
<h3 className="mb-2 font-semibold">{condition.name}</h3>
<p className="mb-3 text-sm text-muted-foreground">{condition.description}</p>
<div className="rounded-lg bg-primary/10 p-3 dark:bg-primary/10">
<p className="text-sm text-primary dark:text-[color:var(--terminal-fg)]">
<div className="rounded-lg bg-primary/10 p-3">
<p className="text-sm text-primary">
<strong>How dictation helps:</strong> {condition.howDictationHelps}
</p>
</div>
@@ -254,7 +254,7 @@ export default function RsiPreventionPage() {
</div>
</section>
<section className="rounded-[12px] border border-border bg-primary/10 p-8 dark:border-border">
<section className="rounded-[12px] border border-border bg-primary/10 p-8">
<h2 className="mb-4 font-display text-2xl font-semibold text-primary">
Take Care of Your Hands
</h2>
@@ -273,7 +273,7 @@ export default function RsiPreventionPage() {
</Link>
<Link
href="/use-cases/"
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-semibold text-primary hover:bg-primary/10 dark:bg-primary/10 dark:hover:bg-primary/10"
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-semibold text-primary hover:bg-primary/10"
>
See Other Use Cases
</Link>
+3 -4
View File
@@ -105,7 +105,7 @@ const settingsShots: Screenshot[] = [
export const metadata: Metadata = buildPageMetadata({
title: "Vocalinux Screenshots | Linux Voice Dictation UI",
description:
"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.",
"See Vocalinux in action: system tray, log viewer, and settings pages for speech model, dictation, audio, performance, application, advanced options, and about/updates. Flip between light and dark app shots.",
path: "/screenshots",
keywords: [
"Vocalinux screenshots",
@@ -157,9 +157,8 @@ export default function ScreenshotsPage() {
</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 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.
tools, and the searchable sidebar settings UI from v0.15. Flip light
and dark app shots. Click any image to expand it.
</p>
</section>
+3 -3
View File
@@ -246,7 +246,7 @@ export default function TroubleshootingPage() {
>
<h2 className="mb-4 text-xl font-semibold">{item.title}</h2>
<div className="mb-4 rounded-lg bg-muted p-4 dark:bg-muted">
<div className="mb-4 rounded-lg bg-muted p-4">
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-muted-foreground">
<AlertTriangle className="h-4 w-4" />
Symptoms
@@ -260,8 +260,8 @@ export default function TroubleshootingPage() {
</ul>
</div>
<div className="rounded-lg bg-primary/10 p-4 dark:bg-primary/10">
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-primary dark:text-[color:var(--terminal-fg)]">
<div className="rounded-lg bg-primary/10 p-4">
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-primary">
<CheckCircle2 className="h-4 w-4" />
Solutions
</p>
+1 -1
View File
@@ -228,7 +228,7 @@ export default function VoiceTypingVsCodePage() {
</ul>
</section>
<section className="mb-12 rounded-[12px] border border-border bg-muted p-6 dark:border-border">
<section className="mb-12 rounded-[12px] border border-border bg-muted p-6">
<h2 className="mb-4 flex items-center gap-2 font-display text-2xl font-semibold text-muted-foreground">
<Bug className="h-5 w-5" />
Troubleshooting
+1 -1
View File
@@ -154,7 +154,7 @@ export default function VsNerdDictationPage() {
</td>
<td className="px-4 py-4 text-sm text-muted-foreground">{row.vocalinux}</td>
<td className="px-4 py-4 text-sm text-muted-foreground">{row.nerdDictation}</td>
<td className="px-4 py-4 text-sm font-semibold text-primary dark:text-[color:var(--terminal-fg)]">
<td className="px-4 py-4 text-sm font-semibold text-primary">
<span className="inline-flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
{row.winner}
+1 -1
View File
@@ -210,7 +210,7 @@ export default function WaylandPage() {
{item.method}
</td>
<td className="py-3 pr-4">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary dark:text-[color:var(--terminal-fg)]">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<CheckCircle2 className="h-3 w-3" />
{item.status}
</span>
@@ -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>;
}
+5 -1
View File
@@ -3,10 +3,14 @@ import { type Metadata } from "next";
export const SITE_URL = "https://vocalinux.com";
export const SITE_NAME = "Vocalinux";
export const DEFAULT_OG_IMAGE_PATH = "/og-image.png";
export const DEFAULT_OG_IMAGE_ALT = "Vocalinux - Voice Dictation for Linux";
export const DEFAULT_OG_IMAGE_ALT =
"Dictate locally. In any app. Offline voice dictation for Linux.";
export const TWITTER_HANDLE = "@vocahq";
export const VOCAHQ_URL = "https://vocahq.com";
export const GITHUB_REPO_URL = "https://github.com/VocaHQ/vocalinux";
export const X_URL = "https://x.com/vocahq";
export const DISCORD_URL = "https://discord.gg/t6muquAJbm";
export const EMAIL_URL = "mailto:hello@vocahq.com";
interface PageMetadataOptions {
title: string;
+83
View File
@@ -0,0 +1,83 @@
import {
DISCORD_URL,
EMAIL_URL,
GITHUB_REPO_URL,
VOCAHQ_URL,
X_URL,
} from "@/lib/seo";
export type NavLink = {
href: string;
label: string;
};
export const homeNav: NavLink[] = [
{ href: "#how-it-works", label: "How it works" },
{ href: "#engines", label: "Engines" },
{ href: "#privacy", label: "Privacy" },
{ href: "#install", label: "Install" },
{ href: "#faq", label: "Questions" },
];
export const pageNav: NavLink[] = [
{ href: "/", label: "Home" },
{ href: "/#how-it-works", label: "How it works" },
{ href: "/install/", label: "Install" },
{ href: "/screenshots/", label: "Screenshots" },
{ href: "/faq/", label: "Questions" },
];
export const mobileExtraNav: NavLink[] = [
{ href: "/compare/", label: "Compare engines" },
{ href: "/changelog/", label: "Changelog" },
{ href: "/troubleshooting/", label: "Troubleshooting" },
];
export const socialLinks: { href: string; label: string; icon: string }[] = [
{ href: X_URL, label: "X", icon: "/brand/social/x.svg" },
{ href: DISCORD_URL, label: "Discord", icon: "/brand/social/discord.svg" },
{ href: GITHUB_REPO_URL, label: "GitHub", icon: "/brand/social/github.svg" },
{ href: EMAIL_URL, label: "Email hello@vocahq.com", icon: "/brand/social/mail.svg" },
];
export const footerGroups: { title: string; links: NavLink[] }[] = [
{
title: "Product",
links: [
{ href: "/#how-it-works", label: "How it works" },
{ href: "/screenshots/", label: "Screenshots" },
{ href: "/#engines", label: "Engines" },
{ href: "/compare/", label: "Compare engines" },
],
},
{
title: "Install",
links: [
{ href: "/#install", label: "Installation" },
{ href: "/install/", label: "Distro guides" },
{ href: "/install/ubuntu/", label: "Ubuntu" },
{ href: "/install/fedora/", label: "Fedora" },
{ href: "/install/arch/", label: "Arch" },
],
},
{
title: "Guides",
links: [
{ href: "/offline/", label: "Offline" },
{ href: "/wayland/", label: "Wayland" },
{ href: "/gpu-acceleration/", label: "GPU acceleration" },
{ href: "/desktop-reliability/", label: "Reliability" },
{ href: "/remote-api/", label: "Remote API" },
],
},
{
title: "Project",
links: [
{ href: "/faq/", label: "FAQ" },
{ href: "/privacy/", label: "Privacy" },
{ href: "/changelog/", label: "Changelog" },
{ href: GITHUB_REPO_URL, label: "Source" },
{ href: VOCAHQ_URL, label: "VocaHQ" },
],
},
];
+1801 -195
View File
File diff suppressed because it is too large Load Diff