Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 513d2bc118 Don't let shared caches store release info before every app is fetched
A cold instance answers with apps that have no release and no
app_last_fetched. With public, max-age=60, Cloudflare could keep serving
that for a minute after the instance warmed up, sending launchers to
GitHub directly. Until last_fetched is set the answer is now no-store.
The body is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:57:09 -04:00
Brandon ThomasandClaude Opus 5.5 ca7c9f56bd Give the release poller its own Redis pool with I/O timeouts
The poller shared the request handlers' Redis pool, whose connections
have no read timeout. If Redis stopped answering, each timed-out poller
call left a blocking thread holding a connection, and within minutes the
poller could hold the whole pool while handlers waited 30 s for one.

The poller now has a pool of two connections that connect within 3 s and
time out every read and write (the pool's PING check included) after 5 s.
It is built without connecting, so a Redis outage at startup doesn't stop
the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:57:09 -04:00
Brandon ThomasandClaude Opus 5.5 2ee18084b7 Warm the release cache from Redis at startup
A new instance now reads the fleet's shared releases before its startup
jitter, so it serves them immediately at no GitHub quota cost. The jitter
still spreads out requests to GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:49:59 -04:00
Brandon ThomasandClaude Opus 5.5 097e746cda Fall back to WATCH/MULTI/EXEC when Redis refuses scripts
Some Redis setups refuse EVAL (scripting disabled, ACLs, proxies). The
first refusal switches the shared store to applying the same guarded-write
rule itself: WATCH the hash, read and decode the guarding fields, decide,
and write in a transaction that Redis drops if another instance wrote in
between. Unreachable, busy, OOM and read-only errors are not refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:49:59 -04:00
Brandon ThomasandClaude Opus 5.5 0c441d37c0 Coordinate Craft App release polling across the fleet through Redis
Every storyteller-web pod polled GitHub on its own every 30 minutes, so
new releases took up to ~33 min to appear, pods disagreed with each
other, and without a token the fleet burned GitHub's 60/hour/IP quota
(unauthenticated 304s count too). Pods now share the work in Redis.

Shared state (keys in redis_schema::keys::craft_apps):
- craftApps:releases:app:<app key>: hash per app (v=1, fetched_ms, etag,
release JSON, failed_ms, failures, claimed_ms, by); 7 day TTL refreshed
on every write
- craftApps:releases:backoff: fleet-wide backoff hash (v=1, until_ms,
trouble, by); 1 day TTL, deleted after a good round

Loop (every pod, every 30 s ±10%, after the existing startup jitter):
- Read every app entry plus the backoff in one pipeline; take anything
newer into the in-memory cache, which the endpoint keeps serving
- Stop if the fleet is backing off
- For each app whose shared fetch is older than interval - 30 s (clock
skew margin), and whose failure retry (1, 2, 4… min up to the interval)
has passed: re-read its entry, claim it (claimed_ms, 60 s window, so
pods ticking together don't all ask), then ask GitHub with the shared
ETag and write back
- Writes go through a Lua compare-and-set on the timestamp field, so a
slow pod never replaces a newer fetch; a 304 moves only fetched_ms and
only if the stored ETag is the one revalidated
- Rate limits and all-failed rounds set the shared backoff with the
existing next_wait rules, so backoff is cluster-wide
- Redis errors (3 s connection / 10 s call timeouts, run off the async
runtime with spawn_blocking) fall back to polling alone from memory,
with a warning at most every 10 minutes, and return to coordination
when Redis answers

Config: the default interval depends on the token: 120 s (minimum 60 s)
with CRAFT_APPS_RELEASE_GITHUB_TOKEN, 1800 s (minimum 300 s) without.
The token stays optional: unset, empty or blank means unauthenticated
requests and never an empty Authorization header.
CRAFT_APPS_RELEASE_COORDINATION_ENABLED=false (default true) switches
back to standalone polling.

GET /v1/craft_apps/release_info is unchanged.

Tests: freshness with skew, failure retries, shared backoff, encoding
round trips and garbage tolerance, cache merges, Redis fallback, token
and no-token request building; ignored tests against a real Redis for
the guarded writes, claims and cross-pod refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:12:44 -04:00
65 changed files with 81 additions and 1208 deletions

No files matched your search

+5 -5
View File
@@ -43,7 +43,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
# Free up disk on the runner before BuildKit fills it up. The cargo
# release build inside the Dockerfile produces multi-GB target dirs
@@ -60,10 +60,10 @@ jobs:
# BuildKit is required for the `cache-from`/`cache-to` options on
# docker/build-push-action.
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/setup-buildx-action@v3
name: Set up Docker Buildx
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@v3
name: Login to GitHub Container Registry
with:
registry: ghcr.io
@@ -80,7 +80,7 @@ jobs:
- id: meta
name: Compute Docker tags
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@v5
with:
images: ghcr.io/storytold/artcraft-services
tags: |
@@ -89,7 +89,7 @@ jobs:
type=raw,value=${{ steps.vars.outputs.sha_short }}
- name: Build and push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@v6
with:
context: .
file: ./build/service_cpu.Dockerfile
-59
View File
@@ -1,59 +0,0 @@
# Copy this file to `.env` and change the values.
# `.env` is ignored by git; never commit real keys.
# ----- Local infrastructure (used by docker-compose.yml) -----
MYSQL_ROOT_PASSWORD=change-me-root
MYSQL_PASSWORD=change-me
# Credentials for the local S3-compatible storage (SeaweedFS).
S3_ACCESS_KEY=artcraft
S3_SECRET_KEY=change-me-please
# ----- storyteller-web settings -----
# Cookies are served over plain HTTP locally.
COOKIE_SECURE=false
COOKIE_DOMAIN=localhost
COOKIE_SECRET=change-me
SORT_KEY_SECRET=change-me
WEBSITE_HOMEPAGE_REDIRECT=http://localhost:4201
ENABLE_PAGING=false
NUM_WORKERS=4
# ----- Third-party providers -----
# storyteller-web requires these to be set at startup. Placeholders let the server
# start; features backed by a provider only work once you add a real key.
FAL_API_KEY=unset
FAL_WEBHOOK_URL=http://localhost:12345/v1/webhooks/fal
GMICLOUD_API_KEY=unset
GROK_API_KEY=unset
OPENAI_API_KEY=unset
WORLDLABS_API_KEY=unset
BEEBLE_API_KEY=unset
BEEBLE_WEBHOOK_URL=http://localhost:12345/v1/webhooks/beeble
SEEDANCE2PRO_COOKIES=unset
SEEDANCE2PRO_WHITELIST_COOKIES=unset
SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES=unset
# Email (Resend)
RESEND_API_KEY=unset
# ----- Stripe (billing) -----
STRIPE_SECRET_KEY=unset
STRIPE_SECRET_WEBHOOK_SIGNING_KEY=unset
STRIPE_FAKEYOU_ACCOUNT_ID=unset
STRIPE_PORTAL_DEFAULT_CONFIG_ID=unset
STRIPE_CHECKOUT_SUCCESS_URL_PATH=/checkout_success
STRIPE_CHECKOUT_CANCEL_URL_PATH=/checkout_cancel
STRIPE_PORTAL_RETURN_URL_PATH=/portal_success
STRIPE_ARTCRAFT_ACCOUNT_ID=unset
STRIPE_ARTCRAFT_SECRET_KEY=unset
STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY=unset
STRIPE_ARTCRAFT_CHECKOUT_SUCCESS_URL=http://localhost:4201/checkout_success
STRIPE_ARTCRAFT_CHECKOUT_CANCEL_URL=http://localhost:4201/checkout_cancel
STRIPE_ARTCRAFT_PORTAL_RETURN_URL=http://localhost:4201/portal_closed
-77
View File
@@ -1,77 +0,0 @@
# Self-hosting with Docker Compose
This directory runs the ArtCraft backend (`storyteller-web`) and the services it
depends on with Docker Compose.
| Service | Image | Purpose |
|-------------------|--------------------------|---------------------------------------------|
| `storyteller-web` | built from this repo | HTTP API, on `http://localhost:12345` |
| `mysql` | `mysql:8.4` | Accounts, media metadata, jobs, billing |
| `migrate` | `mysql:8.4` | Applies `_database/sql/migrations`, exits |
| `redis` | `redis:7` | Caching, rate limiting, job progress |
| `elasticsearch` | `elasticsearch:8.15.3` | Search |
| `seaweedfs` | `chrislusf/seaweedfs` | S3-compatible storage for media |
| `storage-init` | `chrislusf/seaweedfs` | Creates the storage buckets, exits |
Only the API port (`12345`) is published to the host.
## Requirements
- Docker with Docker Compose v2.23 or newer.
- About 8 GB of memory for Docker and 20 GB of free disk. The first build
compiles the Rust workspace in release mode and can take a long time.
## Usage
From this directory:
```bash
cp .env.example .env # then edit the passwords and secrets
docker compose up --build -d
```
Check that the server is up:
```bash
curl http://localhost:12345/_status
```
Stop everything with `docker compose down`. Add `-v` to also delete the
database, search index and stored media.
## Configuration
Settings live in `.env`, which git ignores. `docker-compose.yml` sets the
connection settings for MySQL, Redis, Elasticsearch and storage, so you only
need to change passwords and secrets.
`storyteller-web` requires credentials for every third-party provider (FAL,
OpenAI, Stripe, Resend and others) to be present at startup. `.env.example`
sets them to the placeholder `unset`, which lets the server start. Features
that call a provider only work after you add a real key for it.
Provider webhooks, such as `FAL_WEBHOOK_URL`, must be reachable from the
internet for asynchronous generation results to arrive.
## Database migrations
The `migrate` service applies every migration in `_database/sql/migrations` in
order and records each one in `__diesel_schema_migrations`, the table the diesel
CLI uses. It runs on every `docker compose up` and only applies migrations that
have not run yet.
## Connecting the web app
To point the browser app at this server, run it from `frontend` as described in
the repository README:
```bash
VITE_USE_LOCAL_API=true npx nx dev artcraft-webapp
```
## Limitations
- Only the HTTP API is included. Background workers (provider polling, video
thumbnails, email, analytics) are not started.
- Elasticsearch starts with no indexes. Index definitions are in
`_database/elasticsearch/index_definitions`.
-163
View File
@@ -1,163 +0,0 @@
# Self-hosted ArtCraft backend (storyteller-web) with its dependencies.
#
# Usage (from this directory):
# cp .env.example .env
# docker compose up --build
#
# See README.md in this directory for details.
name: artcraft-selfhost
services:
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: storyteller
MYSQL_USER: storyteller
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
# NB: Use TCP (127.0.0.1), not the socket, so the check fails while the image's
# temporary init server (which has networking disabled) is still running.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD}", "--silent"]
interval: 5s
timeout: 5s
retries: 30
# Applies the SQL migrations in `_database/sql/migrations` in order.
# Applied versions are recorded in `__diesel_schema_migrations` (the same table
# the diesel CLI uses), so re-running is safe and only applies new migrations.
migrate:
image: mysql:8.4
depends_on:
mysql:
condition: service_healthy
environment:
MYSQL_PWD: ${MYSQL_ROOT_PASSWORD}
volumes:
- ../../_database/sql/migrations:/migrations:ro
entrypoint: ["bash", "-c"]
command:
- |
set -euo pipefail
run_sql() { mysql -h mysql -u root storyteller "$$@"; }
run_sql -e "CREATE TABLE IF NOT EXISTS __diesel_schema_migrations (
version VARCHAR(50) PRIMARY KEY NOT NULL,
run_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)"
for dir in /migrations/*/; do
name=$$(basename "$$dir")
version=$$(echo "$${name%%_*}" | tr -d '-')
if [ -n "$$(run_sql -N -e "SELECT 1 FROM __diesel_schema_migrations WHERE version = '$$version'")" ]; then
continue
fi
echo "Applying migration $$name"
run_sql < "$$dir/up.sql"
run_sql -e "INSERT INTO __diesel_schema_migrations (version) VALUES ('$$version')"
done
echo "Migrations complete."
redis:
image: redis:7
restart: unless-stopped
volumes:
- redis-data:/data
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.15.3
restart: unless-stopped
environment:
discovery.type: single-node
xpack.security.enabled: "false"
ES_JAVA_OPTS: -Xms512m -Xmx512m
volumes:
- elasticsearch-data:/usr/share/elasticsearch/data
healthcheck:
test: ["CMD-SHELL", "curl -fs http://localhost:9200/_cluster/health || exit 1"]
interval: 10s
timeout: 5s
retries: 30
# S3-compatible object storage (SeaweedFS) for uploaded and generated media.
seaweedfs:
image: chrislusf/seaweedfs:3.97
restart: unless-stopped
command: server -dir=/data -s3 -s3.port=8333 -s3.config=/etc/seaweedfs/s3.json
configs:
- source: seaweedfs-s3
target: /etc/seaweedfs/s3.json
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8333/healthz || exit 1"]
interval: 5s
timeout: 5s
retries: 30
# Creates the buckets storyteller-web expects.
storage-init:
image: chrislusf/seaweedfs:3.97
depends_on:
seaweedfs:
condition: service_healthy
entrypoint: ["sh", "-c"]
command:
- |
set -e
for bucket in artcraft-private artcraft-public artcraft-public-gc; do
echo "s3.bucket.create -name $$bucket" | weed shell -master=seaweedfs:9333
done
storyteller-web:
build:
context: ../..
dockerfile: build/service_cpu.Dockerfile
image: artcraft-services:local
restart: unless-stopped
depends_on:
migrate:
condition: service_completed_successfully
storage-init:
condition: service_completed_successfully
redis:
condition: service_started
elasticsearch:
condition: service_healthy
env_file: .env
environment:
SERVER_ENVIRONMENT: development
BIND_ADDRESS: 0.0.0.0:12345
MYSQL_URL: mysql://storyteller:${MYSQL_PASSWORD}@mysql:3306/storyteller
REDIS_0_URL: redis://redis:6379/0
ELASTICSEARCH_URL: http://elasticsearch:9200
S3_COMPATIBLE_ENDPOINT_URL: http://seaweedfs:8333
ACCESS_KEY: ${S3_ACCESS_KEY}
SECRET_KEY: ${S3_SECRET_KEY}
REGION_NAME: us-east-1
W2L_PRIVATE_DOWNLOAD_BUCKET_NAME: artcraft-private
W2L_PUBLIC_DOWNLOAD_BUCKET_NAME: artcraft-public
GC_ENABLED_PUBLIC_BUCKET_NAME: artcraft-public-gc
AUDIO_UPLOADS_BUCKET_ROOT: /user_uploaded_audio
ports:
- "12345:12345"
configs:
seaweedfs-s3:
content: |
{
"identities": [
{
"name": "artcraft",
"credentials": [{ "accessKey": "${S3_ACCESS_KEY}", "secretKey": "${S3_SECRET_KEY}" }],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
]
}
volumes:
mysql-data:
redis-data:
elasticsearch-data:
seaweedfs-data:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import {
AppDemo,
AppFamily,
AppGallery,
AppGetIt,
@@ -12,7 +11,6 @@ import {
LauncherDownloadButton,
} from "@/components/apps/app-downloads";
import { craftLauncherRecommended } from "@/components/apps/launcher-callout";
import { craftLauncherDownloads } from "@/lib/craft-launcher";
import {
AppIcon,
AppTab,
@@ -85,16 +83,11 @@ export default async function CraftAppPage({ params }: { params: Params }) {
const release = craftAppRelease(app);
// The hero's download is ArtCraft Launcher, which installs this app and keeps
// it updated; the app's own installers are in #get-it, under the launcher.
// An app with no release yet has nothing for the launcher to install.
const launcherDownloads = release ? craftLauncherRecommended() : [];
const allLauncherDownloads = craftLauncherDownloads();
const hasDownload = release !== null;
const launcherDownloads = craftLauncherRecommended();
const hasDownload = launcherDownloads.length > 0 || release !== null;
const [hero] = app.shots;
const [before, accent, after] = app.headline;
const shareUrl = siteUrl(craftAppPath(app));
// A demo takes section 02 and pushes the rest down one.
const sectionIndex = (n: number) =>
String(n + (app.demo ? 1 : 0)).padStart(2, "0");
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
@@ -160,11 +153,7 @@ export default async function CraftAppPage({ params }: { params: Params }) {
<div className="flex flex-col items-center gap-6">
<div className="flex flex-wrap items-start justify-center gap-3">
{launcherDownloads.length > 0 ? (
<LauncherDownloadButton
recommended={launcherDownloads}
allDownloads={allLauncherDownloads}
fallbackHref="#get-it"
/>
<LauncherDownloadButton recommended={launcherDownloads} fallbackHref="#get-it" />
) : (
release && (
<AppHeroDownloadButton
@@ -200,11 +189,9 @@ export default async function CraftAppPage({ params }: { params: Params }) {
</div>
</CampaignHero>
{app.demo && <AppDemo app={app} demo={app.demo} index="02" />}
<CampaignSection
id="highlights"
index={sectionIndex(2)}
index="02"
label="Highlights"
annotation={app.category}
title={
@@ -216,9 +203,9 @@ export default async function CraftAppPage({ params }: { params: Params }) {
<TipGrid items={app.features} columns={3} />
</CampaignSection>
<AppGallery app={app} index={sectionIndex(3)} />
<AppGetIt app={app} index={sectionIndex(4)} />
<AppFamily app={app} index={sectionIndex(5)} />
<AppGallery app={app} index="03" />
<AppGetIt app={app} index="04" />
<AppFamily app={app} index="05" />
<ClosingCta
eyebrow="Open source · Free"
@@ -28,7 +28,6 @@ import { trackAttrs } from "@/lib/analytics";
import {
CRAFT_LAUNCHER_NAME,
CRAFT_LAUNCHER_REPO,
craftLauncherDownloads,
craftLauncherReleasePageUrl,
} from "@/lib/craft-launcher";
import {
@@ -45,7 +44,7 @@ import { siteUrl } from "@/lib/links";
const TITLE = "Crafting Apps: open-source creative tools";
const COVERAGE =
"Image editing, vector illustration, video, photography, PDFs, motion graphics, page layout, interface design, beat making, word processing, audio, presentations, CAD and spreadsheets";
"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 = {
@@ -74,7 +73,6 @@ const PRINCIPLE_ICONS = [
export default function CraftingAppsPage() {
const launcherDownloads = craftLauncherRecommended();
const allLauncherDownloads = craftLauncherDownloads();
return (
<>
<RevealManager />
@@ -86,46 +84,15 @@ export default function CraftingAppsPage() {
annotation={`${CRAFTING_APPS_COUNT_WORD} apps · Open source · Pure Rust`}
title={
<>
{CRAFTING_APPS_COUNT_WORD} apps.{" "}
<span className="whitespace-nowrap">
One <ColorAccent>craft</ColorAccent>.
</span>
{CRAFTING_APPS_COUNT_WORD} apps. One <ColorAccent>craft</ColorAccent>.
</>
}
lede={`${COVERAGE}. Native, open-source apps from the ArtCraft team, built in Rust and free to use.`}
aside={
<nav
aria-label="Crafting Apps"
data-reveal
// Under the text: two even rows from sm up (7 + 6). Beside it from
// xl up: centered rows of five.
className="mt-12 grid w-fit grid-cols-4 gap-x-3 gap-y-6 sm:grid-cols-7 sm:gap-x-5 xl:mt-0 xl:flex xl:w-120 xl:shrink-0 xl:flex-wrap xl:justify-center"
>
{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>
}
>
<div data-reveal className="mt-8 flex flex-wrap items-start gap-3">
{launcherDownloads.length > 0 && (
<LauncherDownloadButton
recommended={launcherDownloads}
allDownloads={allLauncherDownloads}
fallbackHref={craftLauncherReleasePageUrl()}
/>
)}
@@ -166,6 +133,29 @@ export default function CraftingAppsPage() {
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">
@@ -324,13 +324,6 @@ html[data-ruler="on"]::-webkit-scrollbar {
.app-pdfcraft { --app: #12a58a; --app-ink: #0a7563; }
.app-effectcraft { --app: #e0368f; --app-ink: #b0206c; }
.app-designcraft { --app: #7bb51c; --app-ink: #457008; }
.app-figcraft { --app: #02da96; --app-ink: #017a54; }
.app-loopcraft { --app: #f0d824; --app-ink: #6f6300; }
.app-wordcraft { --app: #4c6ef5; --app-ink: #253e99; }
.app-soundcraft { --app: #14a9c4; --app-ink: #0b6f82; }
.app-deckcraft { --app: #f26b1d; --app-ink: #ad4306; }
.app-cadcraft { --app: #14a3c7; --app-ink: #09596d; }
.app-gridcraft { --app: #1f9d55; --app-ink: #136938; }
:root[data-theme="dark"] .app-photocraft { --app-ink: #7fb0ff; }
:root[data-theme="dark"] .app-vectorcraft { --app-ink: #ff8a70; }
:root[data-theme="dark"] .app-filmcraft { --app-ink: #b69cff; }
@@ -338,13 +331,6 @@ html[data-ruler="on"]::-webkit-scrollbar {
:root[data-theme="dark"] .app-pdfcraft { --app-ink: #3fd6b8; }
:root[data-theme="dark"] .app-effectcraft { --app-ink: #ff7ab8; }
:root[data-theme="dark"] .app-designcraft { --app-ink: #a6e04a; }
:root[data-theme="dark"] .app-figcraft { --app-ink: #3fe6ae; }
:root[data-theme="dark"] .app-loopcraft { --app-ink: #f5e04f; }
:root[data-theme="dark"] .app-wordcraft { --app-ink: #8da2fb; }
:root[data-theme="dark"] .app-soundcraft { --app-ink: #52d1ea; }
:root[data-theme="dark"] .app-deckcraft { --app-ink: #ff9657; }
:root[data-theme="dark"] .app-cadcraft { --app-ink: #4cc7e6; }
:root[data-theme="dark"] .app-gridcraft { --app-ink: #5ee089; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .app-photocraft { --app-ink: #7fb0ff; }
:root:not([data-theme="light"]) .app-vectorcraft { --app-ink: #ff8a70; }
@@ -353,13 +339,6 @@ html[data-ruler="on"]::-webkit-scrollbar {
:root:not([data-theme="light"]) .app-pdfcraft { --app-ink: #3fd6b8; }
:root:not([data-theme="light"]) .app-effectcraft { --app-ink: #ff7ab8; }
:root:not([data-theme="light"]) .app-designcraft { --app-ink: #a6e04a; }
:root:not([data-theme="light"]) .app-figcraft { --app-ink: #3fe6ae; }
:root:not([data-theme="light"]) .app-loopcraft { --app-ink: #f5e04f; }
:root:not([data-theme="light"]) .app-wordcraft { --app-ink: #8da2fb; }
:root:not([data-theme="light"]) .app-soundcraft { --app-ink: #52d1ea; }
:root:not([data-theme="light"]) .app-deckcraft { --app-ink: #ff9657; }
:root:not([data-theme="light"]) .app-cadcraft { --app-ink: #4cc7e6; }
:root:not([data-theme="light"]) .app-gridcraft { --app-ink: #5ee089; }
}
/* Plan cell: a color wash pooling under the index tab and fading into the
@@ -1,17 +1,15 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import {
AppleIcon,
ArrowDownIcon,
ArrowDownToLineIcon,
ChevronDownIcon,
SquareTerminalIcon,
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { WindowsIcon } from "@/components/icons";
import { Button, type ButtonProps } from "@/components/ui";
import { craftLauncherDownloads } from "@/lib/craft-launcher";
import {
CRAFT_DESKTOP_PLATFORMS,
type CraftDesktopPlatform,
@@ -163,14 +161,9 @@ export function AppHeroDownloadButton({
* ArtCraft Launcher's CTA: a direct download for the visitor's system once
* it is known (with the file's platform and build underneath), else a jump
* to `fallbackHref`, where every platform is listed.
*
* On Linux, offers Flatpak as the default button action, plus a dropdown
* arrow on the right to select and download other formats: Flatpak first,
* then AppImage, then deb, then rpm.
*/
export function LauncherDownloadButton({
recommended,
allDownloads,
fallbackHref,
size = "lg",
variant = "primary",
@@ -178,7 +171,6 @@ export function LauncherDownloadButton({
className,
}: {
recommended: CraftDownload[];
allDownloads?: CraftDownload[];
fallbackHref: string;
size?: ButtonProps["size"];
variant?: ButtonProps["variant"];
@@ -187,158 +179,6 @@ export function LauncherDownloadButton({
}) {
const detected = useDetectedDesktop();
const mine = detected && pickDownload(recommended, detected);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const [selectedDownload, setSelectedDownload] = useState<CraftDownload | null>(null);
useEffect(() => {
if (!menuOpen) return;
const onPointerDown = (e: PointerEvent) => {
if (!menuRef.current?.contains(e.target as Node)) {
setMenuOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setMenuOpen(false);
}
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [menuOpen]);
const isLinux = detected?.platform === "Linux";
const downloads = allDownloads ?? (isLinux ? craftLauncherDownloads() : []);
const linuxDownloads = isLinux
? downloads.filter(
(download) =>
download.group === "Linux" &&
(!detected.arch || !download.arch || download.arch === detected.arch),
)
: [];
const linuxOptions = isLinux
? [...linuxDownloads].sort(
(a, b) => linuxFormatPriority(a.fileName) - linuxFormatPriority(b.fileName),
)
: [];
const defaultLinuxDownload =
linuxOptions.find((d) => d.fileName.endsWith(".flatpak")) ?? mine;
const activeDownload = selectedDownload ?? defaultLinuxDownload ?? mine;
if (isLinux && linuxOptions.length > 0 && activeDownload) {
const isPrimary = variant === "primary";
return (
<span className={twMerge("flex flex-col gap-2", className)}>
<div
ref={menuRef}
className={twMerge("relative inline-flex items-stretch", fullWidth ? "w-full" : "w-fit")}
>
<Button
href={activeDownload.href}
size={size}
variant={variant}
className={twMerge(
fullWidth ? "flex-1 w-full" : undefined,
"pr-4",
)}
>
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
Download Launcher
</Button>
<button
type="button"
onClick={() => setMenuOpen((prev) => !prev)}
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label="Other Linux download formats"
className={twMerge(
"flex items-center justify-center transition-colors duration-150 cursor-pointer",
size === "lg" ? "h-12 px-3.5" : size === "sm" ? "px-2 py-1.5" : "px-3 py-2",
isPrimary
? "bg-invert-bg text-invert-fg border-l border-invert-fg/20 hover:opacity-80"
: "border border-line-strong border-l-0 bg-bg text-ink hover:bg-invert-bg hover:text-invert-fg",
)}
>
<ChevronDownIcon
aria-hidden
className={twMerge(
"h-4 w-4 transition-transform duration-150",
menuOpen && "rotate-180",
)}
/>
</button>
{menuOpen && (
<div
role="menu"
className="absolute top-full left-0 z-50 mt-1 w-full min-w-[280px] border border-line bg-bg-raised p-1 shadow-lg text-left"
>
<div className="border-b border-line px-3 py-2">
<p className="hud-label text-[11px] text-faint">Select Linux format</p>
</div>
<ul className="flex flex-col">
{linuxOptions.map((opt) => {
const isDefault = opt.fileName.endsWith(".flatpak");
const isSelected = activeDownload.fileName === opt.fileName;
return (
<li key={opt.href}>
<a
href={opt.href}
download={opt.fileName}
role="menuitem"
onClick={() => {
setSelectedDownload(opt);
setMenuOpen(false);
}}
className={twMerge(
"group/opt flex items-center justify-between gap-3 p-2.5 text-left transition-colors duration-150 hover:bg-bg-sunken",
isSelected && "bg-ink/5",
)}
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="block text-sm font-medium text-ink">
{opt.label}
</span>
{isDefault && (
<span className="hud-label text-[10px] text-(--app-ink,var(--accent-ink))">
Default
</span>
)}
</span>
<span className="block text-xs text-muted">
{opt.description}
</span>
<span className="mt-0.5 block truncate font-mono text-[10px] text-faint">
{opt.fileName}
</span>
</span>
<ArrowDownToLineIcon
aria-hidden
className="h-4 w-4 shrink-0 text-muted group-hover/opt:text-ink"
/>
</a>
</li>
);
})}
</ul>
</div>
)}
</div>
<span aria-hidden={!activeDownload} className="hud-label min-h-4 w-0 min-w-full whitespace-nowrap text-faint">
{activeDownload ? `For Linux · ${activeDownload.label}` : "\u00a0"}
</span>
</span>
);
}
return (
<span className={twMerge("flex flex-col gap-2", className)}>
<Button
@@ -360,15 +200,6 @@ export function LauncherDownloadButton({
);
}
function linuxFormatPriority(fileName: string): number {
if (fileName.endsWith(".flatpak")) return 1;
if (fileName.endsWith(".AppImage")) return 2;
if (fileName.endsWith(".deb")) return 3;
if (fileName.endsWith(".rpm")) return 4;
if (fileName.endsWith(".tar.gz")) return 5;
return 6;
}
/**
* Shown under the hero once the direct download is live, so visitors on
* another machine, CPU or package format can still find their build.
@@ -2,11 +2,9 @@ import Link from "next/link";
import type { ReactNode } from "react";
import { ArrowUpRightIcon, TerminalIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { CampaignSection } from "@/components/campaign/sections";
import DiscordButton from "@/components/discord-button";
import { GitHubIcon } from "@/components/icons";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import LazyVideo from "@/components/lazy-video";
import { Badge, Button, CopyButton } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import { CRAFT_LAUNCHER_RELEASE } from "@/lib/crafting-app-releases";
@@ -17,7 +15,6 @@ import {
craftAppName,
craftAppRelease,
craftAppRepo,
craftDemoPosterUrl,
craftReleaseDownloads,
craftReleasePageUrl,
craftReleasesUrl,
@@ -25,7 +22,6 @@ import {
craftShotUrl,
formatList,
type CraftApp,
type CraftDemo,
} from "@/lib/crafting-apps";
import AppCard from "./app-card";
import { AppPlatformDownloads, DownloadRow } from "./app-downloads";
@@ -38,50 +34,6 @@ import { LauncherCallout } from "./launcher-callout";
const CELL_HEADING_CLASSES =
"mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl";
// A feature clip (apps with a `demo`), directly under the hero.
export function AppDemo({
app,
demo,
index,
}: {
app: CraftApp;
demo: CraftDemo;
index: string;
}) {
const [before, accent, after] = demo.headline;
return (
<CampaignSection
id="demo"
index={index}
label={demo.label}
annotation={craftAppName(app)}
title={
<>
{before}
<ColorAccent>{accent}</ColorAccent>
{after}
</>
}
lede={demo.lede}
>
<figure data-reveal className="border-t border-line">
<div className="relative aspect-video w-full overflow-hidden bg-bg-sunken">
<LazyVideo
src={demo.src}
poster={craftDemoPosterUrl(app, demo)}
label={demo.caption}
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
<figcaption className="flex items-center justify-between gap-4 border-t border-line px-6 py-3 md:px-10">
<span className="hud-label text-muted">{demo.caption}</span>
<span className="hud-label text-faint">Video</span>
</figcaption>
</figure>
</CampaignSection>
);
}
// The hero carries shot 01. Window captures pair up, and an odd one out
// leads at full width; portrait pages (layout spreads) sit four across.
export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
@@ -114,11 +66,7 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
rel="noopener noreferrer"
className={twMerge(
"group relative block overflow-hidden bg-bg-sunken",
shot.portrait
? "aspect-[7/9]"
: shot.wide
? "aspect-video"
: "aspect-[16/10]",
shot.portrait ? "aspect-[7/9]" : "aspect-[16/10]",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -65,7 +65,6 @@ export function LauncherCallout({ app }: { app: CraftApp }) {
</p>
<LauncherDownloadButton
recommended={recommended}
allDownloads={craftLauncherDownloads()}
fallbackHref={craftLauncherReleasePageUrl()}
fullWidth
className="mt-4"
@@ -7,12 +7,10 @@ import { useEffect, useRef } from "react";
// ambient motion.
export default function LazyVideo({
src,
poster,
className,
label,
}: {
src: string;
poster?: string;
className?: string;
label?: string;
}) {
@@ -47,7 +45,6 @@ export default function LazyVideo({
<video
ref={ref}
src={src}
poster={poster}
className={className}
muted
loop
@@ -16,7 +16,6 @@ export function PageHeader({
title,
lede,
children,
aside,
align = "left",
className,
}: {
@@ -29,31 +28,10 @@ export function PageHeader({
lede?: ReactNode;
/** Extra content under the lede (CTAs, back links, …). */
children?: ReactNode;
/** Beside the text from xl up (vertically centered), beneath it below. */
aside?: ReactNode;
align?: "left" | "center";
className?: string;
}) {
const centered = align === "center";
const text = (
<>
<h1
data-reveal
className="max-w-3xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl"
>
{title}
</h1>
{lede && (
<p
data-reveal
className="mt-5 max-w-xl text-lg leading-relaxed text-muted"
>
{lede}
</p>
)}
{children}
</>
);
return (
<SectionShell id={id} className={className}>
<SectionEyebrow index={index} label={label} annotation={annotation} />
@@ -61,11 +39,23 @@ export function PageHeader({
className={twMerge(
"px-6 py-14 md:px-10 md:py-20",
centered && "flex flex-col items-center text-center",
aside && "xl:flex xl:items-center xl:justify-between xl:gap-12",
)}
>
{aside ? <div className="min-w-0">{text}</div> : text}
{aside}
<h1
data-reveal
className="max-w-3xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl"
>
{title}
</h1>
{lede && (
<p
data-reveal
className="mt-5 max-w-xl text-lg leading-relaxed text-muted"
>
{lede}
</p>
)}
{children}
</div>
</SectionShell>
);
@@ -105,20 +105,20 @@ export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [
description: "Unzip and run on Windows on ARM.",
arch: "aarch64",
},
{
file: "{slug}-{version}-linux-x86_64.flatpak",
group: "Linux",
label: "Flatpak (x86_64)",
description: "Sandboxed. Install with flatpak install --user.",
arch: "x86_64",
recommended: true,
},
{
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.flatpak",
group: "Linux",
label: "Flatpak (x86_64)",
description: "Sandboxed. Install with flatpak install --user.",
arch: "x86_64",
},
{
file: "{slug}-{version}-linux-x86_64.deb",
@@ -141,20 +141,20 @@ export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [
description: "Plain binaries to unpack anywhere.",
arch: "x86_64",
},
{
file: "{slug}-{version}-linux-aarch64.flatpak",
group: "Linux",
label: "Flatpak (ARM64)",
description: "Sandboxed, on 64-bit ARM.",
arch: "aarch64",
recommended: true,
},
{
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.flatpak",
group: "Linux",
label: "Flatpak (ARM64)",
description: "Sandboxed, on 64-bit ARM.",
arch: "aarch64",
},
{
file: "{slug}-{version}-linux-aarch64.deb",
@@ -208,24 +208,17 @@ export function craftRelease(
// every app above, and the /apps pages recommend it first. Its releases ship
// the same file set minus the macOS CLI, named artcraft-launcher-*.
export const CRAFT_LAUNCHER_RELEASE: CraftRelease | null = craftRelease(
"0.3.2",
"0.2.0",
CRAFT_RELEASE_ASSETS.filter((asset) => !asset.file.startsWith("{slug}-cli-")),
);
// 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.7.0"),
vectorcraft: craftRelease("0.9.0"),
filmcraft: craftRelease("0.6.0"),
lightcraft: craftRelease("0.6.0"),
pdfcraft: craftRelease("0.6.0"),
effectcraft: craftRelease("0.8.0"),
designcraft: craftRelease("0.6.0"),
figcraft: craftRelease("0.5.1"),
loopcraft: null,
wordcraft: craftRelease("0.5.0"),
soundcraft: craftRelease("0.5.0"),
deckcraft: craftRelease("0.5.0"),
cadcraft: craftRelease("0.5.0"),
gridcraft: craftRelease("0.5.0"),
photocraft: craftRelease("0.5.0"),
vectorcraft: craftRelease("0.7.0"),
filmcraft: craftRelease("0.4.0"),
lightcraft: craftRelease("0.4.0"),
pdfcraft: craftRelease("0.4.0"),
effectcraft: craftRelease("0.6.0"),
designcraft: craftRelease("0.4.0"),
};
@@ -18,8 +18,7 @@ import { mediaUrl } from "./links";
// 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), the shots and,
// for an app with a demo, its poster frame.
// hicolor render at 256 px), og.jpg (1200×630 share card) and the shots.
export type CraftAppSlug =
| "photocraft"
@@ -28,14 +27,7 @@ export type CraftAppSlug =
| "lightcraft"
| "pdfcraft"
| "effectcraft"
| "designcraft"
| "figcraft"
| "loopcraft"
| "wordcraft"
| "soundcraft"
| "deckcraft"
| "cadcraft"
| "gridcraft";
| "designcraft";
export type CraftPlatform = "macOS" | "Windows" | "Linux" | "Web";
@@ -48,21 +40,6 @@ export type CraftShot = {
alt: string;
/** Portrait page (7:9) rather than a 16:10 window capture. */
portrait?: boolean;
/** 16:9 window capture rather than 16:10. */
wide?: boolean;
};
/** A feature clip with its own section under the hero. */
export type CraftDemo = {
/** Muted, looping 16:9 clip. */
src: string;
/** Still under public/images/apps/<slug>/, shown until the clip plays. */
poster: string;
label: string;
/** Headline split around its one color-accented contrast word. */
headline: [before: string, accent: string, after: string];
lede: string;
caption: string;
};
export type CraftApp = {
@@ -83,7 +60,6 @@ export type CraftApp = {
features: Tip[];
/** First shot is the hero. */
shots: [CraftShot, ...CraftShot[]];
demo?: CraftDemo;
};
/** One release file, resolved to a URL for a specific app. */
@@ -559,523 +535,9 @@ export const CRAFTING_APPS: CraftApp[] = [
},
],
},
{
slug: "figcraft",
prefix: "Fig",
category: "Interface design",
status: "Early alpha",
headline: ["Interface design, ", "rebuilt", " in pure Rust."],
pitch: "Open-source interface design and prototyping, rebuilt in pure Rust.",
lede: "Frames, auto layout, components and variants, vector networks, styles, variables and prototypes in a native app that opens instantly and works offline. No account, no telemetry, no subscription.",
schemaCategory: "DesignApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.95",
features: [
{
title: "Familiar by design",
body: "The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. Productive on day one, no retraining.",
},
{
title: "Layouts that build themselves",
body: "Auto layout in both directions with wrap and grid, padding, gaps and Hug, Fill and Fixed sizing, plus constraints and layout grids for everything else.",
},
{
title: "Components that scale",
body: "Instances with overrides that survive edits, component sets with variants and properties, and styles and variables with modes for light and dark.",
},
{
title: "Real vector networks",
body: "Pen and pencil, a shape builder, booleans whose operands stay editable, variable-width strokes and smooth corners.",
},
{
title: "Prototype and hand off",
body: "Connections, triggers and animated transitions, a presentation view, and Dev Mode with measurements, annotations and generated CSS, HTML, React, SwiftUI and Compose code.",
},
{
title: "Agent-ready",
body: "Every menu item, tool and gesture runs one of 400+ commands, so the CLI, a JSON control channel and an MCP server can do anything you can click.",
},
],
shots: [
{
file: "hero.webp",
source: "shot-1-hero.png",
caption: "Ridgeline — auto layout screens in light and dark",
alt: "FigCraft editing the Ridgeline hiking app: three phone screens on the canvas, the Featured trail card instance selected, the layers panel showing its auto layout tree, and the Design panel showing Fill and Hug sizing and the Theme variable mode",
},
{
file: "components.webp",
source: "shot-2-components.png",
caption: "Components, variants and the Assets panel",
alt: "FigCraft's Assets panel with live component thumbnails beside a Button component set of nine variants, and the Design panel listing its Style and State variant properties",
},
{
file: "prototype.webp",
source: "shot-3-prototype.png",
caption: "Prototype connections and flows",
alt: "Four onboarding phone screens in FigCraft's Prototype tab joined by connection curves, with the Prototype panel showing an On click, Navigate to interaction",
},
{
file: "dev-mode.webp",
source: "shot-4-devmode.png",
caption: "Dev Mode with annotations and generated code",
alt: "FigCraft's Dev Mode on the Ridgeline home screen with a Ready for dev badge, an annotation callout, and the Inspect panel showing generated React code for the selected trail row",
},
{
file: "vector.webp",
source: "shot-5-vector.png",
caption: "Vector networks and variable-width strokes",
alt: "FigCraft's vector edit mode on a wave path with a tapered gradient stroke, its anchor points and bezier handles visible, above a boolean-built mountain mark and a knot of excluded rings",
},
{
file: "dashboard.webp",
source: "shot-6-dashboard-light.png",
caption: "Layout grids and styles, light theme",
alt: "FigCraft in its light theme with a web analytics dashboard of KPI cards, bar, donut and area charts, and the frame's 12-column layout grid showing as tinted columns",
},
{
file: "collab.webp",
source: "shot-7-collab.png",
caption: "Live session with cursor chat and spotlight",
alt: "A FigCraft live session with three participant avatars, a spotlight banner with a Follow button, two collaborators' cursors and selections on the canvas, and a cursor chat bubble",
},
],
demo: {
src: "https://frontend-cdn.fakeyou.com/videos/figcraft-collab-web.mp4",
poster: "collab-poster.webp",
label: "Live sessions",
headline: ["Design ", "together", ", in real time."],
lede: "Host the open file from the app or a server of your own, share the invite link, and edit it together. Changes sync live with cursors, selections and cursor chat, and undo takes back only your own.",
caption: "A host and a guest editing one file",
},
},
{
slug: "loopcraft",
prefix: "Loop",
category: "Beat making & music production",
status: "Early alpha",
headline: ["Beats, patterns and ", "songs", ", in pure Rust."],
pitch: "An open-source beat maker and music production studio, built in pure Rust.",
lede: "A pattern-based music workstation: a step sequencer and piano roll for writing parts, a playlist for arranging them, and a mixer with effects for finishing them. Native, open source, and drivable end to end by AI agents.",
schemaCategory: "MultimediaApplication",
platforms: ["macOS", "Windows", "Linux"],
rustVersion: "1.95",
features: [
{
title: "Step sequencer",
body: "Program drums in the channel rack, up to four bars a row, with swing, step fills and a graph editor for each step's velocity, pan, pitch and shift.",
},
{
title: "A deep piano roll",
body: "Draw, paint and slice notes, stamp chords and scale runs, then quantise, strum, arpeggiate or humanise them, with slide notes and ghost notes from other channels.",
},
{
title: "Arrange in the playlist",
body: "Place patterns and audio clips on tracks, resize, slice and duplicate them, drop time markers, and draw automation for any knob, fader or the tempo.",
},
{
title: "Instruments and effects",
body: "Twelve instruments, from a sampler and synths to drum machines and a slicer, and twenty-four effects, each with factory presets. The drum sounds are synthesised, not sampled.",
},
{
title: "Record and mix",
body: "Play parts in from a MIDI or computer keyboard, record audio takes, and mix on a master and twenty insert tracks with sends and sidechain compression.",
},
{
title: "Agent-ready",
body: "Every action is a command with an id, so a command-line tool, an MCP server and a control channel can write, arrange and export a track end to end.",
},
],
shots: [
{
file: "hero.webp",
source: "loopcraft-hero.png",
caption: "First Light — channel rack, mixer and playlist",
alt: "LoopCraft with its demo song open: the browser on the left, the channel rack with its step sequencer, the mixer with an effect rack, and the playlist with coloured pattern clips",
wide: true,
},
{
file: "light.webp",
source: "light-theme.png",
caption: "Light theme",
alt: "LoopCraft in its light theme: the same windows on white panels with a dark application bar",
wide: true,
},
{
file: "piano-roll.webp",
source: "piano-roll.png",
caption: "Piano roll with a velocity lane",
alt: "LoopCraft's piano roll showing a melody as green notes on a grid beside a piano keyboard, with a velocity lane underneath",
wide: true,
},
{
file: "instruments.webp",
source: "instrument-and-mixer.png",
caption: "Instrument editor over the playlist and mixer",
alt: "LoopCraft's three-oscillator instrument editor, a grid of knobs for the oscillators, an envelope and a filter, floating over the playlist and mixer",
wide: true,
},
],
},
{
slug: "wordcraft",
prefix: "Word",
category: "Word processing",
status: "In development",
headline: ["A fast, open-source ", "word processor", ", rebuilt in pure Rust."],
pitch: "Fast, open-source word processing, rebuilt in pure Rust.",
lede: "The ribbon, styles, tables, track changes, references and mail merge in a native word processor that reads and writes .docx. Open source, offline, and fast.",
schemaCategory: "BusinessApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "The ribbon you know",
body: "Tabs, groups, shortcuts and the live Styles gallery. Enter continues a list, Tab demotes it, and formatting works as your hands expect.",
},
{
title: "Real document formats",
body: "Opens and saves .docx natively, imports and exports .odt, .rtf, .html, .md and .txt, and exports clean PDF with selectable text.",
},
{
title: "Instant layout",
body: "Paragraph layout is cached: typing in a 188-page document re-lays it out in about 1.4 ms, with pages rendered on demand.",
},
{
title: "Track changes & review",
body: "Comment balloons in the margin, accept and reject changes, document comparison, plus offline spelling and grammar as you type.",
},
{
title: "Academic & business references",
body: "Generate tables of contents, footnotes, citations and bibliographies in APA, MLA, Chicago and IEEE styles, indexes, and captions.",
},
{
title: "Agent-ready commands",
body: "389 discrete commands drive the ribbon, keyboard shortcuts, a command-line tool, a JSON control channel and an MCP server.",
},
],
shots: [
{
file: "hero.webp",
source: "hero.png",
caption: "The Open Studio Handbook — ribbon, styles and navigation",
alt: "WordCraft with the Home tab of the ribbon open over a two-page document titled The Open Studio Handbook, the Navigation pane on the left, and the Styles gallery showing live previews",
},
{
file: "review.webp",
source: "review.png",
caption: "Review and track changes",
alt: "WordCraft's Review tab with Track Changes on, inline insertions and deletions, and margin comment balloons",
},
{
file: "references.webp",
source: "references.png",
caption: "Table of contents and citations",
alt: "WordCraft's References tab showing a table of contents with dot leaders on page one and citations on page two",
},
{
file: "layout.webp",
source: "layout.png",
caption: "Drop caps and text wrap",
alt: "WordCraft layout tab showing a three-line drop cap, text wrapping around an object, and margin line numbers",
},
{
file: "dark.webp",
source: "dark.png",
caption: "Dark mode with formatting marks",
alt: "WordCraft in dark mode with the Insert tab open and formatting marks visible",
},
],
},
{
slug: "soundcraft",
prefix: "Sound",
category: "Digital audio workstation",
status: "In development",
headline: ["Professional audio recording and ", "mixing", ", rebuilt in pure Rust."],
pitch: "Professional audio recording, editing and mixing in pure Rust.",
lede: "Dedicated Edit and Mix windows, multitrack audio and MIDI, ten inserts and sends per track, surround panning, and offline AudioSuite in a native DAW built from scratch.",
schemaCategory: "MultimediaApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "The studio workflow",
body: "Dedicated Edit and Mix windows, Shuffle, Slip, Spot and Grid modes, comping playlists, memory locations, and single-key Commands Focus shortcuts.",
},
{
title: "Full mixing console",
body: "Ten inserts and ten sends per track, busses, aux inputs, VCAs, routing folders, solo-in-place, and automatic plugin delay compensation.",
},
{
title: "Live automation",
body: "Write, Touch, Latch and Trim automation passes recorded live for volume, pan, mutes, sends and every plugin parameter.",
},
{
title: "Original plugins & CLAP support",
body: "Built-in EQ, dynamic processors, reverbs, delays, modulation, synthesisers, plus support for loading external CLAP plugins.",
},
{
title: "MIDI & notation",
body: "Piano-roll MIDI editor with velocity lane, step input, quantize, transposition, score notation editor, and Audio-to-MIDI pitch detection.",
},
{
title: "Surround & video sync",
body: "Surround panning up to 9.1.6, live speaker rendering, and a dedicated picture track with video playback synchronized to the playhead.",
},
],
shots: [
{
file: "hero.webp",
source: "edit.png",
caption: "Edit window — arranging and editing the demo session",
alt: "SoundCraft Edit window showing audio waveforms, MIDI tracks, rulers, markers and transport controls for a multi-track session",
},
{
file: "mix.webp",
source: "mix.png",
caption: "Mix console with inserts and sends",
alt: "SoundCraft Mix window with ten inserts and sends per track, pan knobs, faders, meters and an EQ plugin window",
},
{
file: "midi.webp",
source: "midi.png",
caption: "Piano-roll MIDI editor",
alt: "SoundCraft MIDI editor showing note velocity lanes and piano roll editing",
},
{
file: "automation.webp",
source: "automation.png",
caption: "Breakpoint automation and memory locations",
alt: "SoundCraft displaying volume automation curves on a track alongside the Memory Locations window",
},
{
file: "surround.webp",
source: "surround.png",
caption: "Immersive surround renderer",
alt: "SoundCraft Renderer window displaying a 7.1.4 surround speaker layout and spatial panners",
},
],
},
{
slug: "deckcraft",
prefix: "Deck",
category: "Presentations & slide shows",
status: "In development",
headline: ["Fast, dynamic slide ", "presentations", ", in pure Rust."],
pitch: "Build and deliver dynamic slide presentations, in pure Rust.",
lede: "Slide masters, themes, 150+ vector shapes, transitions, animations and presenter view in a native app that opens and saves presentation files. No subscription, no cloud.",
schemaCategory: "BusinessApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "Master slides & themes",
body: "Slide masters and eleven layouts with inheriting placeholders, eight original themes with font and colour schemes, sections, and presenter notes.",
},
{
title: "Vector shapes & drawing",
body: "Over 150 preset shapes with adjustment handles, sticky connectors, curve booleans, gradient fills, and outline styling.",
},
{
title: "Transitions & animation",
body: "Slide transitions including Morph, entrance, emphasis, exit and motion-path animations, with by-paragraph builds.",
},
{
title: "Presenter view",
body: "Full-screen slide show delivery with speaker notes, upcoming slide previews, elapsed timers, rehearsal timings, and pen drawing.",
},
{
title: "Format compatibility",
body: "Open and save presentation files (.pptx), export vector PDF slides and handouts, and render slide images.",
},
{
title: "Automated with MCP",
body: "Over 200 commands scriptable from the CLI, a JSON control channel, and an MCP server for autonomous agents.",
},
],
shots: [
{
file: "hero.webp",
source: "editor.png",
caption: "Slide editor with process diagram",
alt: "DeckCraft slide editor showing the ribbon, slide thumbnails, notes panel, and a vector process diagram on the canvas",
},
{
file: "shape-format.webp",
source: "shape-format.png",
caption: "Shape formatting and geometry",
alt: "DeckCraft Shape Format tab with selection handles and alignment tools on an active shape",
},
{
file: "format-pane.webp",
source: "format-pane.png",
caption: "Format Shape pane and gradient stops",
alt: "DeckCraft Format Shape pane configuring gradient fill angles, stops and transparency",
},
{
file: "slide-sorter.webp",
source: "slide-sorter.png",
caption: "Slide Sorter view with transitions",
alt: "DeckCraft Slide Sorter showing all slides in the deck with assigned transition badges",
},
{
file: "dark-mode.webp",
source: "dark-mode.png",
caption: "Dark mode with chart slide",
alt: "DeckCraft dark mode editing a presentation slide featuring a native chart",
},
],
},
{
slug: "cadcraft",
prefix: "CAD",
category: "Computer-aided design & drafting",
status: "In development",
headline: ["Computer-aided design and drafting, ", "reimagined", " in pure Rust."],
pitch: "Fast, open-source computer-aided design and drafting in pure Rust.",
lede: "The command line, object snaps, layers, dimensions, hatches, blocks and drawings you already know. Fast, native, and built for both people and AI agents.",
schemaCategory: "DesignApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "The drafting workflow",
body: "Type commands, click points, direct distance entry, object snaps, polar tracking, ortho, grips, and right-click-to-repeat.",
},
{
title: "Open drawing formats",
body: "Reads and writes DXF (ASCII and binary, R12–2018), opens DWG (R13–2018), and exports to PDF, SVG, and PNG.",
},
{
title: "Paper space & viewports",
body: "Layout sheets with floating viewports, per-viewport layer overrides, page setups, and scale-exact plotting.",
},
{
title: "Associative annotations",
body: "Linear, aligned, angular and radial dimensions that follow geometry, multileaders, tables, and custom drafting fonts.",
},
{
title: "Hatching & block libraries",
body: "Pick-point boundary hatching with islands, pattern fills, block definitions with attributes, and nested blocks.",
},
{
title: "Parametric constraints",
body: "Geometric and dimensional constraints with expression parameters that automatically re-solve as you edit.",
},
],
shots: [
{
file: "hero.webp",
source: "ui-apartment.png",
caption: "Apartment floor plan with hatching and dimensions",
alt: "CADCraft displaying an architectural floor plan with pick-point wall hatching, room schedules, dimensions and the command line",
},
{
file: "layout.webp",
source: "ui-layout.png",
caption: "Paper space layout with scaled viewport",
alt: "CADCraft paper space layout showing a title block sheet with an active scaled model viewport",
},
{
file: "bracket.webp",
source: "ui-bracket.png",
caption: "Mechanical part drawing with section hatch",
alt: "CADCraft drafting a mechanical mounting bracket with section lines, dimensions and title block",
},
],
},
{
slug: "gridcraft",
prefix: "Grid",
category: "Spreadsheets & data analysis",
status: "In development",
headline: ["Fast, native spreadsheets, ", "rebuilt", " in pure Rust."],
pitch: "Fast, open-source spreadsheets and data analysis, rebuilt in pure Rust.",
lede: "Ribbon tabs, formula bar, 500+ calculation functions, dynamic arrays, PivotTables and charts in a native spreadsheet app that reads and writes .xlsx files.",
schemaCategory: "BusinessApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "The spreadsheet workflow",
body: "Formula bar, Name Box, sheet tabs, fill handle, colored cell references, conditional formatting, and familiar shortcuts.",
},
{
title: "Native XLSX compatibility",
body: "Styles, themes, formulas, dynamic arrays, tables, validation, comments, sparklines and PivotTables round-trip cleanly.",
},
{
title: "500+ calculation functions",
body: "Full dependency-graph calculation engine with dynamic arrays, spilling, LET, LAMBDA, MAP, REDUCE, and table references.",
},
{
title: "Charts & PivotTables",
body: "Seventeen chart types with customizable formatting, plus PivotTables with filters, field lists, layouts, and date grouping.",
},
{
title: "High-performance engine",
body: "Copy-on-write workbooks with instant undo snapshots, incremental recalculation, and a virtualised grid.",
},
{
title: "Agent-native automation",
body: "Every ribbon button, menu item, and formula can be driven by AI agents over an MCP server, CLI, or JSON control channel.",
},
],
shots: [
{
file: "hero.webp",
source: "hero-sales.png",
caption: "Quarterly sales dashboard with charts and sparklines",
alt: "GridCraft sales dashboard featuring a styled table with conditional formatting, sparklines, and column and pie charts",
},
{
file: "budget.webp",
source: "budget.png",
caption: "Household budget with conditional formatting",
alt: "GridCraft budget sheet showing accounting currency formats, color scales, and comparison bar charts",
},
{
file: "grades-formulas.webp",
source: "grades-formulas.png",
caption: "Formulas and function library",
alt: "GridCraft formula bar and Formulas ribbon tab calculating averages and letter grades",
},
{
file: "pivot.webp",
source: "pivot.png",
caption: "PivotTable and field list",
alt: "GridCraft PivotTable report summarizing data with the PivotTable Fields pane open on the right",
},
{
file: "format-chart.webp",
source: "format-chart.png",
caption: "Chart Design and Format Chart Area pane",
alt: "GridCraft configuring a column chart with the Format Chart Area pane and highlighted source range",
},
],
},
];
const COUNT_WORDS = [
"Zero",
"One",
"Two",
"Three",
"Four",
"Five",
"Six",
"Seven",
"Eight",
"Nine",
"Ten",
"Eleven",
"Twelve",
"Thirteen",
"Fourteen",
"Fifteen",
"Sixteen",
];
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 =
@@ -1156,10 +618,6 @@ export function craftShotSourceUrl(app: CraftApp, shot: CraftShot): string {
return `https://raw.githubusercontent.com/storytold/${app.slug}/main/docs/images/${shot.source}`;
}
export function craftDemoPosterUrl(app: CraftApp, demo: CraftDemo): string {
return mediaUrl(`/images/apps/${app.slug}/${demo.poster}`);
}
export function craftAppIconPath(app: CraftApp): string {
return `/images/apps/${app.slug}/icon.webp`;
}