diff --git a/web/AGENTS.md b/web/AGENTS.md index 58faf68..2a0e7b4 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -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 diff --git a/web/DESIGN.md b/web/DESIGN.md index c102ef6..2a92231 100644 --- a/web/DESIGN.md +++ b/web/DESIGN.md @@ -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 diff --git a/web/PRODUCT.md b/web/PRODUCT.md index fb0f903..71a99a7 100644 --- a/web/PRODUCT.md +++ b/web/PRODUCT.md @@ -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 diff --git a/web/README.md b/web/README.md index f9579a3..586d684 100644 --- a/web/README.md +++ b/web/README.md @@ -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 diff --git a/web/jest.setup.ts b/web/jest.setup.ts index 34b7177..d7d9fb3 100644 --- a/web/jest.setup.ts +++ b/web/jest.setup.ts @@ -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(), + })), + }); +} diff --git a/web/package-lock.json b/web/package-lock.json index 2c78766..0e07ce1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index d6a4dd2..136fa55 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png index e15f882..27b2df0 100644 Binary files a/web/public/apple-touch-icon.png and b/web/public/apple-touch-icon.png differ diff --git a/web/public/brand/paper-dots.svg b/web/public/brand/paper-dots.svg new file mode 100644 index 0000000..893b9b5 --- /dev/null +++ b/web/public/brand/paper-dots.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/public/brand/platforms/android.svg b/web/public/brand/platforms/android.svg new file mode 100644 index 0000000..16bbfab --- /dev/null +++ b/web/public/brand/platforms/android.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/brand/platforms/apple.svg b/web/public/brand/platforms/apple.svg new file mode 100644 index 0000000..c0724ad --- /dev/null +++ b/web/public/brand/platforms/apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/brand/platforms/linux.svg b/web/public/brand/platforms/linux.svg new file mode 100644 index 0000000..d9a5b20 --- /dev/null +++ b/web/public/brand/platforms/linux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/brand/platforms/server.svg b/web/public/brand/platforms/server.svg new file mode 100644 index 0000000..766c2cd --- /dev/null +++ b/web/public/brand/platforms/server.svg @@ -0,0 +1 @@ + diff --git a/web/public/brand/platforms/windows.svg b/web/public/brand/platforms/windows.svg new file mode 100644 index 0000000..053bf38 --- /dev/null +++ b/web/public/brand/platforms/windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/brand/social/discord.svg b/web/public/brand/social/discord.svg new file mode 100644 index 0000000..43868fa --- /dev/null +++ b/web/public/brand/social/discord.svg @@ -0,0 +1,4 @@ + diff --git a/web/public/brand/social/github.svg b/web/public/brand/social/github.svg new file mode 100644 index 0000000..ca42587 --- /dev/null +++ b/web/public/brand/social/github.svg @@ -0,0 +1,4 @@ + diff --git a/web/public/brand/social/mail.svg b/web/public/brand/social/mail.svg new file mode 100644 index 0000000..0ae8952 --- /dev/null +++ b/web/public/brand/social/mail.svg @@ -0,0 +1,4 @@ + diff --git a/web/public/brand/social/x.svg b/web/public/brand/social/x.svg new file mode 100644 index 0000000..843ef66 --- /dev/null +++ b/web/public/brand/social/x.svg @@ -0,0 +1,4 @@ + diff --git a/web/public/brand/vocalinux-mark-circle.svg b/web/public/brand/vocalinux-mark-circle.svg new file mode 100644 index 0000000..5b522ea --- /dev/null +++ b/web/public/brand/vocalinux-mark-circle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/web/public/favicon-16x16.png b/web/public/favicon-16x16.png index 500b07f..6bba7a9 100644 Binary files a/web/public/favicon-16x16.png and b/web/public/favicon-16x16.png differ diff --git a/web/public/favicon-32x32.png b/web/public/favicon-32x32.png index dca88ac..fd1d31d 100644 Binary files a/web/public/favicon-32x32.png and b/web/public/favicon-32x32.png differ diff --git a/web/public/favicon-96x96.png b/web/public/favicon-96x96.png index 7d83de2..1385ead 100644 Binary files a/web/public/favicon-96x96.png and b/web/public/favicon-96x96.png differ diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..8326bf7 Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/icon-192x192.png b/web/public/icon-192x192.png index 18784cc..0e84e51 100644 Binary files a/web/public/icon-192x192.png and b/web/public/icon-192x192.png differ diff --git a/web/public/icon-512x512.png b/web/public/icon-512x512.png index 096aeeb..2874df5 100644 Binary files a/web/public/icon-512x512.png and b/web/public/icon-512x512.png differ diff --git a/web/public/og-image.png b/web/public/og-image.png index cc28c40..668fd47 100644 Binary files a/web/public/og-image.png and b/web/public/og-image.png differ diff --git a/web/public/og-image.svg b/web/public/og-image.svg deleted file mode 100644 index 21dc736..0000000 --- a/web/public/og-image.svg +++ /dev/null @@ -1,179 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Vocalinux - - - Dictate Privately. Locally. Instantly. - - - - - • - Local enginesdefault path stays on the machine - - - • - Any Apptypes wherever your cursor is - - - • - whisper.cpp + VOSKpowered - - - • - Free & Open Source(AGPL-3.0) - - - - - - - - - - - Download on GitHub - - - - - - Ubuntu - - - Fedora - - - Arch Linux - - - Wayland - - - X11 - - - - vocalinux.com - - - - - - - - - - - - - - - - - - - Local Processing - Local engines keep audio on the - machine. Remote API is optional. - - - PRIVATE - - - - - - - - - - - - - - - - - - System-Wide Dictation - Types wherever your cursor is, - in every app - - ANY APP - - - - - - - - - - - - whisper.cpp + VOSK - Dual engine support for speed - and accuracy - - DUAL ENGINE - - - - - - - - - - - - - - - Push-to-Talk - Hold hotkey, speak, release. - Instant results. - - INSTANT - - \ No newline at end of file diff --git a/web/public/og-image.webp b/web/public/og-image.webp index 1139168..b89c471 100644 Binary files a/web/public/og-image.webp and b/web/public/og-image.webp differ diff --git a/web/public/site.webmanifest b/web/public/site.webmanifest index e56e338..4f62132 100644 --- a/web/public/site.webmanifest +++ b/web/public/site.webmanifest @@ -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" } ] } diff --git a/web/public/vocalinux-192x192.png b/web/public/vocalinux-192x192.png index 18784cc..0e84e51 100644 Binary files a/web/public/vocalinux-192x192.png and b/web/public/vocalinux-192x192.png differ diff --git a/web/public/vocalinux-192x192.webp b/web/public/vocalinux-192x192.webp index 7ceac5d..d9ed32e 100644 Binary files a/web/public/vocalinux-192x192.webp and b/web/public/vocalinux-192x192.webp differ diff --git a/web/public/vocalinux-32x32.png b/web/public/vocalinux-32x32.png index dca88ac..fd1d31d 100644 Binary files a/web/public/vocalinux-32x32.png and b/web/public/vocalinux-32x32.png differ diff --git a/web/public/vocalinux-32x32.webp b/web/public/vocalinux-32x32.webp index 703fca9..ac0079c 100644 Binary files a/web/public/vocalinux-32x32.webp and b/web/public/vocalinux-32x32.webp differ diff --git a/web/public/vocalinux-512x512.png b/web/public/vocalinux-512x512.png index 096aeeb..2874df5 100644 Binary files a/web/public/vocalinux-512x512.png and b/web/public/vocalinux-512x512.png differ diff --git a/web/public/vocalinux-512x512.webp b/web/public/vocalinux-512x512.webp index 03e206c..05c9114 100644 Binary files a/web/public/vocalinux-512x512.webp and b/web/public/vocalinux-512x512.webp differ diff --git a/web/public/vocalinux-64x64.png b/web/public/vocalinux-64x64.png index c467ad2..851ce07 100644 Binary files a/web/public/vocalinux-64x64.png and b/web/public/vocalinux-64x64.png differ diff --git a/web/public/vocalinux-64x64.webp b/web/public/vocalinux-64x64.webp index 46f2ed2..f1f6a0d 100644 Binary files a/web/public/vocalinux-64x64.webp and b/web/public/vocalinux-64x64.webp differ diff --git a/web/public/vocalinux-96x96.png b/web/public/vocalinux-96x96.png index 7d83de2..1385ead 100644 Binary files a/web/public/vocalinux-96x96.png and b/web/public/vocalinux-96x96.png differ diff --git a/web/public/vocalinux-96x96.webp b/web/public/vocalinux-96x96.webp index 89e7ab0..4693d21 100644 Binary files a/web/public/vocalinux-96x96.webp and b/web/public/vocalinux-96x96.webp differ diff --git a/web/public/vocalinux.png b/web/public/vocalinux.png index 096aeeb..2874df5 100644 Binary files a/web/public/vocalinux.png and b/web/public/vocalinux.png differ diff --git a/web/public/vocalinux.webp b/web/public/vocalinux.webp index 03e206c..05c9114 100644 Binary files a/web/public/vocalinux.webp and b/web/public/vocalinux.webp differ diff --git a/web/scripts/render-social-assets.mjs b/web/scripts/render-social-assets.mjs new file mode 100644 index 0000000..ebd9e94 --- /dev/null +++ b/web/scripts/render-social-assets.mjs @@ -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 = ` + + + + + + +
+
+
+ + Vocalinux +
+

