diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c6d043880..5bda0cce7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -69,7 +69,7 @@ body: attributes: label: Checklist options: - - label: I reproduced this on the latest preview revision. + - label: I reproduced this on the latest `main` revision. required: true - label: This report does not contain a vulnerability or sensitive data. required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31d0c5c62..ad2e7a70e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: Repository CI on: push: - branches: [main, preview] + branches: [main] pull_request: types: [opened, reopened, synchronize, ready_for_review] workflow_dispatch: @@ -17,7 +17,7 @@ concurrency: jobs: validate: name: Validate - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 45 steps: - name: Checkout @@ -69,26 +69,3 @@ jobs: - name: Test if: github.event_name != 'pull_request' || github.event.pull_request.draft == false run: pnpm test --concurrency=2 - - storekit-purchase-validation: - name: StoreKit purchase validation - if: github.event_name != 'pull_request' || github.event.pull_request.draft == false - runs-on: macos-15 - timeout-minutes: 15 - env: - DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Test StoreKit transaction retention - run: swift test --package-path libraries/react-native - - - name: Test StoreKit purchase and restore - working-directory: libraries/react-native - run: bash ios-storekit-tests/run-tests.sh diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index 10f7135ca..fadb0cf41 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -13,7 +13,7 @@ on: - selfhost/**/package.json - .github/workflows/osv-scanner.yml push: - branches: [main, preview] + branches: [main] paths: - package.json - pnpm-lock.yaml @@ -38,7 +38,7 @@ concurrency: jobs: scan: name: Production lockfile - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 10 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pr-packages.yml b/.github/workflows/pr-packages.yml index 5bcb6fc1a..7ed7c938b 100644 --- a/.github/workflows/pr-packages.yml +++ b/.github/workflows/pr-packages.yml @@ -4,7 +4,7 @@ # # pnpm add @voidhash/node@https://pkg.voidha.sh/node/ # -# - push to main/preview → tarballs tagged with sha + branch name +# - push to main → tarballs tagged with sha + branch name # - pull_request → tarballs tagged with sha + branch + `pr-` (1 week # TTL), plus a sticky install-instructions comment on # the PR @@ -19,7 +19,7 @@ name: PR Packages on: push: - branches: [main, preview] + branches: [main] paths: - packages/generated-clients/** - packages/shared/** diff --git a/.github/workflows/secret-scanning.yml b/.github/workflows/secret-scanning.yml index 77f3b2636..2c2ee20fe 100644 --- a/.github/workflows/secret-scanning.yml +++ b/.github/workflows/secret-scanning.yml @@ -2,7 +2,7 @@ name: Secret scanning on: push: - branches: [main, preview] + branches: [main] pull_request: schedule: - cron: "15 11 * * 1" @@ -18,7 +18,7 @@ concurrency: jobs: scan: name: Secret scanners - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout full history diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 08c803a2f..36812bfae 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -2,7 +2,7 @@ name: Security audit on: push: - branches: [main, preview] + branches: [main] paths: - package.json - pnpm-lock.yaml @@ -37,7 +37,7 @@ jobs: production-dependencies: name: Production dependencies if: github.event_name != 'pull_request' || github.event.pull_request.draft == false - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/selfhost.yml b/.github/workflows/selfhost.yml index ba96f6926..6cb17f201 100644 --- a/.github/workflows/selfhost.yml +++ b/.github/workflows/selfhost.yml @@ -6,7 +6,6 @@ on: push: branches: - main - - preview workflow_dispatch: permissions: @@ -22,7 +21,7 @@ env: jobs: smoke: if: github.event_name != 'pull_request' || github.event.pull_request.draft == false - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 40 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/storekit.yml b/.github/workflows/storekit.yml new file mode 100644 index 000000000..c47b219fd --- /dev/null +++ b/.github/workflows/storekit.yml @@ -0,0 +1,50 @@ +# StoreKit tests need a macOS runner, which bills at a large multiple of the +# Linux rate — so this workflow only triggers when the React Native library +# (the only code these tests exercise) actually changes, instead of on every +# PR. Blacksmith's macOS images mirror GitHub's, so the pinned DEVELOPER_DIR +# stays valid. +name: StoreKit + +on: + push: + branches: [main] + paths: + - libraries/react-native/** + - .github/workflows/storekit.yml + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + paths: + - libraries/react-native/** + - .github/workflows/storekit.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: storekit-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + storekit-purchase-validation: + name: StoreKit purchase validation + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: blacksmith-6vcpu-macos-15 + timeout-minutes: 15 + env: + DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Test StoreKit transaction retention + run: swift test --package-path libraries/react-native + + - name: Test StoreKit purchase and restore + working-directory: libraries/react-native + run: bash ios-storekit-tests/run-tests.sh diff --git a/SECURITY.md b/SECURITY.md index d15e04a76..e6fb2493c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,7 +3,7 @@ ## Supported Versions Voidhash is currently in private alpha and is not yet supported for production -use. Security fixes are applied to the latest `preview` branch. Older commits, +use. Security fixes are applied to the latest `main` branch. Older commits, preview builds, and unpublished alpha artifacts are not supported. This policy will be updated with a version support table before the first diff --git a/apps/www/src/features/www/landing/paywall-build/agent-transcript.tsx b/apps/www/src/features/www/landing/paywall-build/agent-transcript.tsx index 2ee8ad777..e584a7212 100644 --- a/apps/www/src/features/www/landing/paywall-build/agent-transcript.tsx +++ b/apps/www/src/features/www/landing/paywall-build/agent-transcript.tsx @@ -90,6 +90,7 @@ function ToolCallBlock({ /** * The Claude Code pane of the illustration: one user prompt followed by the Voidhash * MCP calls that compose the paywall. Scrolls itself so the newest call stays in view. + * Fills its container's width; only the height is fixed. */ export function AgentTranscript({ playback }: { playback: TranscriptPlayback }) { const scrollerRef = useRef(null); @@ -105,18 +106,18 @@ export function AgentTranscript({ playback }: { playback: TranscriptPlayback }) return (
-
+
Claude Code
setScrolled(event.currentTarget.scrollTop > 4)} ref={scrollerRef} > diff --git a/apps/www/src/features/www/landing/paywall-build/paywall-build.tsx b/apps/www/src/features/www/landing/paywall-build/paywall-build.tsx index 15ae64de8..fb21159e0 100644 --- a/apps/www/src/features/www/landing/paywall-build/paywall-build.tsx +++ b/apps/www/src/features/www/landing/paywall-build/paywall-build.tsx @@ -1,11 +1,11 @@ "use client"; import { useInView } from "motion/react"; -import { useRef } from "react"; +import { createContext, type ReactNode, useContext, useRef } from "react"; import { AgentTranscript } from "./agent-transcript"; import { LenscalPaywall, PAYWALL_HEIGHT, PAYWALL_WIDTH } from "./lenscal-paywall"; -import { useTranscriptPlayback } from "./use-transcript-playback"; +import { type TranscriptPlayback, useTranscriptPlayback } from "./use-transcript-playback"; import { WorkingIndicator } from "./working-indicator"; /** @@ -14,11 +14,19 @@ import { WorkingIndicator } from "./working-indicator"; */ const PAYWALL_SCALE = 0.6015; +const PlaybackContext = createContext(null); + /** - * The designer illustration's live half: a Claude Code session driving the Voidhash MCP, - * and the paywall assembling itself on the canvas as each call comes back. + * Hosts the shared Claude Code session driving both halves of the paywalls illustration: + * the full-width terminal and the designer canvas the paywall assembles on. */ -export function PaywallBuild() { +export function PaywallBuildProvider({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { const ref = useRef(null); const started = useInView(ref, { amount: 0.35, once: true }); // The session would otherwise loop (timers, typing interval, pixi ticker) for the rest of the @@ -28,13 +36,52 @@ export function PaywallBuild() { const playback = useTranscriptPlayback(started && visible); return ( -
-
+
+ {children} +
+ ); +} + +function usePlayback(): TranscriptPlayback { + const playback = useContext(PlaybackContext); + if (!playback) { + throw new Error("PaywallBuild pieces must render inside PaywallBuildProvider"); + } + return playback; +} + +/** + * The Claude Code terminal rendered at natural size above the designer mock. Only shown + * below xl — scaling the terminal down with the rest of the mock made it unreadable, so + * small screens get it full width while desktop keeps it floating on the canvas. + */ +export function PaywallBuildTerminal() { + return ( +
+ +
+ ); +} + +/** + * Overlay for the designer mock: the paywall assembling itself on the canvas as each MCP + * call comes back. On xl the terminal floats over the canvas as in the original design and + * the phone keeps its artboard position. Below xl the terminal lives above the mock, the + * mock's side panels and tab bar are trimmed, and the phone (with its working-indicator + * effect) is bumped up 25% and centered on the now-free canvas — `top-35` leaves an even + * 48px of artboard padding under the toolbar and above the window's bottom edge. + */ +export function PaywallBuildCanvas() { + const playback = usePlayback(); + + return ( +
+
- -
-
+ + {/* Below xl the detail card stops floating over the table and stacks above it, so the + two panels become an ordinary flex column instead of the artboard's overlap. */} +
+
Person @@ -205,9 +207,13 @@ export function LandingCrm() { 6 days ago
-
+ {/* Blends the table under the floating detail card — its focal point sits where the + card overlaps, so it only makes sense while the two are stacked in z, not in flow. */} +
+ {/* Mobile keeps just the bottom fade, so the row list still reads as continuing. */} +
-
+
diff --git a/apps/www/src/features/www/landing/sections/paywalls.tsx b/apps/www/src/features/www/landing/sections/paywalls.tsx index d688d9819..70100be80 100644 --- a/apps/www/src/features/www/landing/sections/paywalls.tsx +++ b/apps/www/src/features/www/landing/sections/paywalls.tsx @@ -1,4 +1,8 @@ -import { PaywallBuild } from "../paywall-build/paywall-build"; +import { + PaywallBuildCanvas, + PaywallBuildProvider, + PaywallBuildTerminal, +} from "../paywall-build/paywall-build"; import { LandingSection, ScaledMock, SectionHeader } from "../shared"; import { LandingAgentCompatibility } from "./agent-compatibility"; @@ -12,9 +16,11 @@ export function LandingPaywalls() { eyebrow="Paywalls" title="Ship paywalls in minutes with our no-code designer and full AI integration." /> - -
-
+ + + +
+
@@ -22,8 +28,8 @@ export function LandingPaywalls() {
-
-
+
+
@@ -39,7 +45,7 @@ export function LandingPaywalls() {
-
+
-
- -
- Dev -
-
Share @@ -107,7 +104,7 @@ export function LandingPaywalls() {
-
+
@@ -385,7 +382,7 @@ export function LandingPaywalls() {
-
+
@@ -565,12 +562,13 @@ export function LandingPaywalls() {
-
+
- +
+
diff --git a/apps/www/src/features/www/landing/shared.tsx b/apps/www/src/features/www/landing/shared.tsx index bb08a883e..0648f4112 100644 --- a/apps/www/src/features/www/landing/shared.tsx +++ b/apps/www/src/features/www/landing/shared.tsx @@ -1,7 +1,7 @@ "use client"; import { cn } from "@voidhash/ui"; -import { type ReactNode, useEffect, useRef } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; /** Full-bleed landing section: bottom hairline plus a centered, side-bordered content column. */ export function LandingSection({ @@ -69,42 +69,82 @@ export function SectionHeader({ * Scales a fixed-width design fragment down to fit its container. * * The product mockups are pixel-faithful exports from the 1810px artboard, so instead of - * re-flowing their internals we shrink each one with `zoom` — unlike `transform: scale` - * it keeps the layout height in sync with the scaled content. + * re-flowing their internals we shrink each one with `transform: scale`. `zoom` would keep + * layout height in sync for free, but iOS Safari shrinks only the boxes and re-renders the + * text near its original size, leaving the copy overlapping — a transform scales the rendered + * pixels wholesale, text included. The wrapper's height is synced to the scaled frame instead. + * + * @param designWidth Intrinsic width of the exported fragment, in artboard pixels. + * @param compactDesignWidth Narrower intrinsic width used below `xl`, for mockups whose markup + * drops its outer columns there. Scaling to the trimmed width rather than the full artboard is + * what actually buys the phone-sized viewport its resolution back. */ export function ScaledMock({ designWidth, + compactDesignWidth, className, children, }: { designWidth: number; + compactDesignWidth?: number; className?: string; children: ReactNode; }) { const frameRef = useRef(null); + const sizerRef = useRef(null); + const viewportRef = useRef(null); + const [compact, setCompact] = useState(false); + + useEffect(() => { + if (compactDesignWidth === undefined) { + return; + } + + // Matches Tailwind's `xl` breakpoint, which is where the mockups drop their outer columns. + const query = window.matchMedia("(width < 80rem)"); + const sync = () => setCompact(query.matches); + + sync(); + query.addEventListener("change", sync); + return () => query.removeEventListener("change", sync); + }, [compactDesignWidth]); + + const width = compact && compactDesignWidth !== undefined ? compactDesignWidth : designWidth; useEffect(() => { const frame = frameRef.current; - const viewport = frame?.parentElement; - if (!frame || !viewport) { + const sizer = sizerRef.current; + const viewport = viewportRef.current; + if (!frame || !sizer || !viewport) { return; } - const applyZoom = () => { - frame.style.zoom = `${Math.min(1, viewport.clientWidth / designWidth)}`; + const applyScale = () => { + const scale = Math.min(1, viewport.clientWidth / width); + frame.style.transform = `scale(${scale})`; + sizer.style.height = `${frame.offsetHeight * scale}px`; }; - applyZoom(); + applyScale(); - const observer = new ResizeObserver(applyZoom); + // The frame is observed too: offsetHeight ignores the transform, so a content-height + // change (fonts loading, animated blocks) must re-sync the sizer height. + const observer = new ResizeObserver(applyScale); observer.observe(viewport); + observer.observe(frame); return () => observer.disconnect(); - }, [designWidth]); + }, [width]); return ( -
-
- {children} +
+ {/* The scaled height lives on this inner div: callers pass flex classes for the outer + wrapper, and a flex-basis would override an inline height set directly on it. */} +
+ {/* flow-root keeps child margins inside the frame's box, so they get measured and + scaled with the rest of the mockup instead of collapsing out past the transform. */} +
+ {children} +
); diff --git a/docs/licensing-and-self-hosting-faq.md b/docs/licensing-and-self-hosting-faq.md index b3a6766c5..8eb5fd48c 100644 --- a/docs/licensing-and-self-hosting-faq.md +++ b/docs/licensing-and-self-hosting-faq.md @@ -37,7 +37,7 @@ Software is governed only by terms that expressly identify it. ## Is self-hosting production supported today? -Not yet. The repository is in private alpha and the latest `preview` branch is +Not yet. The repository is in private alpha and the latest `main` branch is the only security-maintained line. The supported path for evaluation is the documented Docker Compose configuration. A production support matrix and version table will replace this answer before the first public release.