fix web design

This commit is contained in:
abue-ammar
2026-07-18 20:54:35 +06:00
parent ed6ea7ed9c
commit bc1104efa9
15 changed files with 356 additions and 70 deletions
+12 -6
View File
@@ -9,7 +9,11 @@
<title>Tinycast — a tiny, native macOS launcher</title>
<meta
name="description"
content="Tinycast is a tiny, fully native macOS launcher: fuzzy app search, inline calculator, clipboard history, and global hotkeys. ~3 MB, no Electron, no account, no telemetry. Free and open source under AGPL-3.0."
content="Tinycast is a tiny, fully native macOS launcher and a free, open-source Raycast alternative: fuzzy app search, inline calculator, clipboard history, and global hotkeys. A lighter take on Raycast, Alfred, Spotlight, and Supermac — ~3 MB, no Electron, no account, no telemetry. Free and open source under AGPL-3.0."
/>
<meta
name="keywords"
content="Raycast alternative, Alfred alternative, Spotlight replacement, Supermac, macOS launcher, Mac app launcher, clipboard manager, fuzzy app search, global hotkeys, native SwiftUI launcher, open source launcher"
/>
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://abue-ammar.github.io/tinycast/" />
@@ -21,11 +25,11 @@
<meta property="og:locale" content="en_US" />
<meta
property="og:title"
content="Tinycast — a tiny, native macOS launcher"
content="Tinycast — a tiny, native macOS launcher "
/>
<meta
property="og:description"
content="Fuzzy app search, calculator, clipboard history, and global hotkeys in ~3 MB. Native, local, no telemetry. Free and open source."
content="A free, open-source Raycast alternative for macOS: fuzzy app search, calculator, clipboard history, and global hotkeys in ~3 MB. A lighter take on Raycast, Alfred, and Spotlight. Native, local, no telemetry."
/>
<meta
property="og:image"
@@ -43,11 +47,11 @@
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:title"
content="Tinycast — a tiny, native macOS launcher"
content="Tinycast — a tiny, native macOS launcher "
/>
<meta
name="twitter:description"
content="Fuzzy app search, calculator, clipboard history, and global hotkeys in ~3 MB. Native, local, no telemetry. Free and open source."
content="A free, open-source Raycast alternative for macOS: fuzzy app search, calculator, clipboard history, and global hotkeys in ~3 MB. A lighter take on Raycast, Alfred, and Spotlight. Native, local, no telemetry."
/>
<meta
name="twitter:image"
@@ -65,7 +69,9 @@
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Tinycast",
"description": "A tiny, fully native macOS launcher: fuzzy app search, inline calculator, clipboard history, and global hotkeys. ~3 MB, no Electron, no account, no telemetry.",
"alternativeHeadline": "A free, open-source Raycast alternative for macOS",
"keywords": "Raycast alternative, Alfred alternative, Spotlight replacement, Supermac, macOS launcher, clipboard manager, global hotkeys",
"description": "A tiny, fully native macOS launcher and free Raycast alternative: fuzzy app search, inline calculator, clipboard history, and global hotkeys. ~3 MB, no Electron, no account, no telemetry.",
"url": "https://abue-ammar.github.io/tinycast/",
"image": "https://abue-ammar.github.io/tinycast/og.png",
"applicationCategory": "UtilitiesApplication",
+33 -4
View File
@@ -12,7 +12,8 @@
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0"
"tailwind-merge": "^3.6.0",
"yet-another-react-lightbox": "^3.32.1"
},
"devDependencies": {
"@babel/core": "^7.29.7",
@@ -1368,7 +1369,7 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -1379,8 +1380,9 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -1511,7 +1513,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -2105,6 +2107,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -2366,6 +2369,32 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yet-another-react-lightbox": {
"version": "3.32.1",
"resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.32.1.tgz",
"integrity": "sha512-/OK5U79uTD2kGv3m+w22IDCuVtgC0l9J2SwJRYE+88XqfkWwVkAVNbE8AdWozAlPFTjGiIyPHxukoIINH9mpiw==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/igordanchenko"
},
"peerDependencies": {
"@types/react": "^16 || ^17 || ^18 || ^19",
"@types/react-dom": "^16 || ^17 || ^18 || ^19",
"react": "^16.8.0 || ^17 || ^18 || ^19",
"react-dom": "^16.8.0 || ^17 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
}
}
}
+3 -2
View File
@@ -11,11 +11,12 @@
"format": "prettier --write ."
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"clsx": "^2.1.1",
"tailwind-merge": "^3.6.0"
"tailwind-merge": "^3.6.0",
"yet-another-react-lightbox": "^3.32.1"
},
"devDependencies": {
"@babel/core": "^7.29.7",
+2
View File
@@ -1,5 +1,6 @@
import { Nav } from "./components/nav";
import { Hero } from "./components/hero";
import { Gallery } from "./components/gallery";
import { Features } from "./components/features";
import { Compare } from "./components/compare";
import { Switch } from "./components/switch";
@@ -14,6 +15,7 @@ function App() {
<Nav />
<main>
<Hero />
<Gallery />
<Features />
<Compare />
<Switch />
+35 -1
View File
@@ -51,7 +51,8 @@ export function Compare() {
intro="Everything you actually reach for is here — then Tinycast wins on the things that don't show up in a feature list: size, price, and who owns your data."
>
<Reveal className="mx-auto max-w-4xl">
<div className="overflow-x-auto rounded-2xl border border-border shadow-key">
{/* Desktop: the aligned three-column table. */}
<div className="hidden overflow-x-auto rounded-2xl border border-border shadow-key md:block">
<div className="relative min-w-152">
{/* The "winner column" — one continuous panel behind Tinycast. */}
<div
@@ -103,6 +104,39 @@ export function Compare() {
</div>
</div>
{/* Mobile: one card per row, so nothing scrolls sideways. */}
<div className="space-y-3 md:hidden">
{compareRows.map((row) => (
<div
key={row.label}
className="rounded-xl border border-border/50 p-4"
>
<div className="mb-3 flex items-center text-small text-ash">
{row.label}
{row.sourced && (
<sup className="ml-0.5 text-violet-bright">†</sup>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col items-center gap-2 rounded-lg bg-violet/[0.07] px-3 py-3 text-center ring-1 ring-inset ring-violet/20">
<span className="flex items-center gap-1.5 font-mono text-eyebrow uppercase text-smoke">
<Logo size={13} />
Tinycast
</span>
<Value value={row.tinycast} own />
</div>
<div className="flex flex-col items-center gap-2 px-3 py-3 text-center">
<span className="flex items-center gap-1.5 font-mono text-eyebrow uppercase text-smoke">
<RaycastLogo size={12} />
Raycast
</span>
<Value value={row.raycast} own={false} />
</div>
</div>
</div>
))}
</div>
{/* The one external claim, cited so it holds up. */}
<p className="mt-4 text-center font-mono text-caption text-smoke">
<span className="text-violet-bright">†</span> Stack and memory figures
+14 -16
View File
@@ -10,7 +10,7 @@ export function Ethos() {
id="why"
eyebrow="Why it's tiny"
title="Built like a Mac app should be."
intro="SwiftUI and AppKit, zero dependencies, no Electron runtime hiding underneath. It's fast because there's barely anything to it."
intro="Native SwiftUI and AppKit. No Electron, no bloat — fast because there's barely anything to it."
>
{/* Stat strip — the one place numbers get loud. */}
<Reveal className="mb-10 md:mb-14">
@@ -36,21 +36,19 @@ export function Ethos() {
</div>
</Reveal>
<div className="grid gap-4 sm:grid-cols-2">
{values.map((value, i) => (
<Reveal key={value.title} delay={i * 60}>
<article className="flex h-full gap-4 rounded-2xl border border-border p-4 sm:p-6">
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-violet/15 text-violet-bright">
<Check size={15} strokeWidth={2} />
</span>
<div>
<h3 className="text-body-lg font-medium">{value.title}</h3>
<p className="mt-1.5 text-body text-ash">{value.body}</p>
</div>
</article>
</Reveal>
))}
</div>
<Reveal>
<ul className="flex flex-wrap justify-center gap-2">
{values.map((value) => (
<li
key={value}
className="inline-flex items-center gap-1.5 rounded-lg bg-white/5 px-3 py-2 text-small text-ash shadow-keycap"
>
<Check size={13} strokeWidth={2.4} className="text-violet-bright" />
{value}
</li>
))}
</ul>
</Reveal>
</Section>
);
}
+22 -10
View File
@@ -1,9 +1,10 @@
import { site } from "../data/site";
import { GitHubLogo, Logo } from "./ui/icon";
import { DiscordLogo, GitHubLogo, Logo } from "./ui/icon";
import { MetaStrip } from "./ui/meta-strip";
export function Footer() {
const repoPath = site.repo.replace("https://", "");
const hasDiscord = site.community.discord !== "#";
return (
<footer className="border-t border-border/50">
@@ -23,15 +24,26 @@ export function Footer() {
</p>
</div>
<a
href={site.repo}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-small text-ash transition-colors hover:text-white"
>
<GitHubLogo size={16} />
{repoPath}
</a>
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
<a
href={site.repo}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-small text-ash transition-colors hover:text-white"
>
<GitHubLogo size={16} />
{repoPath}
</a>
<a
href={site.community.discord}
target={hasDiscord ? "_blank" : undefined}
rel={hasDiscord ? "noreferrer" : undefined}
className="flex items-center gap-2 text-small text-ash transition-colors hover:text-white"
>
<DiscordLogo size={16} />
Join the Discord
</a>
</div>
</div>
</footer>
);
+99
View File
@@ -0,0 +1,99 @@
import { Play } from "lucide-react";
import { useState } from "react";
import Lightbox, { type Slide } from "yet-another-react-lightbox";
import Captions from "yet-another-react-lightbox/plugins/captions";
import Video from "yet-another-react-lightbox/plugins/video";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css";
import { galleryItems, type GalleryItem } from "../data/gallery";
import { Reveal } from "./ui/reveal";
import { Section } from "./ui/section";
// Everything in `public/` is served under the Vite base path.
const asset = (name: string) => `${import.meta.env.BASE_URL}${name}`;
// The grid thumbnail: an explicit thumb, else a video's poster, else the image.
const tileImage = (item: GalleryItem) =>
asset(item.thumb ?? (item.type === "video" ? (item.poster ?? item.src) : item.src));
// One gallery item → one lightbox slide. Images carry title/description for the
// Captions plugin; videos use the Video plugin's `sources` shape.
function toSlide(item: GalleryItem): Slide {
if (item.type === "video") {
return {
type: "video",
poster: item.poster ? asset(item.poster) : undefined,
width: item.width,
height: item.height,
title: item.title,
description: item.caption,
sources: [{ src: asset(item.src), type: "video/mp4" }],
};
}
return {
src: asset(item.src),
title: item.title,
description: item.caption,
width: item.width,
height: item.height,
};
}
export function Gallery() {
const [index, setIndex] = useState(-1);
const slides = galleryItems.map(toSlide);
return (
<Section
id="gallery"
eyebrow="Tinycast in action"
title="See it in motion."
intro="A palette that stays out of your way — until you need it."
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{galleryItems.map((item, i) => (
<Reveal key={`${item.title}-${i}`} delay={i * 60}>
<button
type="button"
onClick={() => setIndex(i)}
className="group flex h-full w-full flex-col gap-3 rounded-2xl bg-ink/40 p-3 text-left shadow-key transition-shadow duration-200 hover:shadow-key-hover"
>
<figure className="relative aspect-video w-full overflow-hidden rounded-xl">
<img
src={tileImage(item)}
alt={item.title}
className="block size-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
decoding="async"
/>
{item.type === "video" && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="flex size-12 items-center justify-center rounded-full bg-black/40 text-white shadow-highlight backdrop-blur-sm">
<Play size={20} fill="currentColor" />
</span>
</span>
)}
</figure>
<div className="px-1 pb-1">
<h3 className="text-body-lg font-medium text-white">
{item.title}
</h3>
<p className="mt-1 text-body text-ash">{item.caption}</p>
</div>
</button>
</Reveal>
))}
</div>
<Lightbox
open={index >= 0}
index={index}
close={() => setIndex(-1)}
slides={slides}
plugins={[Video, Captions, Zoom]}
controller={{ closeOnBackdropClick: true }}
/>
</Section>
);
}
+5 -9
View File
@@ -1,4 +1,4 @@
import { site } from "../data/site";
import { hero, site } from "../data/site";
import { AppShot } from "./app-shot";
import { Button } from "./ui/button";
import { AppleLogo, GitHubLogo } from "./ui/icon";
@@ -30,25 +30,21 @@ export function Hero() {
className="rise font-mono text-eyebrow uppercase text-ash"
style={{ animationDelay: "0ms" }}
>
Native macOS launcher
{hero.eyebrow}
</p>
<h1
className="rise mt-6 max-w-3xl text-display"
style={{ animationDelay: "80ms" }}
>
Your shortcut to
<br className="hidden sm:block" /> everything on your Mac.
{hero.headline}
</h1>
<p
className="rise mt-6 max-w-xl text-sm text-ash"
className="rise mt-6 max-w-lg text-body-lg text-ash"
style={{ animationDelay: "160ms" }}
>
{site.name} is a tiny, fully native launcher — fuzzy app search, a
calculator, clipboard history, and global hotkeys. A few megabytes on
disk, and it brings your Raycast setup along. No Electron, no account,
no telemetry.
{hero.sub}
</p>
<div
+2 -2
View File
@@ -18,8 +18,8 @@ export function Nav() {
return (
<header className="fixed inset-x-0 top-4 z-50">
<div className="container-page">
<nav className="rounded-xl border border-border bg-void-black/60 backdrop-blur-2xl">
<div className="flex items-center justify-between gap-4 px-3 py-2">
<nav className="rounded-2xl border border-white/8 bg-void-black/60 backdrop-blur-2xl">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<a
href="#top"
onClick={close}
+15
View File
@@ -71,6 +71,21 @@ export function RaycastLogo({ size = 18, ...props }: BrandIconProps) {
);
}
export function DiscordLogo({ size = 16, ...props }: BrandIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M20.317 4.369a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.211.375-.445.865-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.6 12.6 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.74 19.74 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.1 13.1 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.009c.12.099.246.198.373.292a.077.077 0 0 1-.006.127 12.3 12.3 0 0 1-1.873.891.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.055c.5-5.177-.838-9.674-3.548-13.66a.06.06 0 0 0-.031-.028ZM8.02 15.331c-1.182 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418Zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418Z" />
</svg>
);
}
export function GitHubLogo({ size = 16, ...props }: BrandIconProps) {
return (
<svg
+5 -19
View File
@@ -1,21 +1,7 @@
import { site } from "./site";
// The values that make Tinycast Tinycast. Each is a plain promise, not a pitch.
// The values that make Tinycast Tinycast — each a plain promise, one line each.
export const values = [
{
title: "Free and open source",
body: `Every line is public under the ${site.license} license. Read it, fork it, build it yourself.`,
},
{
title: "Local by design",
body: "Your apps, clipboard, and shortcuts stay on your Mac. Nothing is uploaded, ever.",
},
{
title: "No account, no sign-in",
body: "Install it and it works. There's no login, no paywall, and no pro tier.",
},
{
title: "Zero telemetry",
body: "No analytics, no crash pings, no background phone-home. It does nothing you didn't ask for.",
},
"Free & open source",
"Local by design",
"No account, no sign-in",
"Zero telemetry",
] as const;
+87
View File
@@ -0,0 +1,87 @@
// Drives the "Tinycast in action" gallery + lightbox. Each item is a tile in
// the grid and a slide in the lightbox. `src`/`thumb`/`poster` are resolved
// against import.meta.env.BASE_URL in the component, so give plain filenames
// that live in `public/` (e.g. "screenshot.png").
//
// DUMMY CONTENT: these reuse the two screenshots we already ship so the grid
// looks full. Swap in real captures/clips later. To add a clip, use the video
// shape shown in the commented template at the bottom — the lightbox already
// loads the video plugin.
export type GalleryItem = {
type: "image" | "video";
// Full-size media shown in the lightbox (image src, or video file for clips).
src: string;
// Grid thumbnail; falls back to `src`/`poster` when omitted.
thumb?: string;
// Poster frame for video tiles/slides.
poster?: string;
title: string;
caption: string;
width: number;
height: number;
};
export const galleryItems: GalleryItem[] = [
{
type: "image",
src: "screenshot.png",
title: "App launcher",
caption: "Fuzzy-search every app and open it with a keystroke.",
width: 2451,
height: 1510,
},
{
type: "image",
src: "import.png",
title: "Import from Raycast",
caption: "Bring your whole setup across in one click.",
width: 1800,
height: 1192,
},
{
type: "image",
src: "screenshot.png",
title: "Clipboard history",
caption: "Text and images, full-text searchable, always local.",
width: 2451,
height: 1510,
},
{
type: "image",
src: "import.png",
title: "Inline calculator",
caption: "Math and unit conversions right in the palette.",
width: 1800,
height: 1192,
},
{
type: "image",
src: "screenshot.png",
title: "Emoji & symbols",
caption: "Search the full set; your most-used float to the top.",
width: 2451,
height: 1510,
},
{
type: "image",
src: "import.png",
title: "Hyper key",
caption: "Turn Caps Lock into a whole extra shortcut layer.",
width: 1800,
height: 1192,
},
];
// Video template — drop the .mp4 (and a poster frame) into `public/`, then copy
// an entry like this into the array above:
//
// {
// type: "video",
// src: "demo.mp4",
// poster: "demo-poster.png",
// title: "Live demo",
// caption: "Watch the launcher in motion.",
// width: 1920,
// height: 1080,
// },
+12
View File
@@ -9,9 +9,21 @@ export const site = {
platform: "macOS 26+",
license: "AGPL-3.0",
licenseUrl: "https://github.com/abue-ammar/tinycast/blob/main/LICENSE",
// Community. Replace the placeholder with the real Discord invite when ready.
community: {
discord: "#",
},
} as const;
// The hero, in as few words as possible — headline plus one punchy line.
export const hero = {
eyebrow: "Native macOS launcher",
headline: "Everything on your Mac. One keystroke away.",
sub: "A tiny, native launcher. No Electron. No account. No telemetry. No bullshit.",
} as const;
export const nav = [
{ label: "Gallery", href: "#gallery" },
{ label: "Features", href: "#features" },
{ label: "Compare", href: "#compare" },
{ label: "Why tiny", href: "#why" },
+10 -1
View File
@@ -140,10 +140,19 @@
/* Anchor targets land below the floating nav instead of underneath it. */
section[id] {
scroll-margin-top: 5.5rem;
scroll-margin-top: 6rem;
}
}
/* Lightbox (yet-another-react-lightbox) — tint its dark chrome to match the
page. YARL's own classes are namespaced (yarl__), so nothing here leaks. */
.yarl__root {
--yarl__color_backdrop: rgb(4 5 6 / 0.94);
--yarl__color_button: var(--color-ash);
--yarl__color_button_active: var(--color-white);
--yarl__slide_captions_container_background: rgb(4 5 6 / 0.6);
}
/* The page gutter: 16px on mobile, 24px from the sm breakpoint up. */
@utility container-page {
width: 100%;