Dictate locally.
In any app.

+

Hold Right Alt. Text lands in the focused window. Local engines by default.

+
+ X11 and Wayland + No telemetry + AGPL-3.0 +
+

vocalinux.com

+
+
+
Vocalinuxlistening
+
+
notes.txt
+
+
Ready to dictate
+
Hold Right Alt to record
+
the words go where you are already working
+
+
+
+
+ + +`; + +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"); diff --git a/web/scripts/viewport-qa.mjs b/web/scripts/viewport-qa.mjs index 4068d7b..c053ed8 100644 --- a/web/scripts/viewport-qa.mjs +++ b/web/scripts/viewport-qa.mjs @@ -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 { diff --git a/web/src/app/__tests__/lazy-loading.test.tsx b/web/src/app/__tests__/lazy-loading.test.tsx index a1da729..35f7c41 100644 --- a/web/src/app/__tests__/lazy-loading.test.tsx +++ b/web/src/app/__tests__/lazy-loading.test.tsx @@ -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(); - - 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(); - - 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( - - ); - - const picture = container.querySelector('picture'); - expect(picture).toBeInTheDocument(); - - // Picture element helps with responsive images and prevents layout shift + render(); 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( - - ); - - 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(); + const img = screen.getByAltText('Vocalinux'); + expect(img).toHaveAttribute('src', '/brand/vocalinux-mark-circle.svg'); }); }); }); diff --git a/web/src/app/__tests__/screenshots-page.test.ts b/web/src/app/__tests__/screenshots-page.test.ts index 448f5a1..0d82862 100644 --- a/web/src/app/__tests__/screenshots-page.test.ts +++ b/web/src/app/__tests__/screenshots-page.test.ts @@ -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", () => { diff --git a/web/src/app/__tests__/viewport-layout.test.ts b/web/src/app/__tests__/viewport-layout.test.ts index 9b78049..a1a1ba0 100644 --- a/web/src/app/__tests__/viewport-layout.test.ts +++ b/web/src/app/__tests__/viewport-layout.test.ts @@ -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(); diff --git a/web/src/app/alternatives/page.tsx b/web/src/app/alternatives/page.tsx index d7600eb..67d548f 100644 --- a/web/src/app/alternatives/page.tsx +++ b/web/src/app/alternatives/page.tsx @@ -157,7 +157,7 @@ export default function AlternativesPage() { ) : ( )} @@ -167,7 +167,7 @@ export default function AlternativesPage() { ) : ( )} @@ -177,7 +177,7 @@ export default function AlternativesPage() { ) : ( )} @@ -187,7 +187,7 @@ export default function AlternativesPage() { ) : ( )} @@ -232,7 +232,7 @@ export default function AlternativesPage() {
-

+

Cons

    @@ -241,7 +241,7 @@ export default function AlternativesPage() { key={con} className="flex items-start gap-2 text-sm text-muted-foreground" > - + {con} ))} diff --git a/web/src/app/autostart/page.tsx b/web/src/app/autostart/page.tsx index 284f442..d1d275a 100644 --- a/web/src/app/autostart/page.tsx +++ b/web/src/app/autostart/page.tsx @@ -219,7 +219,7 @@ export default function AutostartPage() { {de.autostartMethod} {de.supported ? ( - + Yes diff --git a/web/src/app/changelog/page.tsx b/web/src/app/changelog/page.tsx index 6cdb79f..a5163e4 100644 --- a/web/src/app/changelog/page.tsx +++ b/web/src/app/changelog/page.tsx @@ -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, }; } diff --git a/web/src/app/install/[distro]/page.tsx b/web/src/app/install/[distro]/page.tsx index 21b8d31..ffb55d4 100644 --- a/web/src/app/install/[distro]/page.tsx +++ b/web/src/app/install/[distro]/page.tsx @@ -304,7 +304,7 @@ export default async function DistroInstallPage({ className={`rounded-[12px] border border-border p-6 ${styles.panelClass}`} >

    - + Install command

    diff --git a/web/src/app/install/page.tsx b/web/src/app/install/page.tsx index 481f226..22f144a 100644 --- a/web/src/app/install/page.tsx +++ b/web/src/app/install/page.tsx @@ -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.

    -
    -

    - - install.sh --interactive -

    - - {installCommand} - +
    +

    - Prefer no installer? Download an{" "} - - AppImage - {" "} - for x86_64 or aarch64 from GitHub Releases (host text-injection tools - still required). + Then launch vocalinux.

    +
    diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 9c41b52..058f5c0 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -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 ( - +