Compare commits

...
Author SHA1 Message Date
Brandon Thomas 036e378ac7 App downloads 2026-10-05 06:16:53 -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
43 changed files with 874 additions and 97 deletions

No files matched your search

@@ -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

@@ -5,7 +5,9 @@ import {
AppGallery,
AppGetIt,
} from "@/components/apps/app-sections";
import { AppHeroDownloadButton } from "@/components/apps/app-downloads";
import {
AppIcon,
AppTab,
AppWordmark,
ColorAccent,
@@ -24,7 +26,11 @@ import {
craftAppName,
craftAppOgImage,
craftAppPath,
craftAppIconUrl,
craftAppRelease,
craftAppRepo,
craftReleaseDownloads,
craftReleasePageUrl,
craftShotUrl,
getCraftApp,
} from "@/lib/crafting-apps";
@@ -69,6 +75,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 +88,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 +113,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 +135,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 +143,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"
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
BotIcon,
CodeXmlIcon,
@@ -17,22 +18,26 @@ 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 {
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 +72,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 +95,31 @@ 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)}`}
>
<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 +130,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
@@ -5,14 +5,16 @@ import { Badge } from "@/components/ui";
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,6 +25,7 @@ export default function AppCard({
compact?: boolean;
}) {
const [hero] = app.shots;
const release = craftAppRelease(app);
return (
<Link
href={craftAppPath(app)}
@@ -47,22 +50,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,130 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowDownToLineIcon } from "lucide-react";
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 download buttons for /apps/<slug>. Detection runs after mount
// (no user agent on the server), so the first paint offers every platform
// equally and the "for your system" button arrives with hydration. Phones,
// tablets and Chromebooks keep the neutral layout.
type DetectedDesktop = {
platform: CraftDesktopPlatform;
arch?: CraftDownload["arch"];
};
/** Main buttons in the Get-it section: your platform first, then the rest. */
export function AppDownloadButtons({
recommended,
version,
}: {
/** The release's `recommended` files. */
recommended: CraftDownload[];
version: string;
}) {
const detected = useDetectedDesktop();
const mine = detected && pickDownload(recommended, detected);
const defaults = CRAFT_DESKTOP_PLATFORMS.flatMap((platform) => {
const download = pickDownload(recommended, { platform });
return download ? [download] : [];
});
if (!mine) {
return (
<div className="mt-8 flex flex-wrap gap-3">
{defaults.map((download) => (
<Button key={download.href} href={download.href} size="lg">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
{download.group}
</Button>
))}
</div>
);
}
const others = defaults.filter((download) => download.group !== mine.group);
return (
<div className="mt-8 flex flex-col gap-3">
<Button href={mine.href} size="lg">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
Download for {mine.group}
</Button>
<p className="hud-label text-faint">
{mine.label} · v{version}
</p>
{others.length > 0 && (
<div className="mt-3 flex flex-wrap gap-3">
{others.map((download) => (
<Button
key={download.href}
href={download.href}
variant="secondary"
size="md"
>
<ArrowDownToLineIcon aria-hidden className="h-3.5 w-3.5" />
{download.group}
</Button>
))}
</div>
)}
</div>
);
}
/** 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>
);
}
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(
recommended: CraftDownload[],
{ platform, arch }: DetectedDesktop,
): CraftDownload | undefined {
const candidates = recommended.filter((download) => download.group === platform);
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,50 @@
import Link from "next/link";
import { ArrowDownToLineIcon, ArrowUpRightIcon, TerminalIcon } from "lucide-react";
import type { ReactNode } from "react";
import {
ArrowDownToLineIcon,
ArrowUpRightIcon,
ChevronDownIcon,
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 { CRAFT_DESKTOP_PLATFORMS } from "@/lib/crafting-app-releases";
import {
CRAFTING_APPS,
craftAppBuildCommand,
craftAppIndex,
craftAppName,
craftAppRelease,
craftAppRepo,
craftReleaseDownloads,
craftReleasePageUrl,
craftReleasesUrl,
craftShotSourceUrl,
craftShotUrl,
formatList,
type CraftApp,
type CraftDownload,
} from "@/lib/crafting-apps";
import AppCard from "./app-card";
import { AppDownloadButtons } 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 download buttons.
const DOWNLOAD_GROUPS = [...CRAFT_DESKTOP_PLATFORMS, "Other"] as const;
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 +52,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 +80,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 +87,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")}
@@ -77,12 +106,14 @@ 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.
// build-from-source. Versions and files come from crafting-app-releases.ts;
// a null release there flips the left cell to the waitlist.
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) : [];
return (
<SectionShell id="get-it">
@@ -94,18 +125,27 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
<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 ? (
<>
<h3 className={CELL_HEADING_CLASSES}>
Download <ColorAccent>{name}</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">
Version {release.version}, free. Questions or feedback?
The team is in the ArtCraft Discord.
</p>
<AppDownloadButtons
recommended={downloads.filter((download) => download.recommended)}
version={release.version}
/>
<AllDownloads downloads={downloads} />
<div className="mt-5 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>
</>
) : (
@@ -180,6 +220,67 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
}
// The other apps, compact, linking back to the hub.
// Every file in the release, grouped by platform, behind a disclosure.
function AllDownloads({ downloads }: { downloads: CraftDownload[] }) {
return (
<details className="group/all mt-6 border border-line">
<summary className="hud-label flex cursor-pointer list-none items-center justify-between gap-2 px-4 py-3 text-muted hover:text-ink [&::-webkit-details-marker]:hidden">
All downloads ({downloads.length} files)
<ChevronDownIcon
aria-hidden
className="h-3.5 w-3.5 transition-transform group-open/all:rotate-180"
/>
</summary>
{DOWNLOAD_GROUPS.map((group) => {
const files = downloads.filter((download) => download.group === group);
if (files.length === 0) return null;
return (
<div key={group} className="border-t border-line">
<p className="hud-label bg-bg-sunken px-4 py-2 text-faint">{group}</p>
<ul>
{files.map((download) => (
<li key={download.href} className="border-t border-line">
<a
href={download.href}
className="group/file flex items-center justify-between gap-4 px-4 py-3 hover:bg-bg-sunken"
>
<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>
</li>
))}
</ul>
</div>
);
})}
</details>
);
}
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>
);
}
export function AppFamily({ app, index }: { app: CraftApp; index: string }) {
const siblings = CRAFTING_APPS.filter((other) => other.slug !== app.slug);
return (
@@ -202,7 +303,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">
@@ -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
@@ -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;
}
@@ -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.0";
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: LATEST,
vectorcraft: LATEST,
filmcraft: LATEST,
lightcraft: LATEST,
printcraft: LATEST,
effectcraft: LATEST,
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