Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 1edeaedfef Bump Crafting App downloads to their latest releases
VectorCraft and EffectCraft move to 0.3.1; FilmCraft, LightCraft,
PrintCraft and DesignCraft to 0.2.1 (the new shared default). PhotoCraft
stays on 0.2.0, which is still its latest release. Every expected asset
exists in each release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:25:18 +07:00
BFlatandClaude Opus 5.5 3880127f68 Add Google Analytics to the marketing site (#50)
## What

Adds GA4 to `artcraft-website-next`, covering the whole marketing site.

- `src/components/analytics.tsx` loads gtag.js (`afterInteractive`) and
installs one delegated click listener. It's mounted once in the root
layout.
- `src/lib/analytics.ts` holds the typed event catalogue,
`trackEvent()`, `trackAttrs()` (data attributes that work in server
components) and the link classifier.
- Analytics is **off unless `NEXT_PUBLIC_GA_MEASUREMENT_ID` is set**, so
local dev and deploy previews stay out of the reports.

## Why

The new site had no analytics. This gives us download, signup and
checkout funnels, plus CTA performance by page section.

## Events

GA4 Enhanced Measurement handles page views (App Router history
changes), scrolls, outbound clicks and common file downloads. On top of
that:

| Event | Trigger |
|---|---|
| `app_download` | GitHub release asset clicks (ArtCraft + Crafting
Apps): app, version, platform, file |
| `webapp_click` | Links to app.getartcraft.com: destination path |
| `cta_click` | Other `Button` links (tagged `data-cta`) |
| `app_select` | App cards and icons on `/apps` and sibling cards on app
pages: app name |
| `social_click`, `contact_click` | `SOCIAL_LINKS` hosts, mailto / copy
email |
| `begin_checkout` | Plan selection (`signup`/`subscribe`/`switch`) and
credit packs, with GA4 ecommerce `items` |
| `billing_cadence_change`, `view_credit_packs`, `manage_subscription` |
Pricing actions |
| `prompt_submit`, `video_generation` | H3 promptbox (`logged_in`;
complete/failed/timeout) |
| `sign_up`, `login` | Auth gate success |
| `generate_lead` | Beta form submission (honeypot hits excluded) |
| `video_start`, `share`, `copy_command`, `copy_prompt`,
`media_download`, `press_kit_download` | Video facades, share bars, copy
buttons, media/press downloads |

Every delegated click event also carries `link_location`: nav, footer,
modal, or the enclosing `section[id]`.

## Reviewer notes

- `Button` now renders `data-cta=""` on its anchor variants. There is no
visual change.
- `PricingTable.choosePlan` now branches on the computed `checkoutType`
rather than repeating the `user`/`hasActivePlan` conditions. The
behaviour is unchanged.
- `AuthGateModal` forms now return early on error instead of using
if/else, so the success event goes on the success path.

## Deploy

Set `NEXT_PUBLIC_GA_MEASUREMENT_ID` in Netlify, **Production context
only**, then redeploy. The ID is inlined at build time. This is
documented in `netlify.toml`.

After deploying:
- Mark key events in GA: `app_download`, `begin_checkout`, `sign_up`,
`generate_lead`, `webapp_click`.
- Register custom dimensions (e.g. `link_location`, `app_name`,
`platform`, `destination`, `checkout_type`) so the params show in
reports.

## Testing

Ran the dev server with a dummy measurement ID and checked `dataLayer`
on these pages:
- `/`: nav, footer, CTA, social, mailto and YouTube events.
- `/download`: Windows and macOS installers.
- `/apps/photocraft`: Linux downloads, share, copy command, GitHub link,
and `app_select` on the sibling cards (also checked on the `/apps` icon
row and lineup cards).
- `/pricing`: cadence toggle.
- `/minimax-h3`: logged-out `prompt_submit` and example `video_start`.

Not exercised in the browser: the beta form (submitting would post a
real Google Form entry), signup/login, checkout, the credits modal, the
media page, the press kit, email copy and manage plan.

`tsc` shows no new errors. Existing errors in `media-viewer-3d.tsx` and
the Playwright test files are unchanged. The production build was not
run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:00:50 +07:00
Brandon Thomas 03dea1c660 app downloads 2 (#49) 2026-10-05 06:38:56 -04:00
Brandon Thomas b5272188f7 App downloads (#48) 2026-10-05 06:17:51 -04:00
bflatastic b02f710c76 page scroll fix 2026-10-04 21:15:58 +07:00
BFlat 708de238c5 Add VectorCraft, EffectCraft, DesignCraft and app icons to Craft Apps pages (#47)
## Summary

Updates the Craft Apps pages to cover the org's seven Craft repos and
adds each app's icon.

- **VectorCraft replaces DrawCraft.** The repo was renamed, so the page
moves to `/apps/vectorcraft`. `/apps/drawcraft` permanently redirects
there, because every Craft README's app table still links the old URL.
- **New pages:** `/apps/effectcraft` (motion graphics & VFX) and
`/apps/designcraft` (page layout & publishing), each with copy,
screenshots and a share image.
- **App icons:**
  - A row in the `/apps` header linking to every app.
- On each lineup card and sibling-app card, overlapping the bottom of
the screenshot.
  - Above the wordmark in each app's hero.
- **PhotoCraft installers (`v0.1.1-rc.4`):**
  - A download button in the hero.
- macOS, Windows and Linux buttons, plus an "All builds and checksums"
link.
  - An "Installers ready" badge on its card.
  - `softwareVersion` and `downloadUrl` in its JSON-LD.
- **Share images:** each app's image has its icon in the corner, and the
`/apps` share image is the seven icons in a row.
- **Press kit:** a new "Crafting Apps icons" section, linking to each
repo's 1024px PNG.

## Details
- **Data file:** everything is still driven by
`src/lib/crafting-apps.ts`. An app's `release` field (version plus a
filename suffix per platform) produces its download buttons, so other
apps need one data entry when they ship.
- **Icons:** each repo's 512px render, converted to a 256px WebP with
transparency, about 25 KB each.
- **DesignCraft gallery:** its magazine pages are portrait, so they show
four across instead of in the 16:10 layout.
- **Hub copy:** the app count is worked out from the data ("Seven
apps"). The lineup is four columns wide on large screens, so seven cards
plus the Discord cell fill two even rows.
- **Colors:** EffectCraft and DesignCraft get their icon tile colors as
accents, with AA-contrast text colors in light and dark.
- **Adobe names:** the copy still never names Adobe products.

## Testing
- A clean `npm ci && npm run build` (what Netlify runs) passes with a
clean TypeScript check, and all seven app pages are generated as static
HTML.
- `next start` serves every page and asset with a 200, `/apps/drawcraft`
returns a 308 to `/apps/vectorcraft`, and an unknown slug returns 404.
- Checked visually at 1440px and at a 390px mobile viewport: no broken
images or horizontal overflow, and the mobile icon row wraps four over
three.
- The PhotoCraft download links and every 1024px icon URL in the press
kit resolve.
2026-10-02 23:06:56 +07:00
Brandon Thomas fc78787f30 better website hero text (#46) 2026-10-01 20:22:51 -04:00
59 changed files with 1298 additions and 111 deletions

No files matched your search

@@ -4,6 +4,10 @@
# Manage the build command and publish directory here; leave those UI fields empty.
# The Next.js runtime handles routing, the /cdn-media proxy rewrite, and the
# legacy-route redirects declared in next.config.ts. Other media is in public/.
#
# Google Analytics: set NEXT_PUBLIC_GA_MEASUREMENT_ID (G-XXXXXXXXXX) in the
# Netlify UI, scoped to the Production context so deploy previews don't
# report. It is inlined at build time, so redeploy after changing it.
[build]
command = "npm run build"
@@ -25,6 +25,13 @@ const nextConfig: NextConfig = {
permanent: false,
}),
),
// DrawCraft was renamed VectorCraft; the Crafting Apps READMEs still
// link the old page.
{
source: "/apps/drawcraft",
destination: "/apps/vectorcraft",
permanent: true,
},
// Legacy share-link form (`/media?media=<token>`) from the Vite site;
// the canonical route is /media/<token>. Mirrored in netlify.toml so
// the edge answers it before the Next runtime.
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

@@ -6,6 +6,11 @@ import {
AppGetIt,
} from "@/components/apps/app-sections";
import {
AppHeroDownloadButton,
AppHeroOtherDownloadsLink,
} from "@/components/apps/app-downloads";
import {
AppIcon,
AppTab,
AppWordmark,
ColorAccent,
@@ -24,7 +29,11 @@ import {
craftAppName,
craftAppOgImage,
craftAppPath,
craftAppIconUrl,
craftAppRelease,
craftAppRepo,
craftReleaseDownloads,
craftReleasePageUrl,
craftShotUrl,
getCraftApp,
} from "@/lib/crafting-apps";
@@ -69,6 +78,7 @@ export default async function CraftAppPage({ params }: { params: Params }) {
if (!app) notFound();
const name = craftAppName(app);
const release = craftAppRelease(app);
const [hero] = app.shots;
const [before, accent, after] = app.headline;
const shareUrl = siteUrl(craftAppPath(app));
@@ -81,8 +91,13 @@ export default async function CraftAppPage({ params }: { params: Params }) {
description: app.lede,
url: shareUrl,
image: siteUrl(craftAppOgImage(app)),
thumbnailUrl: siteUrl(craftAppIconUrl(app)),
screenshot: app.shots.map((shot) => siteUrl(craftShotUrl(app, shot))),
codeRepository: craftAppRepo(app),
sameAs: craftAppRepo(app),
...(release && {
softwareVersion: release.version,
downloadUrl: craftReleasePageUrl(app, release),
}),
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
publisher: { "@type": "Organization", name: "ArtCraft", url: SITE_URL },
@@ -101,9 +116,12 @@ export default async function CraftAppPage({ params }: { params: Params }) {
label={name}
annotation={`${app.category} · ${app.status}`}
kicker={
<span className="flex items-center gap-3">
<AppTab>{craftAppIndex(app)}</AppTab>
<AppWordmark app={app} className="text-2xl" />
<span className="flex flex-col items-center gap-5">
<AppIcon app={app} priority className="h-24 w-24 md:h-28 md:w-28" />
<span className="flex items-center gap-3">
<AppTab>{craftAppIndex(app)}</AppTab>
<AppWordmark app={app} className="text-2xl" />
</span>
</span>
}
title={
@@ -120,8 +138,6 @@ export default async function CraftAppPage({ params }: { params: Params }) {
src={craftShotUrl(app, hero)}
alt={hero.alt}
fetchPriority="high"
width={1600}
height={1000}
className="absolute inset-0 h-full w-full object-cover"
/>
}
@@ -130,7 +146,20 @@ export default async function CraftAppPage({ params }: { params: Params }) {
>
<div className="flex flex-col items-center gap-6">
<div className="flex flex-wrap justify-center gap-3">
<DiscordButton size="lg">Join the Discord</DiscordButton>
{release && (
<AppHeroDownloadButton
name={name}
recommended={craftReleaseDownloads(app, release).filter(
(download) => download.recommended,
)}
/>
)}
<DiscordButton
size="lg"
variant={release ? "secondary" : "primary"}
>
Join the Discord
</DiscordButton>
<Button
href={craftAppRepo(app)}
variant="secondary"
@@ -142,6 +171,7 @@ export default async function CraftAppPage({ params }: { params: Params }) {
View on GitHub
</Button>
</div>
{release && <AppHeroOtherDownloadsLink />}
<p className="hud-label text-faint">
{app.platforms.join(" · ")} · Free and open source
</p>
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
BotIcon,
CodeXmlIcon,
@@ -17,22 +18,27 @@ import {
import DiscordButton from "@/components/discord-button";
import { GitHubIcon } from "@/components/icons";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { ColorAccent } from "@/components/apps/app-wordmark";
import { AppIcon, ColorAccent, appThemeClass } from "@/components/apps/app-wordmark";
import { PageHeader } from "@/components/page/page-header";
import RevealManager from "@/components/reveal-manager";
import { Button } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
CRAFTING_APPS,
CRAFTING_APPS_COUNT_WORD,
CRAFTING_APPS_GITHUB_ORG,
CRAFTING_APPS_OG_IMAGE,
CRAFTING_APPS_PRINCIPLES,
craftAppIndex,
craftAppName,
craftAppPath,
} from "@/lib/crafting-apps";
import { siteUrl } from "@/lib/links";
const TITLE = "Crafting Apps: open-source creative tools";
const DESCRIPTION =
"Image editing, vector illustration, video, photography and PDFs: five native, open-source apps from the ArtCraft team, built in Rust and free to use.";
const COVERAGE =
"Image editing, vector illustration, video, photography, PDFs, motion graphics and page layout";
const DESCRIPTION = `${COVERAGE}: ${CRAFTING_APPS_COUNT_WORD.toLowerCase()} native, open-source apps from the ArtCraft team, built in Rust and free to use.`;
export const metadata: Metadata = {
title: TITLE,
@@ -67,13 +73,13 @@ export default function CraftingAppsPage() {
id="apps"
index="01"
label="Crafting Apps"
annotation="Five apps · Open source · Pure Rust"
annotation={`${CRAFTING_APPS_COUNT_WORD} apps · Open source · Pure Rust`}
title={
<>
Five apps. One <ColorAccent>craft</ColorAccent>.
{CRAFTING_APPS_COUNT_WORD} apps. One <ColorAccent>craft</ColorAccent>.
</>
}
lede="Image editing, vector illustration, video, photography and PDFs. Native, open-source apps from the ArtCraft team, built in Rust and free to use."
lede={`${COVERAGE}. Native, open-source apps from the ArtCraft team, built in Rust and free to use.`}
>
<div data-reveal className="mt-8 flex flex-wrap gap-3">
<DiscordButton size="lg">Join the Discord</DiscordButton>
@@ -90,9 +96,32 @@ export default function CraftingAppsPage() {
</div>
<ShareBar
url={siteUrl("/apps")}
text="Crafting Apps: five open-source creative apps from ArtCraft"
text={`Crafting Apps: ${CRAFTING_APPS_COUNT_WORD.toLowerCase()} open-source creative apps from ArtCraft`}
className="mt-6"
/>
<nav
aria-label="Crafting Apps"
data-reveal
className="mt-12 flex flex-wrap gap-x-3 gap-y-6 sm:gap-x-5"
>
{CRAFTING_APPS.map((app) => (
<Link
key={app.slug}
href={craftAppPath(app)}
className={`group flex w-18 flex-col items-center gap-2 sm:w-20 ${appThemeClass(app)}`}
{...trackAttrs("app_select", { app_name: app.slug })}
>
<AppIcon
app={app}
priority
className="h-14 w-14 transition-transform duration-300 ease-out group-hover:-translate-y-1 sm:h-20 sm:w-20"
/>
<span className="text-xs font-medium text-muted group-hover:text-(--app-ink)">
{craftAppName(app)}
</span>
</Link>
))}
</nav>
</PageHeader>
<SectionShell id="lineup">
@@ -103,7 +132,7 @@ export default function CraftingAppsPage() {
/>
<div
data-reveal-group
className="grid gap-px bg-line sm:grid-cols-2 lg:grid-cols-3"
className="grid gap-px bg-line sm:grid-cols-2 xl:grid-cols-4"
>
{CRAFTING_APPS.map((app) => (
<AppCard key={app.slug} app={app} index={craftAppIndex(app)} />
@@ -313,25 +313,32 @@ html[data-ruler="on"]::-webkit-scrollbar {
:root:not([data-theme="light"]) .plan-enterprise { --plan-ink: #7ea3ee; }
}
/* Crafting Apps color identities, same contract as the plan colors:
/* Crafting Apps color identities (each app's icon tile color), same
contract as the plan colors:
`--app` is the saturated surface color (black text on it passes AA),
`--app-ink` the contrast-safe text shade per theme. */
.app-photocraft { --app: #2f7bf5; --app-ink: #1a5bc4; }
.app-drawcraft { --app: #e8573f; --app-ink: #b83a24; }
.app-vectorcraft { --app: #e8573f; --app-ink: #b83a24; }
.app-filmcraft { --app: #8b5cf6; --app-ink: #6a3fd6; }
.app-lightcraft { --app: #f2a516; --app-ink: #8a5800; }
.app-printcraft { --app: #12a58a; --app-ink: #0a7563; }
.app-effectcraft { --app: #e0368f; --app-ink: #b0206c; }
.app-designcraft { --app: #7bb51c; --app-ink: #457008; }
:root[data-theme="dark"] .app-photocraft { --app-ink: #7fb0ff; }
:root[data-theme="dark"] .app-drawcraft { --app-ink: #ff8a70; }
:root[data-theme="dark"] .app-vectorcraft { --app-ink: #ff8a70; }
:root[data-theme="dark"] .app-filmcraft { --app-ink: #b69cff; }
:root[data-theme="dark"] .app-lightcraft { --app-ink: #ffc14d; }
:root[data-theme="dark"] .app-printcraft { --app-ink: #3fd6b8; }
:root[data-theme="dark"] .app-effectcraft { --app-ink: #ff7ab8; }
:root[data-theme="dark"] .app-designcraft { --app-ink: #a6e04a; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .app-photocraft { --app-ink: #7fb0ff; }
:root:not([data-theme="light"]) .app-drawcraft { --app-ink: #ff8a70; }
:root:not([data-theme="light"]) .app-vectorcraft { --app-ink: #ff8a70; }
:root:not([data-theme="light"]) .app-filmcraft { --app-ink: #b69cff; }
:root:not([data-theme="light"]) .app-lightcraft { --app-ink: #ffc14d; }
:root:not([data-theme="light"]) .app-printcraft { --app-ink: #3fd6b8; }
:root:not([data-theme="light"]) .app-effectcraft { --app-ink: #ff7ab8; }
:root:not([data-theme="light"]) .app-designcraft { --app-ink: #a6e04a; }
}
/* Plan cell: a color wash pooling under the index tab and fading into the
@@ -9,6 +9,7 @@ import SiteNav from "@/components/site-nav";
import SiteFooter from "@/components/site-footer";
import IntroConductor from "@/components/intro-conductor";
import LandingContext from "@/components/landing-context";
import Analytics from "@/components/analytics";
import MotionProvider from "@/components/motion-provider";
import ScrollRuler from "@/components/ruler/scroll-ruler";
import TunerPanel from "@/components/dev/tuner-panel";
@@ -138,6 +139,7 @@ export default function RootLayout({
</MotionProvider>
<TunerPanel />
<LandingContext />
<Analytics />
</body>
</html>
);
@@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
import Script from "next/script";
import { GA_MEASUREMENT_ID, trackClick } from "@/lib/analytics";
// Loads gtag.js after hydration and installs the one delegated click
// listener behind the site's link and data-track events (lib/analytics.ts).
// Renders nothing when no measurement id is configured.
export default function Analytics() {
useEffect(() => {
if (!GA_MEASUREMENT_ID) return;
// Capture phase: runs before click-to-play facades swap themselves out,
// and before any handler can stop propagation. auxclick covers
// middle-click "open in new tab".
const onClick = (event: MouseEvent) => {
if (event.type === "auxclick" && event.button !== 1) return;
trackClick(event.target);
};
document.addEventListener("click", onClick, true);
document.addEventListener("auxclick", onClick, true);
return () => {
document.removeEventListener("click", onClick, true);
document.removeEventListener("auxclick", onClick, true);
};
}, []);
if (!GA_MEASUREMENT_ID) return null;
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="ga-init" strategy="afterInteractive">
{`window.dataLayer=window.dataLayer||[];window.gtag=function(){dataLayer.push(arguments);};gtag("js",new Date());gtag("config",${JSON.stringify(GA_MEASUREMENT_ID)});`}
</Script>
</>
);
}
@@ -2,17 +2,20 @@ import Link from "next/link";
import { ArrowUpRightIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Badge } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
craftAppName,
craftAppPath,
craftAppRelease,
craftShotUrl,
type CraftApp,
} from "@/lib/crafting-apps";
import { AppTab, AppWordmark, appThemeClass } from "./app-wordmark";
import { AppIcon, AppTab, AppWordmark, appThemeClass } from "./app-wordmark";
// Lineup cell: color tab + category, the hero screenshot, wordmark, pitch,
// and (unless compact) platform/status chips. The whole cell is the link; a
// hairline in the app's color draws across the top on hover.
// Lineup cell: color tab + category, the hero screenshot, the app icon
// straddling the screenshot's bottom edge, wordmark, pitch, and (unless
// compact) status/platform chips. The whole cell is the link; a hairline in
// the app's color draws across the top on hover.
export default function AppCard({
app,
index,
@@ -23,10 +26,12 @@ export default function AppCard({
compact?: boolean;
}) {
const [hero] = app.shots;
const release = craftAppRelease(app);
return (
<Link
href={craftAppPath(app)}
data-reveal
{...trackAttrs("app_select", { app_name: app.slug })}
className={twMerge(
"group relative flex h-full flex-col bg-bg",
appThemeClass(app),
@@ -47,22 +52,27 @@ export default function AppCard({
alt={hero.alt}
loading="lazy"
decoding="async"
width={1600}
height={1000}
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.03]"
/>
</div>
<div className={twMerge("flex flex-1 flex-col", compact ? "p-6" : "p-6 md:p-8")}>
<h3 className="leading-none">
<AppWordmark
app={app}
className={compact ? "text-2xl" : "text-3xl sm:text-4xl"}
/>
<div className={twMerge("flex flex-1 flex-col", compact ? "px-6 pb-6" : "px-6 pb-6 md:px-8 md:pb-8")}>
<AppIcon
app={app}
className={twMerge(
"relative transition-transform duration-300 ease-out group-hover:-translate-y-1",
compact ? "-mt-7 h-14 w-14" : "-mt-9 h-18 w-18",
)}
/>
<h3 className="mt-4 leading-none">
<AppWordmark app={app} className={compact ? "text-2xl" : "text-3xl"} />
</h3>
<p className="mt-3 leading-relaxed text-muted">{app.pitch}</p>
{!compact && (
<div className="mt-6 flex flex-wrap gap-1.5">
<Badge label={app.status} className="text-(--app-ink)" />
{release && (
<Badge label="Installers ready" className="text-(--app-ink)" />
)}
{app.platforms.map((platform) => (
<Badge key={platform} label={platform} />
))}
@@ -0,0 +1,199 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import {
AppleIcon,
ArrowDownIcon,
ArrowDownToLineIcon,
SquareTerminalIcon,
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { WindowsIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import {
CRAFT_DESKTOP_PLATFORMS,
type CraftDesktopPlatform,
} from "@/lib/crafting-app-releases";
import type { CraftDownload } from "@/lib/crafting-apps";
// OS-aware downloads for /apps/<slug>, modeled on the ArtCraft /download
// page. Detection runs after mount (no user agent on the server), so the
// first paint treats every platform equally and "Your system" arrives with
// hydration. Phones, tablets and Chromebooks keep the neutral layout.
type DetectedDesktop = {
platform: CraftDesktopPlatform;
arch?: CraftDownload["arch"];
};
const PLATFORM_ICONS: Record<CraftDesktopPlatform, ReactNode> = {
macOS: <AppleIcon aria-hidden className="h-5 w-5" />,
Windows: <WindowsIcon className="h-5 w-5" />,
Linux: <SquareTerminalIcon aria-hidden className="h-5 w-5" />,
};
/**
* One card per desktop platform: its recommended build as the main button,
* then every other file for that platform. The visitor's platform is tagged
* "Your system" and gets the solid button.
*/
export function AppPlatformDownloads({ downloads }: { downloads: CraftDownload[] }) {
const detected = useDetectedDesktop();
return (
<div className="grid gap-px bg-line md:grid-cols-3">
{CRAFT_DESKTOP_PLATFORMS.map((platform, i) => {
const files = downloads.filter((download) => download.group === platform);
const isDetected = detected?.platform === platform;
const main = pickDownload(files, isDetected ? detected : { platform });
if (!main) return null;
const others = files.filter((download) => download !== main);
return (
<article key={platform} data-reveal className="flex flex-col bg-bg">
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2.5 md:px-8">
<p className="hud-label text-muted">
{isDetected ? (
<span className="text-(--app-ink,var(--accent-ink))">Your system</span>
) : (
"Platform"
)}
</p>
<p className="hud-label text-faint">{String(i + 1).padStart(2, "0")}</p>
</div>
<div className="flex flex-1 flex-col p-6 md:p-8">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center border border-line text-ink">
{PLATFORM_ICONS[platform]}
</span>
<h3 className="font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
{platform}
</h3>
</div>
<p className="mt-5 text-sm text-ink">{main.label}</p>
<p className="mt-1 text-sm text-muted">{main.description}</p>
<Button
href={main.href}
variant={isDetected ? "primary" : "secondary"}
className="mt-6 w-full"
>
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
Download for {platform}
</Button>
{others.length > 0 && (
<>
<p className="hud-label mt-8 text-faint">Other {platform} downloads</p>
<ul className="mt-2 border-t border-line">
{others.map((download) => (
<li key={download.href} className="border-b border-line">
<DownloadRow download={download} />
</li>
))}
</ul>
</>
)}
</div>
</article>
);
})}
</div>
);
}
/** A compact file link: label, description and filename. */
export function DownloadRow({ download, className }: { download: CraftDownload; className?: string }) {
return (
<a
href={download.href}
title={download.fileName}
className={twMerge(
"group/file flex items-center justify-between gap-4 py-3 hover:bg-bg-sunken",
className,
)}
>
<span className="min-w-0">
<span className="block text-sm text-ink">{download.label}</span>
<span className="block text-sm text-muted">{download.description}</span>
<span className="mt-1 block truncate font-mono text-[11px] text-faint">
{download.fileName}
</span>
</span>
<ArrowDownToLineIcon
aria-hidden
className="h-4 w-4 shrink-0 text-muted group-hover/file:text-ink"
/>
</a>
);
}
/** Hero CTA: a direct download once the OS is known, else a jump to #get-it. */
export function AppHeroDownloadButton({
recommended,
name,
}: {
recommended: CraftDownload[];
name: string;
}) {
const detected = useDetectedDesktop();
const mine = detected && pickDownload(recommended, detected);
return (
<Button href={mine ? mine.href : "#get-it"} size="lg">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
{mine ? `Download for ${mine.group}` : `Download ${name}`}
</Button>
);
}
/**
* Shown under the hero once the direct download is live, so visitors on
* another machine, CPU or package format can still find their build.
*/
export function AppHeroOtherDownloadsLink() {
const detected = useDetectedDesktop();
if (!detected) return null;
return (
<a
href="#get-it"
className="hud-label flex items-center gap-1.5 text-muted hover:text-ink"
>
Other platforms and downloads
<ArrowDownIcon aria-hidden className="h-3.5 w-3.5" />
</a>
);
}
function useDetectedDesktop(): DetectedDesktop | null {
const [detected, setDetected] = useState<DetectedDesktop | null>(null);
useEffect(() => {
setDetected(detectDesktop());
}, []);
return detected;
}
/** The platform's first recommended file, or one matching the CPU if any. */
function pickDownload(
downloads: CraftDownload[],
{ platform, arch }: DetectedDesktop,
): CraftDownload | undefined {
const candidates = downloads.filter(
(download) => download.group === platform && download.recommended,
);
return candidates.find((download) => arch && download.arch === arch) ?? candidates[0];
}
function detectDesktop(): DetectedDesktop | null {
const ua = navigator.userAgent;
if (/Android|iPhone|iPad|iPod|Mobile|CrOS/i.test(ua)) return null;
if (/Windows/i.test(ua)) return { platform: "Windows" };
if (/Macintosh|Mac OS X/i.test(ua)) {
// iPadOS Safari reports itself as a Mac; touch support gives it away.
return navigator.maxTouchPoints > 1 ? null : { platform: "macOS" };
}
if (/Linux/i.test(ua)) {
return {
platform: "Linux",
arch: /aarch64|arm64|armv8/i.test(ua) ? "aarch64" : "x86_64",
};
}
return null;
}
@@ -1,33 +1,42 @@
import Link from "next/link";
import { ArrowDownToLineIcon, ArrowUpRightIcon, TerminalIcon } from "lucide-react";
import type { ReactNode } from "react";
import { ArrowUpRightIcon, TerminalIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import DiscordButton from "@/components/discord-button";
import { GitHubIcon } from "@/components/icons";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Badge, Button, CopyButton } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
CRAFTING_APPS,
craftAppBuildCommand,
craftAppIndex,
craftAppName,
craftAppRelease,
craftAppRepo,
craftReleaseDownloads,
craftReleasePageUrl,
craftReleasesUrl,
craftShotSourceUrl,
craftShotUrl,
formatList,
type CraftApp,
} from "@/lib/crafting-apps";
import AppCard from "./app-card";
import { AppPlatformDownloads, DownloadRow } from "./app-downloads";
import { ColorAccent } from "./app-wordmark";
// Body sections of an /apps/<slug> page, in page order. Server components;
// the only client island is the CopyButton.
// the client islands are the CopyButton and the OS-aware platform cards.
const CELL_HEADING_CLASSES =
"mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl";
// The hero carries shot 01; the rest lead with a full-width shot, then pair.
// The hero carries shot 01. Window captures lead with a full-width shot,
// then pair; portrait pages (layout spreads) sit four across.
export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
const [, ...shots] = app.shots;
const portrait = shots.every((shot) => shot.portrait);
return (
<SectionShell id="gallery">
<SectionEyebrow
@@ -35,18 +44,27 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
label="Screenshots"
annotation={`Captured in ${craftAppName(app)}`}
/>
<div data-reveal-group className="grid gap-px bg-line md:grid-cols-2">
<div
data-reveal-group
className={twMerge(
"grid gap-px bg-line",
portrait ? "grid-cols-2 lg:grid-cols-4" : "md:grid-cols-2",
)}
>
{shots.map((shot, i) => (
<figure
key={shot.file}
data-reveal
className={twMerge("bg-bg", i === 0 && "md:col-span-2")}
className={twMerge("bg-bg", !portrait && i === 0 && "md:col-span-2")}
>
<a
href={craftShotSourceUrl(app, shot)}
target="_blank"
rel="noopener noreferrer"
className="group relative block aspect-[16/10] overflow-hidden bg-bg-sunken"
className={twMerge(
"group relative block overflow-hidden bg-bg-sunken",
shot.portrait ? "aspect-[7/9]" : "aspect-[16/10]",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -54,8 +72,6 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
alt={shot.alt}
loading="lazy"
decoding="async"
width={1600}
height={1000}
className="h-full w-full object-cover"
/>
<span className="hud-label absolute right-3 bottom-3 flex items-center gap-1 bg-bg/80 px-2 py-1 text-ink opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
@@ -63,7 +79,12 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
<ArrowUpRightIcon aria-hidden className="h-3 w-3" />
</span>
</a>
<figcaption className="flex items-center justify-between gap-4 border-t border-line px-6 py-3 md:px-10">
<figcaption
className={twMerge(
"flex items-center justify-between gap-4 border-t border-line py-3",
portrait ? "px-4 md:px-6" : "px-6 md:px-10",
)}
>
<span className="hud-label text-muted">{shot.caption}</span>
<span className="hud-label text-faint">
{String(i + 2).padStart(2, "0")}
@@ -76,13 +97,17 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
);
}
// Installers (or, until there are any, the Discord waitlist) beside
// build-from-source. Filling `downloads` in crafting-apps.ts flips the left
// cell to download buttons.
// Platform cards (your system first-class, every other build one click
// away) above release details and build-from-source. Versions and files come
// from crafting-app-releases.ts; a null release there drops the cards and
// shows the Discord waitlist instead.
export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
const name = craftAppName(app);
const command = craftAppBuildCommand(app);
const desktop = app.platforms.filter((platform) => platform !== "Web");
const release = craftAppRelease(app);
const downloads = release ? craftReleaseDownloads(app, release) : [];
const extras = downloads.filter((download) => download.group === "Other");
return (
<SectionShell id="get-it">
@@ -91,25 +116,47 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
label={`Get ${name}`}
annotation="Free · Open source"
/>
{release && (
<div className="border-b border-line">
<AppPlatformDownloads downloads={downloads} />
</div>
)}
<div className="grid gap-px bg-line md:grid-cols-2">
<div data-reveal className="flex flex-col bg-bg p-6 md:p-10">
<p className="hud-label text-faint">Installers</p>
{app.downloads ? (
{release ? (
<>
<p className="hud-label text-faint">Release</p>
<h3 className={CELL_HEADING_CLASSES}>
Download <ColorAccent>{name}</ColorAccent>.
Version <ColorAccent>{release.version}</ColorAccent>.
</h3>
<div className="mt-8 flex flex-wrap gap-3">
{app.downloads.map((download) => (
<Button key={download.href} href={download.href} size="lg">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
{download.label}
</Button>
))}
<p className="mt-4 max-w-md leading-relaxed text-muted">
Free for every platform above. Questions or feedback? The
team is in the ArtCraft Discord.
</p>
{extras.length > 0 && (
<>
<p className="hud-label mt-8 text-faint">Other downloads</p>
<ul className="mt-2 border-t border-line">
{extras.map((download) => (
<li key={download.href} className="border-b border-line">
<DownloadRow download={download} />
</li>
))}
</ul>
</>
)}
<div className="mt-6 flex flex-wrap gap-x-6 gap-y-2">
<ExternalLink href={craftReleasePageUrl(app, release)}>
Release notes on GitHub
</ExternalLink>
<ExternalLink href={craftReleasesUrl(app)}>
All releases
</ExternalLink>
</div>
</>
) : (
<>
<p className="hud-label text-faint">Installers</p>
<h3 className={CELL_HEADING_CLASSES}>
Installers are <ColorAccent>on the way</ColorAccent>.
</h3>
@@ -157,6 +204,7 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
value={command}
variant="ghost"
className="h-9 border-l border-line px-3"
{...trackAttrs("copy_command", { app_name: app.slug })}
/>
</div>
<pre className="overflow-x-auto p-4 font-mono text-[13px] leading-relaxed text-ink">
@@ -179,6 +227,20 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
);
}
function ExternalLink({ href, children }: { href: string; children: ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="hud-label flex w-fit items-center gap-1.5 text-muted hover:text-ink"
>
{children}
<ArrowUpRightIcon aria-hidden className="h-3.5 w-3.5" />
</a>
);
}
// The other apps, compact, linking back to the hub.
export function AppFamily({ app, index }: { app: CraftApp; index: string }) {
const siblings = CRAFTING_APPS.filter((other) => other.slug !== app.slug);
@@ -202,7 +264,7 @@ export function AppFamily({ app, index }: { app: CraftApp; index: string }) {
</div>
<div
data-reveal-group
className="grid gap-px border-t border-line bg-line sm:grid-cols-2 lg:grid-cols-4"
className="grid gap-px border-t border-line bg-line sm:grid-cols-2 lg:grid-cols-3"
>
{siblings.map((sibling) => (
<AppCard
@@ -1,6 +1,37 @@
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import type { CraftApp } from "@/lib/crafting-apps";
import { craftAppIconUrl, type CraftApp } from "@/lib/crafting-apps";
// The app's engraved-animal icon: a rounded tile with transparent corners,
// so the drop shadow follows the tile shape. Decorative — it always sits
// beside the app's name.
export function AppIcon({
app,
priority = false,
className,
}: {
app: CraftApp;
/** Above the fold: load eagerly at high priority. */
priority?: boolean;
className?: string;
}) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={craftAppIconUrl(app)}
alt=""
width={256}
height={256}
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : undefined}
decoding="async"
className={twMerge(
"h-14 w-14 shrink-0 drop-shadow-[0_6px_14px_rgba(0,0,0,0.28)]",
className,
)}
/>
);
}
// Scopes the app's color identity (`--app`, `--app-ink` in globals.css).
export function appThemeClass(app: CraftApp): string {
@@ -37,11 +68,11 @@ export function AppWordmark({
// serif italic used elsewhere on the site.
export function ColorAccent({ children }: { children: ReactNode }) {
return (
<span className="text-[var(--app-ink,var(--accent-ink))]">{children}</span>
<span className="text-(--app-ink,var(--accent-ink))">{children}</span>
);
}
// Saturated index tab in the app's color (black text passes AA on all five).
// Saturated index tab in the app's color (black text passes AA on every app).
export function AppTab({ children }: { children: ReactNode }) {
return (
<span className="hud-label bg-(--app) px-2 py-1 font-bold text-black">
@@ -1,5 +1,6 @@
import { twMerge } from "tailwind-merge";
import { Button, CopyButton } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
// Share strip: copy-link plus intent links for the networks our audience
// posts to. Hairline cells (gap-px over the line color) so it wraps cleanly
@@ -40,6 +41,7 @@ export default function ShareBar({
text: string;
className?: string;
}) {
const shareParams = { content_type: "page", item_id: new URL(url).pathname };
return (
<div
className={twMerge(
@@ -56,6 +58,7 @@ export default function ShareBar({
copiedLabel="Link copied"
variant="ghost"
className={CELL_CLASSES}
{...trackAttrs("share", { method: "copy_link", ...shareParams })}
/>
{TARGETS.map((target) => (
<Button
@@ -66,6 +69,7 @@ export default function ShareBar({
aria-label={`Share on ${target.name}`}
variant="ghost"
className={CELL_CLASSES}
{...trackAttrs("share", { method: target.name.toLowerCase(), ...shareParams })}
>
{target.name}
</Button>
@@ -4,6 +4,7 @@ import { useState, type FormEvent } from "react";
import { CheckIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Button, Input, Label } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { webappUrl } from "@/lib/links";
// Submissions go to a Google Form whose responses land in a Google Sheet.
@@ -83,6 +84,7 @@ export default function BetaForm() {
// Google Forms sends no CORS headers, so this is fire-and-forget: the
// response is opaque and "request completed" counts as success.
await fetch(FORM_RESPONSE_URL, { method: "POST", mode: "no-cors", body });
trackEvent("generate_lead", { form_name: "beta", user_type: userType ?? "" });
setSubmitted(true);
} catch {
setSubmitError(
@@ -3,6 +3,7 @@
import { useState, type FormEvent } from "react";
import { CheckIcon, EyeIcon, EyeOffIcon } from "lucide-react";
import { Button, Input, Modal } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { login, signup } from "@/lib/api";
import {
getLandingUrl,
@@ -118,8 +119,9 @@ function SignupForm({
maybeReferralCode: getReferralCode(),
});
setLoading(false);
if (result.success) onSuccess();
else setError(result.errorMessage || "Failed to create account");
if (!result.success) return setError(result.errorMessage || "Failed to create account");
trackEvent("sign_up", { method: "email", signup_source: signupSource });
onSuccess();
};
return (
@@ -162,8 +164,9 @@ function LoginForm({ onSuccess }: { onSuccess: () => void }) {
setLoading(true);
const result = await login({ usernameOrEmail, password });
setLoading(false);
if (result.success) onSuccess();
else setError(result.errorMessage || "Invalid credentials");
if (!result.success) return setError(result.errorMessage || "Invalid credentials");
trackEvent("login", { method: "email" });
onSuccess();
};
return (
@@ -3,6 +3,7 @@
import { useState } from "react";
import { PlayIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { trackAttrs } from "@/lib/analytics";
import type { ExampleVideo } from "@/lib/campaign-data";
// Click-to-play facades: the mp4s (several MB each) are only requested
@@ -69,6 +70,11 @@ function ExampleCard({
onClick={() => setActive(true)}
aria-label={`Play example: ${example.label}`}
className="group absolute inset-0 h-full w-full"
{...trackAttrs("video_start", {
video_provider: "self_hosted",
video_title: example.label,
video_url: example.src,
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -9,6 +9,7 @@ import {
XIcon,
} from "lucide-react";
import { Button, Select, Switch } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { generateVideo, getVideoJob } from "@/lib/api";
import { H3, H3_GATE_PERKS, H3_SAMPLE_PROMPTS } from "@/lib/campaign-data";
import { webappUrl } from "@/lib/links";
@@ -101,6 +102,7 @@ export default function H3PromptBox() {
setSubmitting(false);
if (!result.success) {
patch(id, { status: "failed", error: result.errorMessage });
trackEvent("video_generation", { model: H3.modelId, status: "failed" });
return;
}
@@ -114,12 +116,15 @@ export default function H3PromptBox() {
if (job.status === "complete") {
patch(id, { status: "complete", videoUrl: job.videoUrl });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "complete" });
} else if (job.status === "failed") {
patch(id, { status: "failed", error: job.error });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "failed" });
} else if (attempts >= POLL_MAX_ATTEMPTS) {
patch(id, { status: "failed", error: "Generation timed out" });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "timeout" });
} else {
setTimeout(poll, POLL_INTERVAL_MS);
}
@@ -136,6 +141,14 @@ export default function H3PromptBox() {
setError(`Prompt exceeds the ${MAX_PROMPT_LENGTH} character limit for this model`);
return;
}
trackEvent("prompt_submit", {
model: H3.modelId,
logged_in: loggedIn,
aspect_ratio: aspectRatio,
resolution,
duration_seconds: Number(duration),
sound,
});
if (!loggedIn) {
setGateOpen(true);
return;
@@ -36,7 +36,7 @@ export default function Hero() {
data-reveal
className="relative mt-8 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl"
>
Controllable AI{" "}
Capable tools{" "}
<span className="font-serif italic font-normal text-muted">
for artists.
</span>
@@ -46,9 +46,8 @@ export default function Hero() {
data-reveal
className="relative mt-4 max-w-md text-lg leading-relaxed text-muted"
>
Artists need and deserve unparalleled control and precision.
ArtCraft&rsquo;s got you covered — compose in real 3D, then
render with AI.
ArtCraft builds open source tools for artists. We pride ourselves
on freedom of control and expression.
</p>
<div
@@ -2,6 +2,7 @@
import { useState } from "react";
import { PlayIcon } from "lucide-react";
import { trackAttrs } from "@/lib/analytics";
// Click-to-load YouTube embed: a thumbnail until the visitor asks for the
// player, so the landing never pays the iframe cost up front.
@@ -32,6 +33,11 @@ export default function LiteYouTube({
onClick={() => setActivated(true)}
className="group absolute inset-0 h-full w-full"
aria-label={`Play video: ${title}`}
{...trackAttrs("video_start", {
video_provider: "youtube",
video_title: title,
video_url: `https://www.youtube.com/watch?v=${videoId}`,
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { ArrowDownToLineIcon, CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { MEDIA_LABELS, corsMediaUrl, mediaDetailLabel, mediaFormat, mediaKind, mediaThumbnail, safeMediaUrl, type MediaDimensions, type MediaPrompt, type SharedMedia } from "@/lib/media";
import { getModelDisplayName, getModelIcon, getProviderDisplayName, getProviderIcon } from "@/lib/model-names";
@@ -35,6 +36,7 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
}
async function download() {
trackEvent("media_download", { media_type: kind, file_extension: mediaFormat(media) });
setDownloading(true);
setMessage("");
const controller = new AbortController();
@@ -102,7 +104,10 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
<h2 className="hud-label text-faint">Prompt</h2>
{prompt?.maybe_positive_prompt && (
<button type="button" className="text-muted hover:text-ink" aria-label={copied === "prompt" ? "Prompt copied" : "Copy prompt"}
onClick={() => copy(prompt.maybe_positive_prompt!, "prompt")}>
onClick={() => {
copy(prompt.maybe_positive_prompt!, "prompt");
trackEvent("copy_prompt", { media_type: kind });
}}>
{copied === "prompt" ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
</button>
)}
@@ -143,7 +148,10 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
<Button onClick={download} loading={downloading} className="w-full">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />{downloading ? "Downloading" : "Download file"}
</Button>
<Button variant="secondary" className="w-full" onClick={() => copy(`${SHARE_URL_BASE}${encodeURIComponent(media.token)}`, "link")}>
<Button variant="secondary" className="w-full" onClick={() => {
copy(`${SHARE_URL_BASE}${encodeURIComponent(media.token)}`, "link");
trackEvent("share", { method: "copy_link", content_type: kind, item_id: media.token });
}}>
{copied === "link" ? <CheckIcon aria-hidden className="h-4 w-4" /> : <CopyIcon aria-hidden className="h-4 w-4" />}
{copied === "link" ? "Link copied" : "Copy share link"}
</Button>
@@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
@@ -41,5 +42,44 @@ export default function MotionProvider({
};
}, []);
return <>{children}</>;
return (
<>
{children}
<RouteScrollReset />
</>
);
}
// Lenis keeps its own scroll target, so the router's scroll-to-top on a
// client navigation gets eased straight back to the previous page's offset.
// Snap Lenis to the top on each new route, except on the first render (a
// reload keeps its restored offset), on back/forward (the browser restores
// the old offset) and for #hash targets (anchors scroll themselves).
// Reduced-motion visitors have no Lenis; the router's native reset already
// works for them.
function RouteScrollReset() {
const pathname = usePathname();
const lastPathname = useRef(pathname);
// Path a back/forward traversal landed on. A hash-only traversal never
// changes the pathname, so it can't suppress the next real navigation.
const traversalPath = useRef<string | null>(null);
useEffect(() => {
const onPopState = () => {
traversalPath.current = window.location.pathname;
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
useEffect(() => {
if (pathname === lastPathname.current) return;
lastPathname.current = pathname;
const traversed = traversalPath.current === pathname;
traversalPath.current = null;
if (traversed || window.location.hash) return;
lenisRef.current?.scrollTo(0, { immediate: true, force: true });
}, [pathname]);
return null;
}
@@ -11,6 +11,7 @@ import {
import { twMerge } from "tailwind-merge";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Button, Modal } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import { mediaUrl } from "@/lib/links";
import {
PRESS_KIT_CATEGORIES,
@@ -148,6 +149,10 @@ function AssetCard({
onClick={onPlay}
aria-label={`Play ${asset.title}`}
className="group relative aspect-video w-full overflow-hidden bg-bg-sunken"
{...trackAttrs("video_start", {
video_provider: asset.type === "embed" ? "youtube" : "self_hosted",
video_title: asset.title,
})}
>
{thumbnail}
</button>
@@ -175,6 +180,7 @@ function AssetCard({
rel="noopener noreferrer"
variant="secondary"
className="w-full"
{...trackAttrs("press_kit_download", { asset_name: asset.title })}
>
<ArrowDownToLineIcon aria-hidden className="h-3.5 w-3.5" />
{asset.downloadLabel ?? "Download"}
@@ -3,6 +3,7 @@
import { useState } from "react";
import { CoinsIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { webappUrl } from "@/lib/links";
import { useAccount } from "@/lib/use-account";
import CreditsModal from "./credits-modal";
@@ -28,7 +29,10 @@ export default function CreditsCta() {
<Button
type="button"
variant="secondary"
onClick={() => setOpen(true)}
onClick={() => {
setOpen(true);
trackEvent("view_credit_packs", {});
}}
className="mt-6"
>
<CoinsIcon aria-hidden className="h-3.5 w-3.5 text-accent-ink" />
@@ -4,6 +4,7 @@ import { useState } from "react";
import { CoinsIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Button, Modal } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { creditsPackCheckout } from "@/lib/api";
type CreditPack = {
@@ -35,6 +36,20 @@ export default function CreditsModal({
const purchase = async (pack: CreditPack) => {
setPurchasingId(pack.id);
setError(null);
trackEvent("begin_checkout", {
currency: "USD",
value: pack.priceUsd,
checkout_type: "credits",
items: [
{
item_id: pack.id,
item_name: `${pack.total.toLocaleString("en-US")} credits`,
item_category: "credits",
price: pack.priceUsd,
quantity: 1,
},
],
});
const result = await creditsPackCheckout(pack.id);
if (result.success) {
window.location.href = result.data.checkoutUrl;
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Badge, Button, TabSelector } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import {
getPortalUrl,
subscriptionCheckout,
@@ -85,9 +86,26 @@ export default function PricingTable({
if (plan.slug === activePlanSlug) return;
setProcessingPlan(plan.slug);
setError(null);
const checkoutType = !user ? "signup" : hasActivePlan ? "switch" : "subscribe";
const price = cadence === "yearly" ? plan.yearlyPrice : plan.monthlyPrice;
trackEvent("begin_checkout", {
currency: "USD",
value: price,
checkout_type: checkoutType,
items: [
{
item_id: plan.slug,
item_name: plan.name,
item_category: "subscription",
item_variant: cadence,
price,
quantity: 1,
},
],
});
const body = { plan: plan.slug, cadence };
const redirected = redirect(
!user
checkoutType === "signup"
? await userSignupSubscriptionCheckout({
...body,
maybeReferralUrl: getReferrer(),
@@ -95,7 +113,7 @@ export default function PricingTable({
maybeReferralUsername: getReferralUsername(),
maybeReferralCode: getReferralCode(),
})
: hasActivePlan
: checkoutType === "switch"
? await switchPlan(body)
: await subscriptionCheckout(body),
);
@@ -103,6 +121,7 @@ export default function PricingTable({
};
const managePlan = async () => {
trackEvent("manage_subscription", {});
setManaging(true);
setError(null);
if (!redirect(await getPortalUrl())) setManaging(false);
@@ -115,7 +134,11 @@ export default function PricingTable({
<TabSelector
tabs={BILLING_TABS}
activeTab={cadence}
onTabChange={(id) => setCadence(id as BillingCadence)}
onTabChange={(id) => {
if (id === cadence) return;
setCadence(id as BillingCadence);
trackEvent("billing_cadence_change", { cadence: id });
}}
tabClassName="w-24"
/>
<Badge
@@ -363,6 +386,7 @@ function ContactButtons({ className }: { className?: string }) {
try {
await navigator.clipboard.writeText(CONTACT_EMAIL);
setCopied(true);
trackEvent("contact_click", { method: "copy_email" });
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard unavailable (insecure context) — the mailto still works.
@@ -86,6 +86,7 @@ export function Button({
</>
);
// Link buttons carry data-cta so analytics can report them as CTA clicks.
if (href) {
const anchorProps =
rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
@@ -96,6 +97,7 @@ export function Button({
target={target}
rel={rel}
className={classes}
data-cta=""
{...anchorProps}
>
{content}
@@ -103,7 +105,7 @@ export function Button({
);
}
return (
<Link href={href} className={classes} {...anchorProps}>
<Link href={href} className={classes} data-cta="" {...anchorProps}>
{content}
</Link>
);
@@ -0,0 +1,232 @@
// Google Analytics 4 (gtag.js). Off unless NEXT_PUBLIC_GA_MEASUREMENT_ID is
// set at build time, so local dev stays out of the reports.
//
// Page views come from GA4 Enhanced Measurement (it records App Router
// navigations via history changes), along with scrolls, outbound clicks and
// common file downloads. On top of that, the site sends the events below.
// Most link clicks need no per-component code: one delegated listener
// (see components/analytics.tsx) classifies them by href, and elements
// tagged with trackAttrs() report their event when clicked. Every delegated
// event also carries `link_location` (nav, footer, modal or the enclosing
// section id).
import { SOCIAL_LINKS, WEBAPP_URL } from "./links";
export const GA_MEASUREMENT_ID =
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID?.trim() || undefined;
export type AnalyticsItem = {
item_id: string;
item_name: string;
item_category: "subscription" | "credits";
item_variant?: string;
price: number;
quantity: number;
};
type AnalyticsEvents = {
// Link clicks, classified by href.
app_download: {
app_name: string;
app_version: string;
platform: "macos" | "windows" | "linux" | "other";
file_name: string;
};
webapp_click: { destination: string; link_text: string };
social_click: { network: string };
contact_click: { method: "mailto" | "copy_email" };
cta_click: { link_url: string; link_text: string };
// Clicks on elements tagged with trackAttrs().
video_start: { video_provider: string; video_title: string; video_url?: string };
share: { method: string; content_type: string; item_id: string };
copy_command: { app_name: string };
app_select: { app_name: string };
press_kit_download: { asset_name: string };
// Sent from component code.
generate_lead: { form_name: string; user_type: string };
sign_up: { method: "email"; signup_source: string };
login: { method: "email" };
prompt_submit: {
model: string;
logged_in: boolean;
aspect_ratio: string;
resolution: string;
duration_seconds: number;
sound: boolean;
};
video_generation: { model: string; status: "complete" | "failed" | "timeout" };
billing_cadence_change: { cadence: string };
view_credit_packs: Record<string, never>;
begin_checkout: {
currency: "USD";
value: number;
checkout_type: "signup" | "subscribe" | "switch" | "credits";
items: AnalyticsItem[];
};
manage_subscription: Record<string, never>;
copy_prompt: { media_type: string };
media_download: { media_type: string; file_extension: string };
};
export type AnalyticsEvent = keyof AnalyticsEvents;
type ParamValue = string | number | boolean | AnalyticsItem[] | undefined;
type EventParams = Record<string, ParamValue>;
declare global {
interface Window {
gtag?: (...args: unknown[]) => void;
}
}
export function trackEvent<E extends AnalyticsEvent>(
event: E,
params: AnalyticsEvents[E],
): void {
send(event, params);
}
/**
* Data attributes that make an element report `event` when clicked. Works in
* server components (no handler needed); spread onto the element or Button.
*/
export function trackAttrs<E extends AnalyticsEvent>(
event: E,
params: AnalyticsEvents[E],
): Record<`data-track${string}`, string> {
const attrs: Record<`data-track${string}`, string> = { "data-track": event };
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) attrs[`data-track-${key}`] = String(value);
}
return attrs;
}
/** Delegated click handler: reports tagged elements and classified links. */
export function trackClick(target: EventTarget | null): void {
if (!(target instanceof Element)) return;
const tagged = target.closest<HTMLElement>("[data-track]");
if (tagged?.dataset.track) {
send(tagged.dataset.track, {
...taggedParams(tagged),
link_location: linkLocation(tagged),
});
return;
}
const anchor = target.closest<HTMLAnchorElement>("a[href]");
const link = anchor && classifyLink(anchor);
if (link) send(link.event, { ...link.params, link_location: linkLocation(anchor) });
}
function send(event: string, params: EventParams): void {
if (!GA_MEASUREMENT_ID) return;
window.gtag?.("event", event, params);
}
const WEBAPP_ORIGIN = new URL(WEBAPP_URL).origin;
// Hostname → network, derived from SOCIAL_LINKS.
const SOCIAL_NETWORKS = new Map(
Object.entries(SOCIAL_LINKS).map(([name, href]) => [
bareHostname(new URL(href).hostname),
name.toLowerCase(),
]),
);
// github.com/<org>/<repo>/releases/download/<tag>/<file>: the ArtCraft
// installers and every Crafting App download.
const RELEASE_ASSET_PATH = /^\/[^/]+\/([^/]+)\/releases\/download\/([^/]+)\/([^/]+)$/;
type ClassifiedLink = {
[E in AnalyticsEvent]: { event: E; params: AnalyticsEvents[E] };
}[AnalyticsEvent];
function classifyLink(anchor: HTMLAnchorElement): ClassifiedLink | null {
let url: URL;
try {
url = new URL(anchor.href);
} catch {
return null;
}
if (url.protocol === "mailto:") {
return { event: "contact_click", params: { method: "mailto" } };
}
const release = url.hostname === "github.com" && RELEASE_ASSET_PATH.exec(url.pathname);
if (release) {
const [, repo, tag, file] = release;
const fileName = decodeURIComponent(file);
return {
event: "app_download",
params: {
app_name: repo,
app_version: tag.replace(/^\D*/, ""),
platform: downloadPlatform(fileName),
file_name: fileName,
},
};
}
if (url.origin === WEBAPP_ORIGIN) {
return {
event: "webapp_click",
params: { destination: url.pathname, link_text: linkText(anchor) },
};
}
const network = SOCIAL_NETWORKS.get(bareHostname(url.hostname));
if (network) return { event: "social_click", params: { network } };
// Remaining Button links (internal CTAs, other outbound buttons). Plain
// text links are left to page views and Enhanced Measurement.
if (anchor.hasAttribute("data-cta")) {
return {
event: "cta_click",
params: {
link_url: url.origin === window.location.origin ? url.pathname + url.hash : url.href,
link_text: linkText(anchor),
},
};
}
return null;
}
function linkLocation(element: Element): string {
if (element.closest("#site-nav")) return "nav";
if (element.closest("footer")) return "footer";
if (element.closest('[role="dialog"]')) return "modal";
return element.closest("section[id]")?.id || "page";
}
// data-track-<param> attributes surface in the dataset as track<Param>.
function taggedParams(element: HTMLElement): EventParams {
const params: EventParams = {};
for (const [key, value] of Object.entries(element.dataset)) {
if (key.length > 5 && key.startsWith("track")) {
params[key.charAt(5).toLowerCase() + key.slice(6)] = value;
}
}
return params;
}
function downloadPlatform(fileName: string): AnalyticsEvents["app_download"]["platform"] {
if (/\.dmg$|macos/i.test(fileName)) return "macos";
if (/\.(exe|msi)$|windows/i.test(fileName)) return "windows";
if (/\.(appimage|deb|rpm)$|linux/i.test(fileName)) return "linux";
return "other";
}
// GA truncates parameter values at 100 characters.
function linkText(anchor: HTMLAnchorElement): string {
const text = anchor.getAttribute("aria-label") || anchor.textContent || "";
return text.replace(/\s+/g, " ").trim().slice(0, 100);
}
function bareHostname(hostname: string): string {
return hostname.replace(/^www\./, "");
}
@@ -0,0 +1,185 @@
import type { CraftAppSlug } from "./crafting-apps";
// GitHub releases for the Crafting Apps, which drive every download button on
// the /apps pages.
//
// To bump every app: change CRAFTING_APPS_LATEST_VERSION.
// To pin one app to another version: give it its own release in
// CRAFT_APP_RELEASES, e.g. `craftRelease("0.1.1")`.
// To hide an app's downloads (Discord waitlist instead): set it to null.
// If a release renames, adds or drops files, edit CRAFT_RELEASE_ASSETS, or
// pass a custom list as craftRelease's second argument for a single app.
//
// Release tags are `v<version>` and live at github.com/storytold/<slug>.
export const CRAFTING_APPS_LATEST_VERSION = "0.2.1";
export type CraftDesktopPlatform = "macOS" | "Windows" | "Linux";
/** Order of the per-platform download buttons. */
export const CRAFT_DESKTOP_PLATFORMS: CraftDesktopPlatform[] = ["macOS", "Windows", "Linux"];
export type CraftReleaseAsset = {
/** Asset filename; `{slug}` and `{version}` are filled in per app. */
file: string;
/** Group in the full file list. "Other" holds the CLI, web build and checksums. */
group: CraftDesktopPlatform | "Other";
/** Short name, e.g. "Installer (x64)". */
label: string;
/** One line on who should pick this file. */
description: string;
/** CPU architecture, used to choose between recommended Linux builds. */
arch?: "universal" | "x64" | "x86" | "x86_64" | "aarch64";
/**
* Offered as a platform's main download button. The first recommended
* asset in a group is the default; a later one wins only when its `arch`
* matches the visitor's detected CPU.
*/
recommended?: boolean;
};
export type CraftRelease = {
/** Version without the leading "v", e.g. "0.2.0". */
version: string;
/** Git tag. Defaults to `v<version>`. */
tag?: string;
assets: readonly CraftReleaseAsset[];
};
// The file set every Crafting App publishes, in display order within each group.
export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [
{
file: "{slug}-{version}-macos-universal.dmg",
group: "macOS",
label: "Disk image (Universal)",
description: "Apple silicon and Intel Macs.",
arch: "universal",
recommended: true,
},
{
file: "{slug}-cli-{version}-macos-universal.zip",
group: "macOS",
label: "Command-line tool (Universal)",
description: "The headless CLI for scripts and agents.",
arch: "universal",
},
{
file: "{slug}-{version}-windows-x64.msi",
group: "Windows",
label: "Installer (64-bit)",
description: "Most Windows 10 and 11 PCs.",
arch: "x64",
recommended: true,
},
{
file: "{slug}-{version}-windows-x64-portable.zip",
group: "Windows",
label: "Portable (64-bit)",
description: "Unzip and run, no install.",
arch: "x64",
},
{
file: "{slug}-{version}-windows-x86.msi",
group: "Windows",
label: "Installer (32-bit)",
description: "Older 32-bit Windows.",
arch: "x86",
},
{
file: "{slug}-{version}-windows-x86-portable.zip",
group: "Windows",
label: "Portable (32-bit)",
description: "Unzip and run on 32-bit Windows.",
arch: "x86",
},
{
file: "{slug}-{version}-linux-x86_64.AppImage",
group: "Linux",
label: "AppImage (x86_64)",
description: "Any distro. Mark it executable and run.",
arch: "x86_64",
recommended: true,
},
{
file: "{slug}-{version}-linux-x86_64.deb",
group: "Linux",
label: "Debian package (x86_64)",
description: "Debian, Ubuntu and derivatives.",
arch: "x86_64",
},
{
file: "{slug}-{version}-linux-x86_64.rpm",
group: "Linux",
label: "RPM package (x86_64)",
description: "Fedora, RHEL and openSUSE.",
arch: "x86_64",
},
{
file: "{slug}-{version}-linux-x86_64.tar.gz",
group: "Linux",
label: "Tarball (x86_64)",
description: "Plain binaries to unpack anywhere.",
arch: "x86_64",
},
{
file: "{slug}-{version}-linux-aarch64.AppImage",
group: "Linux",
label: "AppImage (ARM64)",
description: "Any distro on 64-bit ARM.",
arch: "aarch64",
recommended: true,
},
{
file: "{slug}-{version}-linux-aarch64.deb",
group: "Linux",
label: "Debian package (ARM64)",
description: "Debian, Ubuntu and derivatives on ARM.",
arch: "aarch64",
},
{
file: "{slug}-{version}-linux-aarch64.rpm",
group: "Linux",
label: "RPM package (ARM64)",
description: "Fedora, RHEL and openSUSE on ARM.",
arch: "aarch64",
},
{
file: "{slug}-{version}-linux-aarch64.tar.gz",
group: "Linux",
label: "Tarball (ARM64)",
description: "Plain binaries for 64-bit ARM.",
arch: "aarch64",
},
{
file: "{slug}-web-{version}.zip",
group: "Other",
label: "Web build",
description: "The WebAssembly app, to host yourself.",
},
{
file: "SHA256SUMS.txt",
group: "Other",
label: "SHA-256 checksums",
description: "Verify any download above.",
},
];
export function craftRelease(
version: string,
assets: readonly CraftReleaseAsset[] = CRAFT_RELEASE_ASSETS,
): CraftRelease {
return { version, assets };
}
const LATEST = craftRelease(CRAFTING_APPS_LATEST_VERSION);
// Every app must be listed, so a new slug fails the type check until it is.
export const CRAFT_APP_RELEASES: Record<CraftAppSlug, CraftRelease | null> = {
photocraft: craftRelease("0.2.0"),
vectorcraft: craftRelease("0.3.1"),
filmcraft: LATEST,
lightcraft: LATEST,
printcraft: LATEST,
effectcraft: craftRelease("0.3.1"),
designcraft: LATEST,
};
@@ -1,4 +1,9 @@
import type { Tip } from "./campaign-data";
import {
CRAFT_APP_RELEASES,
type CraftRelease,
type CraftReleaseAsset,
} from "./crafting-app-releases";
import { mediaUrl } from "./links";
// The Crafting Apps family: open-source native apps from the ArtCraft team,
@@ -9,15 +14,20 @@ import { mediaUrl } from "./links";
// Facts (counts, timings, formats) come from each repo's README — keep them
// in sync when the READMEs change.
//
// No installers are published yet. When an app ships a release, give it
// `downloads` and the page swaps its "coming soon" cell for the buttons.
// Download versions and files live in crafting-app-releases.ts. Apps whose
// release there is null show a Discord waitlist instead of installers.
//
// Assets live in public/images/apps/<slug>/: icon.webp (the repo's 512 px
// hicolor render at 256 px), og.jpg (1200×630 share card) and the shots.
export type CraftAppSlug =
| "photocraft"
| "drawcraft"
| "vectorcraft"
| "filmcraft"
| "lightcraft"
| "printcraft";
| "printcraft"
| "effectcraft"
| "designcraft";
export type CraftPlatform = "macOS" | "Windows" | "Linux" | "Web";
@@ -28,15 +38,17 @@ export type CraftShot = {
source: string;
caption: string;
alt: string;
/** Portrait page (7:9) rather than a 16:10 window capture. */
portrait?: boolean;
};
export type CraftApp = {
slug: CraftAppSlug;
/** Prefix before "Craft" — the wordmark sets "Craft" in the serif. */
/** Prefix before "Craft" — the wordmark colors "Craft". */
prefix: string;
category: string;
status: string;
/** Headline split around its one serif-italic contrast word. */
/** Headline split around its one color-accented contrast word. */
headline: [before: string, accent: string, after: string];
/** One line for cards and share text. */
pitch: string;
@@ -48,7 +60,12 @@ export type CraftApp = {
features: Tip[];
/** First shot is the hero. */
shots: [CraftShot, ...CraftShot[]];
downloads?: { label: string; href: string }[];
};
/** One release file, resolved to a URL for a specific app. */
export type CraftDownload = Omit<CraftReleaseAsset, "file"> & {
fileName: string;
href: string;
};
export const CRAFTING_APPS_GITHUB_ORG = "https://github.com/storytold";
@@ -119,8 +136,8 @@ export const CRAFTING_APPS: CraftApp[] = [
],
},
{
slug: "drawcraft",
prefix: "Draw",
slug: "vectorcraft",
prefix: "Vector",
category: "Vector illustration",
status: "In development",
headline: ["Vector illustration, ", "reimagined", " in pure Rust."],
@@ -159,26 +176,26 @@ export const CRAFTING_APPS: CraftApp[] = [
{
file: "hero.webp",
source: "shot-1-neon.png",
caption: "Neon Drive — made in DrawCraft",
alt: "DrawCraft editing a synthwave poster titled Neon Drive with an outer glow in the Appearance panel",
caption: "Neon Drive — made in VectorCraft",
alt: "VectorCraft editing a synthwave poster titled Neon Drive with an outer glow in the Appearance panel",
},
{
file: "ribbons.webp",
source: "shot-2-ribbons.png",
caption: "Live blends between editable spines",
alt: "DrawCraft showing Live Blends: 70 smooth-color steps between two ribbon paths",
alt: "VectorCraft showing live blends: 70 smooth-color steps between two ribbon paths",
},
{
file: "sheet.webp",
source: "shot-3-sheet.png",
caption: "Booleans, mesh, repeat and envelope",
alt: "DrawCraft feature sheet with exact booleans, a gradient mesh, a live radial repeat and an envelope distort",
alt: "VectorCraft feature sheet with exact booleans, a gradient mesh, a live radial repeat and an envelope distort",
},
{
file: "bezier.webp",
source: "shot-4-bezier.png",
caption: "Direct selection on bezier anchors",
alt: "DrawCraft direct-selecting the anchors and handles of a crescent moon path",
alt: "VectorCraft direct-selecting the anchors and handles of a crescent moon path",
},
],
},
@@ -374,8 +391,152 @@ export const CRAFTING_APPS: CraftApp[] = [
},
],
},
{
slug: "effectcraft",
prefix: "Effect",
category: "Motion graphics & VFX",
status: "In development",
headline: ["Motion graphics and visual ", "effects", ", in pure Rust."],
pitch: "Open-source motion graphics and visual effects, built in pure Rust.",
lede: "Compositions, layers, keyframes, 259 effects, expressions, 3D cameras and lights, and a render queue in a native compositor written in pure Rust. Young, moving fast, and already usable.",
schemaCategory: "MultimediaApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.95",
features: [
{
title: "Animate the way you know",
body: "Familiar panels and keyframes: linear, bezier, hold and eased keys, roving keys, and a graph editor with value and speed curves.",
},
{
title: "Layers of every kind",
body: "Solids, shapes, text, footage, nested compositions, nulls, adjustment layers, cameras and lights, with parenting, track mattes and 38 blend modes.",
},
{
title: "259 effects",
body: "Blur, color correction, distortion, generators, keying, particles and simulation, stylize and time effects, each with sensible defaults.",
},
{
title: "Real 3D",
body: "3D layers with cameras, depth of field and lights that cast soft ray-traced shadows, plus orbit, pan and dolly tools.",
},
{
title: "Expressions",
body: "JavaScript expressions with wiggle, loops and layer references, edited inline and linked straight to any property.",
},
{
title: "Export anywhere",
body: "H.264 and ProRes with alpha, PNG, TIFF and 32-bit EXR sequences, GIF, and Lottie import and export for the web. No FFmpeg inside.",
},
],
shots: [
{
file: "hero.webp",
source: "effectcraft-hero.png",
caption: "An animated title on the timeline",
alt: "EffectCraft's composition panel showing an animated EFFECTCRAFT title, with the project panel, a timeline of text, shape and solid layers, and the properties panel",
},
{
file: "graph-editor.webp",
source: "effectcraft-graph-editor.png",
caption: "Graph editor with eased keyframes",
alt: "EffectCraft's graph editor showing an eased value curve for a text animator",
},
{
file: "effects.webp",
source: "effectcraft-effects.png",
caption: "Particle simulation effects",
alt: "EffectCraft's effect controls for a particle simulation bursting from the center of the composition",
},
{
file: "3d.webp",
source: "effectcraft-3d.png",
caption: "3D cameras, lights and soft shadows",
alt: "EffectCraft's 3D showcase: intersecting cards lit by a spot light with soft shadows on a gridded floor, seen from a custom camera view",
},
],
},
{
slug: "designcraft",
prefix: "Design",
category: "Page layout & publishing",
status: "In development",
headline: ["Page layout and ", "publishing", ", rebuilt in pure Rust."],
pitch: "Open-source page layout and publishing, rebuilt in pure Rust.",
lede: "Spreads and parent pages, threaded stories, styles, swatches and text wrap in a native layout app with a professional paragraph composer. No subscription, no licence server, no telemetry.",
schemaCategory: "DesignApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "The layout tools you know",
body: "Spreads and parent pages, frames and threaded stories, paragraph and character styles, swatches and text wrap, laid out the way you expect.",
},
{
title: "Beautiful type",
body: "A Knuth–Plass paragraph composer, dictionary hyphenation, optical margin alignment and baseline grids. Line breaks match on screen and in PDF.",
},
{
title: "Print-ready PDF",
body: "Real selectable text with embedded fonts, CMYK and spot colors, bleed, crop marks and archival PDF/A output.",
},
{
title: "Fast",
body: "Multithreaded SIMD rendering, copy-on-write documents with instant undo, and cached composition.",
},
{
title: "Open formats",
body: "A documented native format, PNG export, and layout interchange import and export for moving work between tools.",
},
{
title: "Agent-native",
body: "Every menu item, tool, panel and dialog can be driven over a JSON control channel and an MCP server.",
},
],
shots: [
{
file: "hero.webp",
source: "ui-spread.png",
caption: "Threaded columns and text wrap — Quarterly",
alt: "DesignCraft showing a magazine spread: a threaded three-column story with its thread line, a wrapped pull quote, and the Properties panel's text frame options",
},
{
file: "cover.webp",
source: "page-cover.png",
caption: "Cover",
alt: "Magazine cover for The Spring Issue: a dusk landscape of layered purple hills under a pale sun, with the serif headline The Quiet Art of Layout",
portrait: true,
},
{
file: "feature.webp",
source: "page-2.png",
caption: "Styles",
alt: "Feature opener titled Notes on the Grid with a kicker rule, italic deck, landscape picture, caption and two columns of justified body text",
portrait: true,
},
{
file: "columns.webp",
source: "page-3.png",
caption: "Threading and wrap",
alt: "Three columns of justified, hyphenated body text wrapping around a shaded pull quote",
portrait: true,
},
{
file: "swatches.webp",
source: "page-4.png",
caption: "Swatches",
alt: "Coming Next page titled Color, Ink and Paper on a plum background with four labeled color swatches",
portrait: true,
},
],
},
];
const COUNT_WORDS = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
/** "Seven" — the family size as a capitalized word, for headlines. */
export const CRAFTING_APPS_COUNT_WORD =
COUNT_WORDS[CRAFTING_APPS.length] ?? String(CRAFTING_APPS.length);
const LIST_FORMAT = new Intl.ListFormat("en", { type: "conjunction" });
/** "a, b and c" — Oxford comma per the en locale. */
@@ -451,6 +612,49 @@ export function craftShotSourceUrl(app: CraftApp, shot: CraftShot): string {
return `https://raw.githubusercontent.com/storytold/${app.slug}/main/docs/images/${shot.source}`;
}
export function craftAppIconPath(app: CraftApp): string {
return `/images/apps/${app.slug}/icon.webp`;
}
export function craftAppIconUrl(app: CraftApp): string {
return mediaUrl(craftAppIconPath(app));
}
/** The repo's 1024 px icon render (press kit download). */
export function craftAppIconSourceUrl(app: CraftApp): string {
return `https://raw.githubusercontent.com/storytold/${app.slug}/main/assets/app-icon/${app.slug}-1024.png`;
}
export function craftAppRelease(app: CraftApp): CraftRelease | null {
return CRAFT_APP_RELEASES[app.slug];
}
export function craftReleaseTag(release: CraftRelease): string {
return release.tag ?? `v${release.version}`;
}
export function craftReleasePageUrl(app: CraftApp, release: CraftRelease): string {
return `${craftAppRepo(app)}/releases/tag/${craftReleaseTag(release)}`;
}
export function craftReleasesUrl(app: CraftApp): string {
return `${craftAppRepo(app)}/releases`;
}
/** Every file in the release, in catalog order. */
export function craftReleaseDownloads(
app: CraftApp,
release: CraftRelease,
): CraftDownload[] {
const base = `${craftAppRepo(app)}/releases/download/${craftReleaseTag(release)}`;
return release.assets.map(({ file, ...asset }) => {
const fileName = file
.replaceAll("{slug}", app.slug)
.replaceAll("{version}", release.version);
return { ...asset, fileName, href: `${base}/${fileName}` };
});
}
export function craftAppOgImage(app: CraftApp): string {
return mediaUrl(`/images/apps/${app.slug}/og.jpg`);
}
@@ -9,6 +9,8 @@
import {
CRAFTING_APPS,
craftAppIconPath,
craftAppIconSourceUrl,
craftAppName,
craftShotPath,
craftShotSourceUrl,
@@ -108,6 +110,18 @@ export const PRESS_KIT_CATEGORIES: PressKitCategory[] = [
},
],
},
{
name: "Crafting Apps icons",
description: "App icons for every Crafting App, 1024 px PNG",
assets: CRAFTING_APPS.map((app) => ({
type: "image" as const,
title: `${craftAppName(app)} icon`,
thumbnail: craftAppIconPath(app),
downloadUrl: craftAppIconSourceUrl(app),
downloadLabel: "1024 px PNG",
containThumbnail: true,
})),
},
{
name: "Screenshots & media",
description: "High-resolution screenshots and promotional images",
+21 -23
View File
@@ -1,42 +1,40 @@
#!/usr/bin/env bash
# This works on Linux and MacOS to launch the frontend dev server
# This works on Linux and MacOS to launch the website (Next.js) dev server
root_dir=$(pwd)
frontend_path="${root_dir}/frontend"
website_path="${frontend_path}/apps/artcraft-website-next"
port=3000
source "${root_dir}/script/common/frontend_preflight.sh"
frontend_preflight "${frontend_path}"
echo "Running Artcraft Website in Dev Mode..."
echo "Running Artcraft Website (Next.js) in Dev Mode..."
echo ""
# Kill any process running on port 5741, which will block startup
if lsof -i tcp:4200 &>/dev/null; then
lsof -i tcp:4200 -t | xargs kill -9
echo "Killed process running on port 4200"
# Kill any process running on the dev port, which will block startup
if lsof -i tcp:${port} &>/dev/null; then
lsof -i tcp:${port} -t | xargs kill -9
echo "Killed process running on port ${port}"
else
echo "No process running on port 4200"
echo "No process running on port ${port}"
fi
pushd "${frontend_path}" || exit
# The Next.js app is excluded from the frontend npm workspace and has its own
# package-lock.json, so it installs and runs from its own directory.
pushd "${website_path}" || exit
frontend_npm_install
export VITE_ENVIRONMENT_TYPE="production"
# Backend devs: launch with USE_LOCAL_API=1 to point the website at a local
# storyteller-web on http://localhost:12345. Otherwise it hits production.
if [[ "${USE_LOCAL_API:-0}" == "1" ]]; then
export NEXT_PUBLIC_API_HOST="http://localhost:12345"
echo "USE_LOCAL_API=1 — API calls will target ${NEXT_PUBLIC_API_HOST}"
else
echo "API calls will target production (set USE_LOCAL_API=1 for a local backend)"
fi
# # Backend devs: launch with USE_LOCAL_API=1 (or `export USE_LOCAL_API=1` once
# # in your shell profile) to point the frontend at a local storyteller-web on
# # http://localhost:12345. Unset/0 — the frontend-dev default — hits
# # production. This replaces the comment/uncomment dance around
# # StorytellerApiHostStore.setDevelopment() in src/main.tsx.
# export VITE_USE_LOCAL_API="${USE_LOCAL_API:-0}"
# if [[ "${VITE_USE_LOCAL_API}" == "1" ]]; then
# echo "USE_LOCAL_API=1 — API calls will target http://localhost:12345"
# else
# echo "API calls will target production (set USE_LOCAL_API=1 for a local backend)"
# fi
export VITE_USE_LOCAL_API="true"
nx dev artcraft-website
npm run dev -- --port ${port}
popd || exit