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.
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 14 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 383 B After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 768 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 62 KiB |
@@ -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">•</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">•</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">•</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">•</text>
|
||||
<text x="18" y="0" fill="#27272a"><tspan font-weight="600">Free & 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 |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 26 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 768 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 310 B After Width: | Height: | Size: 648 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 588 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 788 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 11 KiB |
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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/");
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||