diff --git a/captutor/screenplays/nopaint-first-painting.mjs b/captutor/screenplays/nopaint-first-painting.mjs new file mode 100644 index 0000000000..d1aa417a2f --- /dev/null +++ b/captutor/screenplays/nopaint-first-painting.mjs @@ -0,0 +1,135 @@ +// No Paint 3.0 — canonical first-run tutorial and acceptance screenplay. +// +// This is intentionally the same journey as tests/browser/nopaint-journey: +// first proposal → No → Paint → pause/resume → save. Captutor turns it into +// the narrated UX artifact; check() events turn the finished video into a +// fail-closed storyboard receipt. + +const AC_URL = process.env.CAPTUTOR_AC_URL || "https://aesthetic.computer"; +let acceptedFingerprint = null; + +const snapshot = (cdp) => cdp.eval(` + typeof window.__acNoPaintTest === "function" + ? window.__acNoPaintTest() + : null +`); + +export default { + slug: "nopaint-first-painting", + voice: "jeffrey", + window: "Chrome", + desktopFrame: true, + match: process.env.CAPTUTOR_AC_MATCH || "aesthetic.computer", + signIn: false, + billable: false, + fps: 60, + title: "No Paint 3.0: your first painting", + subtitle: "Listen to the machine. Say No, or Paint.", + openingCard: { + title: "No Paint 3.0", + durationMs: 2200, + transition: "slide", + }, + closingCard: { + title: "Your painting is yours to continue.", + durationMs: 2200, + transition: "genie", + }, + acceptance: { + minimumDurationSec: 24, + requireOpeningCard: true, + requireEndingCard: true, + loudnessLufs: [-18, -14], + requiredChecks: [ + "nopaint_3_booted", + "no_preserved_painting", + "paint_committed_proposal", + "pause_round_trip", + "save_requested", + ], + }, + + setup: async ({ cdp }) => { + await cdp.nav(`${AC_URL}/nopaint?seed=nopaint-e2e-v1&test=1`); + await cdp.waitFor("typeof window.__acNoPaintTest === 'function'"); + await cdp.waitFor("window.__acNoPaintTest().state === 'proposing'"); + }, + + beats: [ + { + say: "Welcome to No Paint. The machine begins by proposing something for your painting.", + do: async ({ cdp, check, point }) => { + await point("canvas", { moveMs: 650 }); + const state = await snapshot(cdp); + acceptedFingerprint = state.paintingFingerprint; + check("nopaint_3_booted", { + version: state.version, + state: state.state, + proposal: state.operation, + proposalNumber: state.proposalNumber, + controls: state.controls, + }); + }, + }, + { + say: "If you do not want the proposal, press N for No. It disappears without touching your painting.", + do: async ({ cdp, check }) => { + await cdp.key("n", "KeyN", 78); + await cdp.waitFor("window.__acNoPaintTest().proposalNumber === 2"); + const state = await snapshot(cdp); + check("no_preserved_painting", { + decision: state.decisions.at(-1), + unchanged: state.paintingFingerprint === acceptedFingerprint, + }); + }, + }, + { + say: "When a proposal belongs in the work, press Enter to Paint it. The machine commits it and continues.", + do: async ({ cdp, check }) => { + await cdp.key("Enter", "Enter", 13); + await cdp.waitFor("window.__acNoPaintTest().proposalNumber === 3"); + const state = await snapshot(cdp); + check("paint_committed_proposal", { + decision: state.decisions.at(-1), + changed: state.paintingFingerprint !== acceptedFingerprint, + fingerprint: state.paintingFingerprint, + }); + acceptedFingerprint = state.paintingFingerprint; + }, + }, + { + say: "Press Space whenever you want time to stop. Press Space again when you are ready to listen.", + do: async ({ cdp, check }) => { + await cdp.key(" ", "Space", 32); + await cdp.waitFor("window.__acNoPaintTest().state === 'paused'"); + const paused = await snapshot(cdp); + await new Promise((resolve) => setTimeout(resolve, 400)); + const stillPaused = await snapshot(cdp); + await cdp.key(" ", "Space", 32); + await cdp.waitFor("window.__acNoPaintTest().state === 'proposing'"); + await new Promise((resolve) => setTimeout(resolve, 250)); + const resumed = await snapshot(cdp); + check("pause_round_trip", { + paused: paused.state, + frameFrozen: stillPaused.proposalFrame === paused.proposalFrame, + resumed: resumed.state, + frameAdvanced: resumed.proposalFrame > stillPaused.proposalFrame, + paintingUnchanged: resumed.paintingFingerprint === acceptedFingerprint, + }); + }, + }, + { + say: "Press S to save the painting. You can return with the same seed and keep following the score.", + do: async ({ cdp, check }) => { + await cdp.key("s", "KeyS", 83); + await cdp.waitFor("window.__acNoPaintTest().saveCount === 1"); + const state = await snapshot(cdp); + check("save_requested", { + saveCount: state.saveCount, + filename: state.lastDownload, + png: state.lastDownload?.endsWith(".png"), + }); + }, + }, + ], +}; diff --git a/captutor/test/nopaint-screenplay.test.mjs b/captutor/test/nopaint-screenplay.test.mjs new file mode 100644 index 0000000000..26ad1af80d --- /dev/null +++ b/captutor/test/nopaint-screenplay.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import screenplay from "../screenplays/nopaint-first-painting.mjs"; + +test("No Paint first-run screenplay is a fail-closed non-billable tutorial", () => { + assert.equal(screenplay.signIn, false); + assert.equal(screenplay.billable, false); + assert.equal(screenplay.desktopFrame, true); + assert.equal(screenplay.beats.length, 5); + assert.ok(screenplay.openingCard); + assert.ok(screenplay.closingCard); + assert.deepEqual(screenplay.acceptance.requiredChecks, [ + "nopaint_3_booted", + "no_preserved_painting", + "paint_committed_proposal", + "pause_round_trip", + "save_requested", + ]); + for (const beat of screenplay.beats) { + assert.equal(typeof beat.say, "string"); + assert.ok(beat.say.length > 30); + assert.equal(typeof beat.do, "function"); + } +}); diff --git a/package.json b/package.json index 38a5966a27..fdb077aa5d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "test:browser": "node tests/browser/prompt-search.test.mjs", "test:browser:headed": "AC_HEADED=1 AC_SLOWMO=60 node tests/browser/prompt-search.test.mjs", "test:browser:local": "AC_TEST_URL=https://localhost:8888 node tests/browser/prompt-search.test.mjs", + "test:nopaint:e2e": "node tests/browser/nopaint-journey.test.mjs", + "test:nopaint:e2e:local": "node tests/browser/run-nopaint-local.mjs", + "test:nopaint:e2e:film": "AC_HEADED=1 AC_SLOWMO=120 AC_FRAME_RECEIPTS=1 node tests/browser/nopaint-journey.test.mjs", "test:flair-fps": "AC_TEST_URL=https://localhost:8888 node tests/browser/flair-fps.test.mjs", "test:flair-fps:headed": "AC_HEADED=1 AC_TEST_URL=https://localhost:8888 node tests/browser/flair-fps.test.mjs", "test:kidlisp": "nodemon --watch spec --watch '**/*.lisp' --exec 'jasmine'", @@ -96,6 +99,10 @@ "assets:sync:up": "aws s3 sync system/public/assets s3://assets-aesthetic-computer --endpoint-url https://sfo3.digitaloceanspaces.com --exclude '*.DS_Store' --exclude 'false.work/spiderlily-*.zip*' --acl public-read", "pop:assets:down": "aws s3 sync s3://assets-aesthetic-computer/pop system/public/assets/pop --endpoint-url https://sfo3.digitaloceanspaces.com", "pop:assets:up": "aws s3 sync system/public/assets/pop s3://assets-aesthetic-computer/pop --endpoint-url https://sfo3.digitaloceanspaces.com --exclude '*.DS_Store' --acl public-read", + "pop:workspace:push": "node pop/bin/remote-workspace.mjs push", + "pop:workspace:verify": "node pop/bin/remote-workspace.mjs verify", + "pop:workspace:fetch": "node pop/bin/remote-workspace.mjs fetch", + "pop:workspace:restore": "node pop/bin/remote-workspace.mjs restore", "podcast:feed": "node marketing/podcast/bin/feed.mjs", "podcast:publish": "node marketing/podcast/bin/publish.mjs", "podcast:publish:push": "node marketing/podcast/bin/publish.mjs --push", diff --git a/papers/cli.mjs b/papers/cli.mjs index 7d98f963f2..94d583ce46 100755 --- a/papers/cli.mjs +++ b/papers/cli.mjs @@ -262,6 +262,13 @@ const PAPER_MAP = { title: "The Media Estate", format: "deck", }, + "nopaint-3-full-shape": { + base: "nopaint-3-full-shape", + siteName: "nopaint-3-full-shape-2026", + title: "No Paint 3.0: The Full Shape", + format: "briefing", + hidden: true, + }, "arxiv-identity": { base: "identity", siteName: "handle-identity-atproto-26-arxiv", diff --git a/papers/nopaint-3-full-shape/nopaint-3-full-shape.tex b/papers/nopaint-3-full-shape/nopaint-3-full-shape.tex new file mode 100644 index 0000000000..9e86e88113 --- /dev/null +++ b/papers/nopaint-3-full-shape/nopaint-3-full-shape.tex @@ -0,0 +1,355 @@ +\documentclass[11pt,letterpaper]{article} +\usepackage[margin=0.78in,headheight=18pt,footskip=0.35in]{geometry} +\usepackage{fontspec} +\usepackage{xcolor} +\usepackage{booktabs} +\usepackage{tabularx} +\usepackage{array} +\usepackage{enumitem} +\usepackage{fancyhdr} +\usepackage{microtype} +\usepackage{tikz} +\usetikzlibrary{arrows.meta,positioning,shapes.geometric,fit} +\usepackage[most]{tcolorbox} +\usepackage[hidelinks]{hyperref} +\usepackage{titlesec} +\usepackage{ragged2e} + +\setmainfont{Arial} +\setsansfont{Arial} +\setmonofont{Menlo}[Scale=0.82] +\definecolor{Pink}{HTML}{E84A8A} +\definecolor{Teal}{HTML}{168E8A} +\definecolor{Purple}{HTML}{7651A8} +\definecolor{Gold}{HTML}{B88212} +\definecolor{Green}{HTML}{247A52} +\definecolor{Red}{HTML}{B43A42} +\definecolor{Ink}{HTML}{171B22} +\definecolor{Muted}{HTML}{626B76} +\definecolor{Pale}{HTML}{F2F4F6} +\definecolor{PalePink}{HTML}{FCEAF2} +\definecolor{PaleTeal}{HTML}{E8F6F4} +\definecolor{Rule}{HTML}{D8DEE4} + +\setlength{\parindent}{0pt} +\setlength{\parskip}{5pt} +\linespread{1.06} +\setlist[itemize]{leftmargin=0.25in,itemsep=2.5pt,topsep=3pt} +\setlist[enumerate]{leftmargin=0.30in,itemsep=3pt,topsep=3pt} +\renewcommand\labelitemi{\textcolor{Teal}{\small\textbullet}} +\titleformat{\section}{\Large\bfseries\color{Teal}}{}{0pt}{} +\titleformat{\subsection}{\large\bfseries\color{Ink}}{}{0pt}{} +\titlespacing*{\section}{0pt}{12pt}{5pt} +\titlespacing*{\subsection}{0pt}{8pt}{4pt} + +\pagestyle{fancy} +\fancyhf{} +\lhead{\footnotesize\color{Muted}\textbf{AESTHETIC COMPUTER} / NO PAINT 3.0} +\rhead{\footnotesize\color{Muted}FULL SHAPE} +\cfoot{\footnotesize\color{Muted}27 July 2026 \hfill Page \thepage} +\renewcommand{\headrulewidth}{0.4pt} +\renewcommand{\headrule}{\hbox to\headwidth{\color{Rule}\leaders\hrule height \headrulewidth\hfill}} + +\newcolumntype{L}[1]{>{\RaggedRight\arraybackslash}p{#1}} +\newcolumntype{Y}{>{\RaggedRight\arraybackslash}X} +\newcommand{\mono}[1]{\texttt{#1}} +\newcommand{\shipped}{\textcolor{Green}{\textbf{SHIPPED}}} +\newcommand{\nexttag}{\textcolor{Gold}{\textbf{CONNECT NEXT}}} +\newcommand{\explore}{\textcolor{Purple}{\textbf{EXPLORE}}} + +\newtcolorbox{hero}{colback=PalePink,colframe=Pink,boxrule=0.9pt,arc=2pt,left=10pt,right=10pt,top=8pt,bottom=8pt} +\newtcolorbox{note}{colback=PaleTeal,colframe=Teal,boxrule=0.65pt,arc=2pt,left=9pt,right=9pt,top=7pt,bottom=7pt} +\newtcolorbox{plain}{colback=Pale,colframe=Rule,boxrule=0.55pt,arc=2pt,left=9pt,right=9pt,top=7pt,bottom=7pt} + +\tikzset{ + nodebox/.style={draw=Rule,rounded corners=2pt,fill=white,minimum height=0.42in,text width=1.15in,align=center,font=\small}, + pinkbox/.style={nodebox,draw=Pink,fill=PalePink}, + tealbox/.style={nodebox,draw=Teal,fill=PaleTeal}, + flow/.style={-{Latex[length=2.2mm]},line width=0.7pt,draw=Muted}, + feedback/.style={-{Latex[length=2.2mm]},line width=0.8pt,draw=Pink,dashed} +} + +\begin{document} + +\vspace*{0.08in} +{\small\bfseries\color{Pink}ARTWORK + PRODUCT + COMPUTE + COMMUNITY SCORE}\par +\vspace{3pt} +{\fontsize{27}{31}\selectfont\bfseries\color{Ink}No Paint 3.0}\par +{\fontsize{15}{19}\selectfont\color{Muted}The complete AC-backed shape, from first proposal to evolving KidLisp lineage}\par +\vspace{10pt} + +\begin{hero} +\textbf{The center stays human.} A machine proposes. A person perceives. \textbf{No} discards; \textbf{Paint / Yes / Yay} preserves. Everything else---basic brushes, KidLisp growth, visual inference, Merry sequencing, remote GPU search, handles, uploads, tutorials, and archives---is a way to supply, judge, remember, or continue that encounter. +\end{hero} + +\begin{tabularx}{\textwidth}{@{}L{1.12in}Y@{}} +\textbf{Artwork} & \emph{No Paint, 2016--present}; recovered Construct lineage plus a living Aesthetic Computer implementation. \\ +\textbf{Version} & 3.0: AC owns proposal rendering, painting state, persistence, sharing, identity, and lineage. \\ +\textbf{Evidence} & Repository implementation and tests; recovered 2021 source/export; 32,184 archived paintings; AC Merry, nopaint, KidLisp, handle, and storage systems. \\ +\textbf{Status key} & \shipped\ exists now. \nexttag\ has an existing AC contract to wire. \explore\ is a deliberate possible UX path. \\ +\end{tabularx} + +\section{One loop, many intelligences} + +\begin{center} +\begin{tikzpicture}[node distance=0.42in and 0.42in] +\node[tealbox] (supplier) {candidate supplier}; +\node[pinkbox,right=of supplier] (proposal) {live proposal buffer}; +\node[nodebox,right=of proposal] (human) {human perception}; +\node[nodebox,below right=0.42in and -0.15in of human] (no) {No / Nay\\discard}; +\node[pinkbox,above right=0.42in and -0.15in of human] (yes) {Paint / Yes / Yay\\commit}; +\node[tealbox,right=0.65in of human] (lineage) {painting + score + lineage}; +\draw[flow] (supplier)--(proposal); +\draw[flow] (proposal)--(human); +\draw[flow] (human)--(no); +\draw[flow] (human)--(yes); +\draw[flow] (yes)--(lineage); +\draw[feedback] (no.south) to[bend right=28] (supplier.south); +\draw[feedback] (lineage.north) to[bend left=25] (supplier.north); +\end{tikzpicture} +\end{center} + +Candidate suppliers are interchangeable. The decision contract is not. + +\begin{tabularx}{\textwidth}{@{}L{1.55in}L{1.0in}Y@{}} +\toprule +\textbf{Supplier} & \textbf{State} & \textbf{What it proposes} \\ +\midrule +AC 3.0 catalog & \shipped & Deterministic Box, Ellipse, Line, Wipe, Camera, Softy, Bubbles, Grid Worm, Walker, Banner, and Wafer operations. \\ +Basic AC brushes & \nexttag & Ordinary \mono{system = "nopaint"} pieces with their existing boot/paint/act/bake behavior and parameter vocabulary. \\ +KidLisp rule queue & \nexttag & User-authored or machine-grown programs rendered into an isolated proposal surface. \\ +Merry pipeline & \nexttag & Ordered, preloaded, timed, URL-addressable sequences; optionally crossfaded and UTC-synchronized. \\ +Painting interpreter & \explore & Several honest coded readings inferred from a finished raster---never a false claim of original source recovery. \\ +\mono{jastow} search & \explore & Bounded population growth, rendering, ranking, and mutation; never canonical storage or required live infrastructure. \\ +\bottomrule +\end{tabularx} + +\section{What has been built} + +\begin{tabularx}{\textwidth}{@{}L{1.65in}Y@{}} +\toprule +\textbf{Layer} & \textbf{Current implementation} \\ +\midrule +Recovered lineage & Complete editable Construct project recovered and hash-verified; 2021 export served at \mono{nopaint.art} and preserved for \mono{/classic}. \\ +Archive & 32,184 paintings from 2021--2025 indexed; searchable gallery with CDN thumbnails and visual descriptions. \\ +Native conductor & Explicit choosing, proposing, committing, discarding, and paused states over AC's separate proposal buffer and persistent painting. \\ +Determinism & Seeded weighted selection; a session URL can describe a repeatable score. Recovered weighting includes duplicated Box, Softy 1.5, and rare Walker 0.2. \\ +Decision record & \mono{nopaint:session} stores version, seed, proposal number, operation, and No/Paint decisions alongside the local AC painting. \\ +Migration ledger & All 35 recovered operations are enumerated in a machine-readable catalog; 11 currently render natively. \\ +Testing/tutorial spine & Unit contract, browser journey, optional native Frame receipts, and a fail-closed Captutor first-painting screenplay have been scaffolded. \\ +\bottomrule +\end{tabularx} + +\begin{note} +\textbf{Already credible as an artwork.} The recovered source, multi-year public archive, playable classic, gallery, native proposal loop, and decision score establish continuity. Feature parity, user sync, and the broader Lisp ecology remain implementation work. +\end{note} + +\section{AC systems we should reuse} + +\subsection{The existing nopaint system is the painting substrate} + +Every basic brush can inherit \mono{system = "nopaint"}. The runtime supplies a persistent painting, transparent proposal buffer, coordinate transforms, pan/zoom, gesture recording, undo paintings, local database persistence, brush parameter parsing, robot input, present/bake behavior, and shared input handling. + +The historical \mono{system.nopaint.no(api, yes)} operation is undo/redo over committed pixel snapshots. It must remain distinct from a proposal decision: \textbf{proposal No/Paint} judges a live candidate; \textbf{history back/forward} navigates already committed revisions. UI copy may say No/Yes, but internal events must not conflate them. + +\begin{center} +\begin{tikzpicture}[node distance=0.36in] +\node[nodebox] (boot) {brush boot\\parse params}; +\node[nodebox,right=of boot] (paint) {brush paint\\preview buffer}; +\node[pinkbox,right=of paint] (decision) {No / Paint}; +\node[nodebox,right=of decision] (bake) {brush bake\\accepted pixels}; +\node[tealbox,right=of bake] (store) {painting + undo + record}; +\draw[flow] (boot)--(paint); \draw[flow] (paint)--(decision); \draw[flow] (decision)--(bake); \draw[flow] (bake)--(store); +\end{tikzpicture} +\end{center} + +\textbf{Rule:} No Paint 3.0 should be a conductor over this system, not a second painting engine. Basic brushes remain normal AC pieces; the conductor adapts their preview and bake into explicit proposals. + +\subsection{Merry is the sequencing substrate} + +Merry already parses duration-wrapped pieces, expands named bags, preloads modules, aligns playback to UTC, exposes current index/progress, advances steps, loops, stops cleanly, records tape, and can host KidLisp crossfades without jumping pieces. No Paint needs its sequencing ideas, but not automatic acceptance. + +\begin{plain} +\textbf{Adaptation:} replace Merry's timer-only transition with a \emph{review gate}. A candidate can animate for a bounded preview; No/Paint advances immediately. Optional auto-step can advance undecided candidates as \textbf{Skip}, never as acceptance. The progress bar becomes a lineage/population bar. +\end{plain} + +\section{The UX family} + +These are modes over one event model, not competing product definitions. + +\begin{tabularx}{\textwidth}{@{}L{1.40in}L{1.15in}Y@{}} +\toprule +\textbf{Path} & \textbf{Decision} & \textbf{Experience} \\ +\midrule +Classic No Paint & No / Paint & A live operation animates over the current painting; Paint commits pixels and asks again. \\ +Lisp jury & Nay / Yay & One rendered KidLisp rule at a time; human perception supplies clean binary fitness. \\ +Pairwise garden & Left / Right & Two mutations render from the same seed; choose which survives. \\ +Rule studio & Reject / Accept / Edit & Code, render, parents, and parameters remain visible; accepted code becomes user-owned source. \\ +Merry review & No / Yes / Step & Traverse a bag/population manually; previous/next/skip never alter the painting unless explicitly accepted. \\ +Painting reading & Nay / Yay & A finished raster yields several compact coded interpretations; the participant curates the reading. \\ +Autonomous growth & Interrupt / Judge & Remote compute evolves between visits, but pauses at meaningful perceptual forks. \\ +Curatorial grid & Keep / Remove / Compare & Rapid review outside a painting; selections seed later No Paint sessions. \\ +Teaching mode & Explain / Continue & Show how a rule, render, mutation, and decision relate; the same journey becomes a tutorial test. \\ +\bottomrule +\end{tabularx} + +\section{Canonical state and events} + +\subsection{Session} + +\begin{verbatim} +NoPaintSession { + id, version, ownerSub?, ownerHandle?, seed, + mode, state, currentCandidateId?, currentStep, + basePaintingHash, acceptedPaintingHash, + createdAt, updatedAt, visibility +} +\end{verbatim} + +\subsection{Candidate} + +\begin{verbatim} +Candidate { + id, kind: brush | kidlisp | transform | inferred, + source, sourceHash, renderSeed, runtimeVersion, + parameters, parentIds[], generation?, supplier, + sourcePaintingHash?, previewArtifact?, createdAt +} +\end{verbatim} + +\subsection{Immutable event stream} + +\begin{verbatim} +NoPaintEvent { + id, sessionId, sequence, at, + type: proposed | no | paint | yay | nay | skip | + step-forward | step-back | edit | save | publish, + candidateId?, paintingBeforeHash?, paintingAfterHash?, + actorSub?, actorHandle?, input: key | pointer | touch | voice | robot +} +\end{verbatim} + +The event stream is the artwork's score. Current state is a projection. Uploads are idempotent by \mono{sessionId + sequence}; local-first queues retry after reconnection. Pixels and large artifacts live in object storage; Mongo documents retain ownership, hashes, source, lineage, decisions, and URLs. + +\begin{plain} +\textbf{Reducer boundary.} Input surfaces dispatch events; one reducer/effect layer owns state. Commit is atomic: candidate + before fingerprint $\rightarrow$ composite and flatten $\rightarrow$ undo snapshot, persist, and cross-tab broadcast $\rightarrow$ after fingerprint $\rightarrow$ append decision $\rightarrow$ clear overlay $\rightarrow$ request next. Reject clears only the overlay and never changes canvas revision, fingerprint, or history. +\end{plain} + +\begin{hero} +\textbf{Core safety rule.} Remote growth and visual inference may propose candidates. Only an explicit human acceptance event may mutate the accepted painting or publish a user-owned rule. +\end{hero} + +\section{Identity, handles, and ownership} + +The first encounter must remain playable without registration. Account creation becomes meaningful at the moment a person wants continuity, public authorship, or cross-device growth. + +\begin{center} +\begin{tikzpicture}[node distance=0.36in] +\node[nodebox] (anon) {anonymous play\\local session}; +\node[pinkbox,right=of anon] (save) {save / continue\\across devices}; +\node[nodebox,right=of save] (new) {\mono{imnew}\\verify email}; +\node[nodebox,right=of new] (handle) {\mono{handle name}\\claim identity}; +\node[tealbox,right=of handle] (sync) {upload score, rules, paintings}; +\draw[flow] (anon)--(save); \draw[flow] (save)--(new); \draw[flow] (new)--(handle); \draw[flow] (handle)--(sync); +\end{tikzpicture} +\end{center} + +\begin{itemize} + \item Anonymous sessions persist locally and can export a portable bundle. + \item After authentication, queued events attach to the stable user subject; a handle supplies public attribution. + \item Handle creation is never required to say No or Paint. It is invited when authorship has something worth naming. + \item Public rule pages can live at handle-addressed AC routes; private sessions remain private by default. + \item Publishing records source/runtime hashes so a visible render can always be traced to its code and score. +\end{itemize} + +Existing AC contracts make this concrete: + +\begin{itemize} + \item \mono{imnew} calls the existing signup/login flow; \mono{handle name} claims a unique, verified-email handle through the authenticated \mono{/handle} backend. Ownership binds to immutable user subject, never to the mutable public handle. + \item The common \mono{upload(...)} pipeline stores a PNG, calls authenticated \mono{/api/track-media}, returns the painting code, and already connects profile/ATProto records. + \item Accepted public Lisp can use authenticated \mono{/api/store-kidlisp}; source-hash dedup means it is a publication store, not the authoritative decision ledger. + \item Add one dedicated authenticated \mono{/api/nopaint-sessions} endpoint and collection for idempotent session/step sync. Keep pixel buffers out of Mongo; reference painting codes and Lisp codes. +\end{itemize} + +Saving therefore has four explicit levels: \textbf{local checkpoint}, \textbf{cloud session sync}, \textbf{published painting}, and \textbf{published Lisp organism}. Only the last two should emit public profile events. + +\section{Painting to code, without pretending} + +A raster cannot reveal one true source program. The product should call the result a \textbf{reading}, \textbf{response}, or \textbf{inferred score}. + +\begin{center} +\begin{tikzpicture}[node distance=0.35in] +\node[tealbox] (raster) {finished painting}; +\node[nodebox,right=of raster] (features) {palette, edges, rhythm, density, symmetry}; +\node[nodebox,right=of features] (synth) {constrained KidLisp synthesis}; +\node[pinkbox,right=of synth] (jury) {human Yay / Nay}; +\node[tealbox,right=of jury] (organism) {editable rule + lineage}; +\draw[flow] (raster)--(features); \draw[flow] (features)--(synth); \draw[flow] (synth)--(jury); \draw[flow] (jury)--(organism); +\end{tikzpicture} +\end{center} + +Deterministic vision supplies the initial structural sketch. A bounded synthesizer produces valid KidLisp. Render similarity can rank candidates, but perception chooses. The accepted rule can mutate, render at any resolution, and become a supplier for another painting. + +\section{The role of jastow} + +\mono{jastow} is an optional worker, not part of the artwork's availability path. At this writing it is offline on the Tailnet and was last seen two days ago, so the most recent Lisp growth results could not be audited for this report. + +\begin{tabularx}{\textwidth}{@{}L{1.2in}Y@{}} +\toprule +\textbf{Do there} & Population mutation, batch KidLisp rendering, visual embeddings, bounded search, thumbnail/video generation, and offline evaluation. \\ +\textbf{Do in AC} & Canonical compilation, sandboxed preview, user decision, painting mutation, identity, persistence, publishing, and cached offline playback. \\ +\textbf{Never require} & A live SSH connection, residential workstation uptime, or unbounded generated code to play a saved session. \\ +\bottomrule +\end{tabularx} + +\section{Testing and tutorial production are one surface} + +Every canonical journey should exist twice from one behavioral contract: + +\begin{enumerate} + \item A fast real-browser e2e asserts the read-only No Paint state hook, painting fingerprints, decision sequence, animation freeze, persistence, and PNG delivery. + \item A Captutor screenplay performs the same journey with narration, visible cursor intent, opening/closing cards, and required evidence checks. + \item Frame captures the actual macOS display at milestones, including browser chrome, clipping, cursor, OCR, and compositor reality. + \item The encoded tutorial emits a storyboard/QA receipt and fails closed when required evidence is missing. +\end{enumerate} + +\begin{plain} +\textbf{First canonical journey:} boot a reproducible proposal $\rightarrow$ No without mutation $\rightarrow$ Paint with mutation $\rightarrow$ pause/freeze $\rightarrow$ resume $\rightarrow$ save a PNG. Future journeys add handle creation, Lisp jury, pairwise growth, painting reading, and publishing. +\end{plain} + +\section{Delivery sequence} + +\begin{enumerate} + \item \textbf{Stabilize the spine.} Finish the seeded browser e2e against the real AC boot shell; retain deterministic unit coverage and Captutor's static acceptance contract. + \item \textbf{Adapt basic brushes.} Create a proposal adapter that runs existing nopaint brush preview/bake behavior without duplicating brush implementations. + \item \textbf{Add manual Lisp jury.} Frozen fixture population first; Yay/Nay/Skip/Previous/Next; immutable local event log. + \item \textbf{Sync to account.} Authenticated idempotent event upload, session list, handle attribution, private-by-default visibility, portable export. + \item \textbf{Complete authored parity.} Port the remaining 10 brushes and 15 transforms; connect stamps/profiles, sound, playlists, and painter stories. + \item \textbf{Connect growth.} Import recent \mono{jastow} experiment outputs, define a versioned candidate packet, and run bounded asynchronous generations with cached fallback. + \item \textbf{Add painting readings.} Deterministic feature extraction, constrained KidLisp candidates, perceptual review, and explicit ``inferred from'' provenance. + \item \textbf{Change the front door.} When 3.0 is credible and recoverable, route \mono{nopaint.art} to it; retain \mono{/classic}, \mono{/gallery}, and add \mono{/story}. +\end{enumerate} + +\section{Launch definition} + +No Paint 3.0 succeeds when: + +\begin{itemize} + \item A new person understands No/Paint in seconds without an account. + \item The same piece works with keyboard, pointer, touch, voice, and robot inputs. + \item Existing AC brushes can become proposals without forks. + \item KidLisp candidates remain inspectable, editable, reproducible, and sandboxed. + \item No/Yay/Nay/step changes survive locally, sync idempotently, and belong to a user account after optional handle creation. + \item Every published painting connects to its score, accepted rules, runtime versions, and lineage. + \item Offline play works without \mono{jastow}; remote compute enriches rather than controls the artwork. + \item Each major UX path ships with a passing browser journey, Captutor tutorial, Frame evidence, and storyboard receipt. +\end{itemize} + +\begin{hero} +\centering +{\large\bfseries Paintings become scores. Scores grow paintings.}\par +\vspace{3pt} +The machine can propose forever. The artwork happens where a person decides what survives. +\end{hero} + +\end{document} diff --git a/plans/NOPAINT_CONSTRUCT_MIGRATION.md b/plans/NOPAINT_CONSTRUCT_MIGRATION.md index 2c520210c8..4b5f820bf8 100644 --- a/plans/NOPAINT_CONSTRUCT_MIGRATION.md +++ b/plans/NOPAINT_CONSTRUCT_MIGRATION.md @@ -1,5 +1,39 @@ # No Paint: Construct Recovery and Aesthetic Computer Migration +## No Paint 3.0 — AC-backed implementation status + +No Paint 3.0 is the living Aesthetic Computer implementation. Construct is a +preserved historical runtime at `nopaint.art/classic`, not a dependency of the +new work. + +Shipped in the AC conductor: + +- Versioned deterministic proposal scores encoded by the `nopaint:` URL. +- Explicit `No`, `Paint`, pause, and save/share actions over AC's proposal + buffer and persistent painting. +- Decision-score persistence in `nopaint:session` alongside the AC painting: + version, seed, proposal number, operation, and `no`/`paint` decision. +- Recovered weighting: Box appears twice, Softy has weight 1.5, and Walker is + rare at 0.2. +- Native renderers for Box, Ellipse, Line, Wipe, Camera, Softy, Bubbles, Grid + Worm, Walker, Banner, and Wafer. +- A machine-readable ledger of all 35 recovered operations in + `lib/nopaint-proposals.mjs`, so migration completeness is testable. + +Remaining 3.0 implementation: + +1. Port the other 10 authored brushes: Build, Dark Window, Aura, Triangle, + Breathe, Vignette, Caterpillar, Frame, Stamp, and Rainbow. +2. Port the other 15 transforms: Blur, Sharpen, Mirror, Noise, Flip, Saturate, + Invert, Scroll, Contrast, Zoom, Recurse, Quicksand, Spin, Light Bump, Turn. +3. Build the 1,197-image stamp/profile manifest and connect it to AC handles. +4. Restore server saving through an AC endpoint with an explicit contribution + license; retain the old 32,184 paintings as an immutable archive generation. +5. Recover playlists, sounds, parameter strings, painter stories, and profile + presentation on top of the same proposal/decision API. +6. Make `nopaint.art` launch 3.0, keeping `/classic`, `/gallery`, and `/story` + as the historical and interpretive routes. + ## Source recovered - Dropbox source: `/Apps/Construct 3/No Paint/No Paint.c3p` diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index 6769c1e45e..b01e21c658 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -3,7 +3,9 @@ import { NOPAINT_LOOP_STATES, + NOPAINT_VERSION, makeProposal, + proposalDefinition, seededRandom, seedFrom, } from "../lib/nopaint-proposals.mjs"; @@ -16,6 +18,21 @@ let proposalNumber = 0; let sessionSeed = 0; let random = seededRandom(1); let cameraFeed = null; +let decisions = []; +let saveCount = 0; +let lastDownload = null; +let testApi = null; +let testChannel = null; + +function initialNavigationURL() { + if (typeof window === "undefined") return null; + const initial = performance.getEntriesByType?.("navigation")?.[0]?.name; + try { + return new URL(initial || window.location.href); + } catch { + return null; + } +} let noButton; let paintButton; let saveButton; @@ -62,8 +79,10 @@ function chooseProposal(api) { function discardProposal(api) { if (loopState !== "proposing" && loopState !== "paused") return; transition("discarding"); + recordDecision(api, "no"); clearProposal(api); chooseProposal(api); + publishTestState(); } function persistPainting({ store, system }) { @@ -72,12 +91,27 @@ function persistPainting({ store, system }) { height: system.painting.height, pixels: new Uint8ClampedArray(system.painting.pixels), }; - store.persist("painting", "local:db"); + store.persist("painting", "nopaint:session", "local:db"); +} + +function recordDecision({ store }, decision) { + decisions.push(Object.freeze({ + number: proposalNumber, + operation: proposal?.kind, + decision, + })); + store["nopaint:session"] = { + version: NOPAINT_VERSION, + seed: sessionSeed, + decisions: decisions.slice(), + }; + store.persist("nopaint:session", "local:db"); } function commitProposal(api) { if (loopState !== "proposing" && loopState !== "paused") return; transition("committing"); + recordDecision(api, "paint"); api.page(api.system.painting).paste(api.system.nopaint.buffer); api.flatten(); @@ -88,6 +122,7 @@ function commitProposal(api) { persistPainting(api); clearProposal(api); chooseProposal(api); + publishTestState(); } function togglePaused({ needsPaint }) { @@ -98,19 +133,98 @@ function togglePaused({ needsPaint }) { transition("paused"); } needsPaint(); + publishTestState(); } function savePainting({ canShare, download, num, system }) { - download(`nopaint-${num.timestamp()}.png`, system.painting, { + saveCount += 1; + lastDownload = `nopaint-${num.timestamp()}.png`; + download(lastDownload, system.painting, { scale: 2, cropToScreen: true, sharing: canShare, }); + publishTestState(); +} + +function paintingFingerprint(painting) { + if (!painting?.pixels) return null; + let hash = 2166136261; + const stride = Math.max(1, Math.floor(painting.pixels.length / 4096)); + for (let index = 0; index < painting.pixels.length; index += stride) { + hash ^= painting.pixels[index]; + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function testSnapshot() { + return { + version: NOPAINT_VERSION, + state: loopState, + seed: sessionSeed, + proposalNumber, + proposalFrame, + operation: proposal?.kind || null, + decisions: decisions.map((decision) => ({ ...decision })), + saveCount, + lastDownload, + ready: Boolean(proposal && testApi?.system?.nopaint?.buffer), + controls: { + no: noButton?.btn?.box ? { + x: noButton.btn.box.x, y: noButton.btn.box.y, + w: noButton.btn.box.w, h: noButton.btn.box.h, + } : null, + paint: paintButton?.btn?.box ? { + x: paintButton.btn.box.x, y: paintButton.btn.box.y, + w: paintButton.btn.box.w, h: paintButton.btn.box.h, + } : null, + save: saveButton?.btn?.box ? { + x: saveButton.btn.box.x, y: saveButton.btn.box.y, + w: saveButton.btn.box.w, h: saveButton.btn.box.h, + } : null, + }, + paintingFingerprint: paintingFingerprint(testApi?.system?.painting), + }; +} + +function publishTestState() { + testChannel?.postMessage(testSnapshot()); +} + +function installTestHook(debug) { + const explicitlyTesting = initialNavigationURL()?.searchParams.has("test"); + if (!debug && !explicitlyTesting && typeof window !== "undefined" && !window.acDEBUG) return; + + if (typeof BroadcastChannel !== "undefined") { + testChannel?.close(); + testChannel = new BroadcastChannel("ac-nopaint-test"); + testChannel.onmessage = ({ data }) => { + if (data?.type !== "configure" || !data.seed || !testApi) return; + const configuredSeed = /^\d+$/.test(String(data.seed)) + ? Number(data.seed) >>> 0 + : seedFrom(String(data.seed)); + if (configuredSeed === sessionSeed) return; + sessionSeed = configuredSeed; + random = seededRandom(sessionSeed); + proposalNumber = 0; + clearProposal(testApi); + chooseProposal(testApi); + publishTestState(); + }; + } + + if (typeof window !== "undefined") { + window.__acNoPaintTest = () => Object.freeze(testSnapshot()); + } } // 🥾 Boot -function boot({ colon, hud, net, num, params, screen, system, ui, ...api }) { - const requestedSeed = colon[0] || params[0]; +function boot({ colon, debug, hud, net, num, params, screen, system, ui, ...api }) { + // The runtime may rewrite the visible route before the piece boots. The + // Navigation Timing entry retains the original tutorial/test URL. + const urlSeed = initialNavigationURL()?.searchParams.get("seed") || null; + const requestedSeed = urlSeed || colon[0] || params[0]; const numericSeed = /^\d+$/.test(requestedSeed || "") ? Number(requestedSeed) >>> 0 : null; @@ -122,6 +236,10 @@ function boot({ colon, hud, net, num, params, screen, system, ui, ...api }) { proposalFrame = 0; proposalNumber = 0; cameraFeed = null; + decisions = []; + saveCount = 0; + lastDownload = null; + testApi = { ...api, screen, system }; stateBeforePause = "proposing"; noButton = new ui.TextButton("No"); @@ -131,7 +249,9 @@ function boot({ colon, hud, net, num, params, screen, system, ui, ...api }) { hud.label("No Paint — No [N] / Paint [Enter]"); net.rewrite(`/nopaint:${sessionSeed}`); - chooseProposal({ ...api, screen, system }); + installTestHook(debug); + chooseProposal(testApi); + publishTestState(); } // 🧮 Sim @@ -139,6 +259,7 @@ function sim({ needsPaint }) { if (loopState === "proposing") { proposalFrame += 1; needsPaint(); + if (proposalFrame % 12 === 0) publishTestState(); } } @@ -186,6 +307,76 @@ function renderProposal($) { proposal.y + proposal.h - drift, proposal.thickness, ); + } else if (proposal.kind === "softy") { + proposal.points.slice(0, 12).forEach((point, index) => { + const breathe = 1 + Math.sin(phase + index * 0.7) * 0.18; + ink(color[0], color[1], color[2], 22).oval( + point.x + drift, + point.y - drift, + point.size * 2.8 * breathe, + point.size * 2.8 * breathe, + true, + ); + }); + } else if (proposal.kind === "bubbles") { + proposal.points.forEach((point, index) => { + const rise = (proposalFrame * (0.08 + (index % 4) * 0.03)) % buffer.height; + ink(color[0], color[1], color[2], 150).oval( + point.x, + (point.y - rise + buffer.height) % buffer.height, + point.size, + point.size, + false, + Math.max(1, proposal.thickness / 3), + ); + }); + } else if (proposal.kind === "grid-worm") { + const cell = Math.max(8, Math.floor(Math.min(buffer.width, buffer.height) / 14)); + const path = proposal.points.map((point, index) => ({ + x: Math.floor(point.x / cell) * cell + Math.sin(phase + index) * 2, + y: Math.floor(point.y / cell) * cell + Math.cos(phase + index) * 2, + })); + for (let index = 1; index < path.length; index += 1) { + ink(color).line(path[index - 1].x, path[index - 1].y, path[index].x, path[index].y, proposal.thickness); + } + } else if (proposal.kind === "walker") { + let x = proposal.x; + let y = proposal.y; + proposal.points.forEach((point, index) => { + const nextX = x + Math.cos(proposal.phase + index * 1.7) * point.size; + const nextY = y + Math.sin(proposal.phase + index * 1.3) * point.size; + ink(color).line(x + drift, y, nextX + drift, nextY, proposal.thickness); + x = nextX; + y = nextY; + }); + } else if (proposal.kind === "banner") { + const stripes = 9; + for (let index = 0; index < stripes; index += 1) { + const y = proposal.y + (proposal.h / stripes) * index; + const sway = Math.sin(phase + index * 0.55) * proposal.drift; + ink(color[0], color[1], color[2], 80 + index * 14).box( + proposal.x + sway, + y, + proposal.w, + Math.max(2, proposal.h / stripes - 1), + ); + } + } else if (proposal.kind === "wafer") { + const cols = 7; + const rows = 7; + const cw = proposal.w / cols; + const ch = proposal.h / rows; + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < cols; col += 1) { + const lift = Math.sin(phase + row * 0.5 + col * 0.35) * 3; + ink(color[0], color[1], color[2], (row + col) % 2 ? 70 : 150).box( + proposal.x + col * cw, + proposal.y + row * ch + lift, + Math.max(1, cw - 1), + Math.max(1, ch - 1), + ); + } + } } else if (proposal.kind === "wipe") { ink(color[0], color[1], color[2], 255).box( 0, @@ -221,11 +412,12 @@ function paint($) { $.system.nopaint.needsPresent = true; const paused = loopState === "paused" ? " · paused" : ""; + const definition = proposalDefinition(proposal.kind); $.ink(255).write( - `${proposal.kind} ${proposalNumber}${paused}`, + `${definition?.label || proposal.kind} ${proposalNumber}${paused}`, { x: 8, y: 8 }, ); - $.ink(255, 150).write(`seed ${sessionSeed}`, { x: 8, y: 20 }); + $.ink(255, 150).write(`No Paint ${NOPAINT_VERSION} · seed ${sessionSeed}`, { x: 8, y: 20 }); positionButtons($.screen); noButton.paint($, [[20, 20, 20], [255, 255, 255], [255, 255, 255]]); @@ -270,6 +462,13 @@ function act($) { // never commit a proposal behind the participant's back. function bake() {} +function leave() { + if (typeof window !== "undefined") delete window.__acNoPaintTest; + testChannel?.close(); + testChannel = null; + testApi = null; +} + function meta() { return { title: "No Paint", @@ -286,4 +485,4 @@ function meta() { // the accepted painting without silently accepting the live proposal. export const system = "nopaint:bake-on-leave"; -export { act, bake, boot, meta, paint, sim }; +export { act, bake, boot, leave, meta, paint, sim }; diff --git a/system/public/aesthetic.computer/lib/nopaint-proposals.mjs b/system/public/aesthetic.computer/lib/nopaint-proposals.mjs index 4fa2bd8b53..62c3d5eb28 100644 --- a/system/public/aesthetic.computer/lib/nopaint-proposals.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-proposals.mjs @@ -8,16 +8,48 @@ export const NOPAINT_LOOP_STATES = Object.freeze([ "paused", ]); +export const NOPAINT_VERSION = "3.0"; + +// Names recovered from the Construct picker. This is the migration ledger; +// entries stay here even before their AC renderer is ready. +export const NOPAINT_RECOVERED_CATALOG = Object.freeze({ + brushes: Object.freeze([ + "softy", "build", "bubbles", "banner", "dark-window", "wafer", + "walker", "aura", "box", "triangle", "line", "ellipse", "breathe", + "vignette", "caterpillar", "frame", "grid-worm", "stamp", "rainbow", + ]), + transforms: Object.freeze([ + "wipe", "blur", "sharpen", "mirror", "noise", "flip", "saturate", + "invert", "scroll", "contrast", "zoom", "recurse", "quicksand", + "spin", "light-bump", "turn", + ]), +}); + +export const NOPAINT_IMPLEMENTED_OPERATIONS = Object.freeze([ + "box", "ellipse", "line", "softy", "bubbles", "grid-worm", "walker", + "banner", "wafer", "wipe", "camera", +]); + // The recovered Construct picker contains Box twice. The other first-pass // operations occur once, so rect keeps that aggregate 2x weight here. export const NOPAINT_PROPOSAL_CATALOG = Object.freeze([ - Object.freeze({ name: "rect", weight: 2 }), - Object.freeze({ name: "oval", weight: 1 }), - Object.freeze({ name: "line", weight: 1 }), + Object.freeze({ name: "rect", label: "Box", family: "brush", weight: 2 }), + Object.freeze({ name: "oval", label: "Ellipse", family: "brush", weight: 1 }), + Object.freeze({ name: "line", label: "Line", family: "brush", weight: 1 }), + Object.freeze({ name: "softy", label: "Softy", family: "authored", weight: 1.5 }), + Object.freeze({ name: "bubbles", label: "Bubbles", family: "authored", weight: 1 }), + Object.freeze({ name: "grid-worm", label: "Grid Worm", family: "authored", weight: 1 }), + Object.freeze({ name: "walker", label: "Walker", family: "authored", weight: 0.2 }), + Object.freeze({ name: "banner", label: "Banner", family: "authored", weight: 1 }), + Object.freeze({ name: "wafer", label: "Wafer", family: "authored", weight: 1 }), Object.freeze({ name: "wipe", weight: 1 }), Object.freeze({ name: "camera", weight: 1 }), ]); +export function proposalDefinition(name, catalog = NOPAINT_PROPOSAL_CATALOG) { + return catalog.find((entry) => entry.name === name) || null; +} + export function seedFrom(value) { const text = String(value ?? "nopaint"); let hash = 2166136261; @@ -63,6 +95,11 @@ export function makeProposal(random, width, height) { const h = Math.max(12, Math.floor(height * (0.12 + random() * 0.42))); const x = Math.floor(random() * Math.max(1, width - w)); const y = Math.floor(random() * Math.max(1, height - h)); + const points = Array.from({ length: 18 }, () => Object.freeze({ + x: Math.floor(random() * width), + y: Math.floor(random() * height), + size: Math.floor(3 + random() * Math.max(4, Math.min(width, height) * 0.12)), + })); return Object.freeze({ kind, @@ -73,5 +110,7 @@ export function makeProposal(random, width, height) { h, drift: Math.floor(4 + random() * Math.max(5, Math.min(width, height) * 0.08)), thickness: Math.floor(1 + random() * 8), + points: Object.freeze(points), + phase: random() * Math.PI * 2, }); } diff --git a/system/tests/nopaint-proposals.test.mjs b/system/tests/nopaint-proposals.test.mjs index b21adda3e4..d8bec05d98 100644 --- a/system/tests/nopaint-proposals.test.mjs +++ b/system/tests/nopaint-proposals.test.mjs @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { NOPAINT_LOOP_STATES, NOPAINT_PROPOSAL_CATALOG, + NOPAINT_VERSION, makeProposal, pickWeightedProposal, seededRandom, @@ -24,10 +25,10 @@ test("native No Paint exposes the recovered loop states", () => { test("the first native catalog retains the recovered duplicate Box weight", () => { const rect = NOPAINT_PROPOSAL_CATALOG.find(({ name }) => name === "rect"); assert.equal(rect.weight, 2); - assert.equal( - NOPAINT_PROPOSAL_CATALOG.reduce((sum, proposal) => sum + proposal.weight, 0), - 6, - ); + assert.equal(NOPAINT_VERSION, "3.0"); + assert.equal(NOPAINT_PROPOSAL_CATALOG.length, 11); + assert.equal(NOPAINT_PROPOSAL_CATALOG.find(({ name }) => name === "softy").weight, 1.5); + assert.equal(NOPAINT_PROPOSAL_CATALOG.find(({ name }) => name === "walker").weight, 0.2); }); test("the same session seed produces the same proposal sequence", () => { @@ -41,11 +42,14 @@ test("the same session seed produces the same proposal sequence", () => { }); test("weighted proposal boundaries select every first-pass operation", () => { - const total = 6; - const at = (weightedPosition) => pickWeightedProposal( - () => weightedPosition / total, - NOPAINT_PROPOSAL_CATALOG, - ); + const catalog = [ + { name: "rect", weight: 2 }, + { name: "oval", weight: 1 }, + { name: "line", weight: 1 }, + { name: "wipe", weight: 1 }, + { name: "camera", weight: 1 }, + ]; + const at = (weightedPosition) => pickWeightedProposal(() => weightedPosition / 6, catalog); assert.equal(at(0), "rect"); assert.equal(at(1.99), "rect"); @@ -55,6 +59,13 @@ test("weighted proposal boundaries select every first-pass operation", () => { assert.equal(at(5), "camera"); }); +test("No Paint 3.0 includes the first recovered authored brush set", () => { + const names = new Set(NOPAINT_PROPOSAL_CATALOG.map(({ name }) => name)); + for (const name of ["softy", "bubbles", "grid-worm", "walker", "banner", "wafer"]) { + assert.equal(names.has(name), true, `${name} is in the proposal catalog`); + } +}); + test("Paint commits the proposal buffer while No only discards it", () => { class TextButton { constructor() { @@ -126,7 +137,16 @@ test("Paint commits the proposal buffer while No only discards it", () => { assert.deepEqual([...painting.pixels], new Array(16).fill(90)); assert.equal(undoCount, 1); - assert.equal(persistCount, 1); + assert.equal(persistCount, 2); + assert.deepEqual(store["nopaint:session"], { + version: "3.0", + seed: store["nopaint:session"].seed, + decisions: [{ + number: 1, + operation: store["nopaint:session"].decisions[0].operation, + decision: "paint", + }], + }); assert.deepEqual([...buffer.pixels], new Array(16).fill(0)); buffer.pixels.fill(180); @@ -137,6 +157,15 @@ test("Paint commits the proposal buffer while No only discards it", () => { assert.deepEqual([...painting.pixels], new Array(16).fill(90)); assert.equal(undoCount, 1); + assert.equal(persistCount, 3); + assert.deepEqual(store["nopaint:session"].decisions, [ + { + number: 1, + operation: store["nopaint:session"].decisions[0].operation, + decision: "paint", + }, + { number: 2, operation: store["nopaint:session"].decisions[1].operation, decision: "no" }, + ]); assert.deepEqual([...buffer.pixels], new Array(16).fill(0)); }); diff --git a/tests/browser/README.md b/tests/browser/README.md index 880dcd5dad..e163b8f9de 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -15,6 +15,35 @@ Env overrides: `AC_TEST_URL`, `AC_HEADED=1`, `AC_SLOWMO=`, `AC_SHOT_DIR`. Screenshots land in `tests/browser/__screens__/` (gitignored). +## No Paint 3.0: tests become tutorials + +The canonical first-run journey has two synchronized surfaces: + +- `nopaint-journey.test.mjs` is the fast functional e2e. It drives a seeded + real browser and asserts the read-only `window.__acNoPaintTest()` contract. +- `captutor/screenplays/nopaint-first-painting.mjs` performs the same journey + as a narrated Captutor production. Its required `check()` events make the + encoded tutorial fail closed when the visual story does not prove the UX. + +Puppeteer screenshots are always produced. On a macOS host with SlabMenubar's +Screen Recording permission, `AC_FRAME_RECEIPTS=1` also captures whole-display +Frame receipts at every milestone. Set `AC_FRAME_REQUIRED=1` when missing +native receipts should fail the run. + +```bash +npm run test:nopaint:e2e:local # starts/stops a static local site when necessary +npm run test:nopaint:e2e:film + +# With a dedicated CDP Chrome already open: +CAPTUTOR_AC_URL=https://localhost:8888 \ +CAPTUTOR_AC_MATCH=localhost:8888 \ +node captutor/captutor.mjs render nopaint-first-painting +``` + +Use a dedicated browser profile for filmed tutorials. The journey deliberately +commits and saves a painting, so a personal AC painting store is the wrong test +fixture. + ## How it works The AC prompt renders to ``, so there are no DOM nodes to assert on. diff --git a/tests/browser/ac-harness.mjs b/tests/browser/ac-harness.mjs index 170a4159a3..e3e07e9bf5 100644 --- a/tests/browser/ac-harness.mjs +++ b/tests/browser/ac-harness.mjs @@ -17,7 +17,7 @@ // degrade to screenshot-only smoke and `state()` returns null. import puppeteer from "puppeteer"; -import { mkdirSync } from "node:fs"; +import { mkdirSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -34,6 +34,7 @@ export class ACSession { browser = null; page = null; shots = []; + downloadDir = null; // One Chrome instance only (this laptop is 8 GB — never run parallel). static async open() { @@ -55,8 +56,28 @@ export class ACSession { // Surface the test hook at boot (prompt.mjs gates it on acDEBUG). await s.page.evaluateOnNewDocument(() => { window.acDEBUG = true; + let snapshot = null; + const testChannel = new BroadcastChannel("ac-nopaint-test"); + testChannel.onmessage = ({ data }) => { + if (data?.version) snapshot = data; + }; + window.__acNoPaintTest = () => snapshot; + const originalURL = new URL(location.href); + const requestedSeed = originalURL.searchParams.get("seed"); + if (requestedSeed) { + const timer = setInterval(() => { + testChannel.postMessage({ type: "configure", seed: requestedSeed }); + }, 100); + setTimeout(() => clearInterval(timer), 5000); + } }); s.page.on("pageerror", (e) => console.warn(" ⚠️ pageerror:", e.message)); + s.downloadDir = join(CONFIG.shotDir, "downloads", String(Date.now())); + mkdirSync(s.downloadDir, { recursive: true }); + await s.page._client().send("Page.setDownloadBehavior", { + behavior: "allow", + downloadPath: s.downloadDir, + }); return s; } @@ -145,8 +166,29 @@ export class ACSession { ); } + async nopaintState() { + return await this.page.evaluate(() => + typeof window.__acNoPaintTest === "function" + ? window.__acNoPaintTest() + : null, + ); + } + + async waitForDownload({ timeoutMs = 10000 } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const files = readdirSync(this.downloadDir).filter( + (name) => !name.endsWith(".crdownload"), + ); + if (files.length > 0) return files; + await this.wait(100); + } + return []; + } + async shot(name) { const path = join(CONFIG.shotDir, `${name}.png`); + mkdirSync(dirname(path), { recursive: true }); await this.page.screenshot({ path }); this.shots.push(path); console.log(` 📸 ${path}`); diff --git a/tests/browser/ac-static-server.mjs b/tests/browser/ac-static-server.mjs new file mode 100644 index 0000000000..ee10e7674e --- /dev/null +++ b/tests/browser/ac-static-server.mjs @@ -0,0 +1,60 @@ +// Minimal AC static server for browser tests. Unknown paths intentionally serve +// index.html because every piece is URL-addressable (`/nopaint:seed`). + +import { createReadStream, existsSync, statSync } from "node:fs"; +import http from "node:http"; +import { extname, join, normalize } from "node:path"; + +const root = process.argv[2]; +const port = Number(process.argv[3] || 8888); +if (!root) throw new Error("usage: ac-static-server.mjs [port]"); + +const types = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".wasm": "application/wasm", + ".woff2": "font/woff2", +}; + +const shell = ` + + + + + + + + +`; + +http.createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname); + const assetPath = pathname.startsWith("/aesthetic.computer/") + ? pathname.slice("/aesthetic.computer".length) + : pathname; + const relative = normalize(assetPath).replace(/^[/\\]+/, ""); + let file = join(root, relative); + if (!file.startsWith(root) || !existsSync(file) || statSync(file).isDirectory()) { + response.writeHead(200, { + "cache-control": "no-store", + "content-type": "text/html; charset=utf-8", + }); + response.end(shell); + return; + } + response.writeHead(200, { + "cache-control": "no-store", + "content-type": types[extname(file).toLowerCase()] || "application/octet-stream", + }); + createReadStream(file).pipe(response); +}).listen(port, "127.0.0.1", () => { + console.log(`AC browser-test server: http://localhost:${port}`); +}); diff --git a/tests/browser/frame-receipts.mjs b/tests/browser/frame-receipts.mjs new file mode 100644 index 0000000000..1c6fd116c4 --- /dev/null +++ b/tests/browser/frame-receipts.mjs @@ -0,0 +1,46 @@ +// Optional native Frame receipts for browser journeys. Puppeteer screenshots +// remain the portable CI artifact; Frame proves what the actual display showed +// during headed/staged runs, including browser chrome and compositor state. + +import { execFileSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const FRAME = join(ROOT, "slab", "bin", "frame.mjs"); + +function localMachine() { + return process.env.AC_FRAME_MACHINE || execFileSync( + "scutil", + ["--get", "LocalHostName"], + { encoding: "utf8" }, + ).trim(); +} + +export function frameReceiptsEnabled() { + return process.env.AC_FRAME_RECEIPTS === "1"; +} + +export function captureFrameReceipt(name, outputDir) { + if (!frameReceiptsEnabled()) return null; + mkdirSync(outputDir, { recursive: true }); + const output = join(outputDir, `${name}.frame.jpg`); + try { + execFileSync(process.execPath, [ + FRAME, + localMachine(), + "--screen", + "--cursor", + "--out", + output, + "--json", + ], { stdio: ["ignore", "pipe", "inherit"], timeout: 20000 }); + console.log(` 🖼️ ${output}`); + return output; + } catch (error) { + if (process.env.AC_FRAME_REQUIRED === "1") throw error; + console.warn(` ⚠️ Frame receipt unavailable: ${error.message}`); + return null; + } +} diff --git a/tests/browser/nopaint-journey.test.mjs b/tests/browser/nopaint-journey.test.mjs new file mode 100644 index 0000000000..9a62b02b51 --- /dev/null +++ b/tests/browser/nopaint-journey.test.mjs @@ -0,0 +1,103 @@ +// No Paint 3.0 canonical first-run journey. +// +// Portable CI: +// AC_TEST_URL=https://localhost:8888 npm run test:nopaint:e2e +// Native visual receipts (headed Chrome + Slab FrameCapture permission): +// AC_HEADED=1 AC_FRAME_RECEIPTS=1 npm run test:nopaint:e2e + +import { join } from "node:path"; +import { ACSession, CONFIG, report, scenario } from "./ac-harness.mjs"; +import { captureFrameReceipt } from "./frame-receipts.mjs"; + +const ac = await ACSession.open(); +const receiptDir = join(CONFIG.shotDir, "nopaint-journey"); + +async function receipt(name) { + await ac.shot(`nopaint-journey/${name}`); + captureFrameReceipt(name, receiptDir); +} + +try { + await scenario("No Paint 3.0 boots a reproducible first proposal", async (expect) => { + await ac.boot("nopaint?seed=nopaint-e2e-v1&test=1"); + const state = await ac.nopaintState(); + await receipt("01-first-proposal"); + if (!state) { + console.log(" ℹ️ hook diagnostic:", await ac.page.evaluate(() => ({ + href: location.href, + navigation: performance.getEntriesByType("navigation")[0]?.name, + debug: window.acDEBUG, + hookType: typeof window.__acNoPaintTest, + }))); + } + expect(state !== null, "No Paint test contract is installed"); + expect(state?.version === "3.0", `version is 3.0 (got ${state?.version})`); + expect(state?.state === "proposing", `state is proposing (got ${state?.state})`); + expect(state?.proposalNumber === 1, "first proposal is numbered 1"); + expect(state?.operation === "camera", `seed begins with Camera (got ${state?.operation})`); + expect(state?.ready === true, "proposal buffer reports ready"); + expect( + [state?.controls?.no, state?.controls?.paint, state?.controls?.save] + .every((box) => box && box.w > 0 && box.h > 0), + "No, Paint, and Save expose visible control rectangles", + ); + }); + + await scenario("No rejects without changing the accepted painting", async (expect) => { + const before = await ac.nopaintState(); + await ac.press("KeyN"); + const after = await ac.nopaintState(); + await receipt("02-after-no"); + expect(after?.proposalNumber === 2, "No advances to proposal 2"); + expect(after?.decisions.at(-1)?.decision === "no", "decision score records No"); + expect( + after?.paintingFingerprint === before?.paintingFingerprint, + "No leaves the accepted painting unchanged", + ); + }); + + await scenario("Paint commits and persists the proposal score", async (expect) => { + const before = await ac.nopaintState(); + await ac.press("KeyP"); + const after = await ac.nopaintState(); + await receipt("03-after-paint"); + expect(after?.proposalNumber === 3, "Paint advances to proposal 3"); + expect(after?.decisions.at(-1)?.decision === "paint", "decision score records Paint"); + expect( + after?.paintingFingerprint !== before?.paintingFingerprint, + "Paint changes the accepted painting", + ); + }); + + await scenario("Pause freezes and resumes the live proposal", async (expect) => { + await ac.press("Space"); + const paused = await ac.nopaintState(); + await ac.wait(400); + const stillPaused = await ac.nopaintState(); + await receipt("04-paused"); + expect(paused?.state === "paused", `state is paused (got ${paused?.state})`); + expect(stillPaused?.proposalFrame === paused?.proposalFrame, "proposal frame freezes while paused"); + await ac.press("Space"); + await ac.wait(250); + const resumed = await ac.nopaintState(); + expect(resumed?.state === "proposing", "Space resumes proposing"); + expect(resumed?.proposalFrame > stillPaused?.proposalFrame, "proposal animation resumes"); + }); + + await scenario("Save uses the AC painting export path", async (expect) => { + await ac.press("KeyS"); + const downloads = await ac.waitForDownload(); + const state = await ac.nopaintState(); + await receipt("05-saved"); + expect(state?.saveCount === 1, `one save was requested (got ${state?.saveCount})`); + expect(state?.lastDownload?.endsWith(".png"), `download is named as PNG (${state?.lastDownload})`); + expect( + downloads.some((name) => name.endsWith(".png")) || state?.lastDownload?.endsWith(".png"), + "the browser received a PNG download or native-share export request", + ); + }); +} finally { + await ac.close(); +} + +process.exit(report()); diff --git a/tests/browser/run-nopaint-local.mjs b/tests/browser/run-nopaint-local.mjs new file mode 100644 index 0000000000..4dcaa4ee3c --- /dev/null +++ b/tests/browser/run-nopaint-local.mjs @@ -0,0 +1,83 @@ +// One-command local No Paint e2e: reuse a healthy site when one exists, +// otherwise start Netlify dev, wait for HTTPS readiness, run the journey, and +// always stop only the process group this runner created. + +import { spawn } from "node:child_process"; +import http from "node:http"; +import https from "node:https"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const BASE = process.env.AC_TEST_URL || "http://localhost:8888"; + +function healthy() { + const url = new URL("/nopaint?seed=nopaint-e2e-v1&test=1", BASE); + const client = url.protocol === "https:" ? https : http; + return new Promise((resolve) => { + const request = client.get(url, { + rejectUnauthorized: false, + timeout: 1500, + }, (response) => { + response.resume(); + resolve((response.statusCode || 0) < 500); + }); + request.on("timeout", () => request.destroy()); + request.on("error", () => resolve(false)); + }); +} + +async function waitForSite(site, timeoutMs = 30000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await healthy()) return; + if (site.exitCode !== null) { + throw new Error(`local AC site exited before becoming healthy (${site.exitCode})`); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`local AC site did not become healthy at ${BASE}`); +} + +function run(command, args, options = {}) { + return spawn(command, args, { stdio: "inherit", ...options }); +} + +let site = null; +let exitCode = 1; +try { + if (!(await healthy())) { + console.log(`Starting the local AC site for No Paint e2e at ${BASE}…`); + site = run(process.execPath, [ + join(ROOT, "tests", "browser", "ac-static-server.mjs"), + join(ROOT, "system", "public", "aesthetic.computer"), + "8888", + ], { + cwd: ROOT, + detached: true, + }); + site.on("exit", (code) => { + if (code && code !== 0) console.error(`local AC site exited early (${code})`); + }); + await waitForSite(site); + } else { + console.log(`Reusing the local AC site at ${BASE}.`); + } + + const test = run(process.execPath, [ + join(ROOT, "tests", "browser", "nopaint-journey.test.mjs"), + ], { + cwd: ROOT, + env: { ...process.env, AC_TEST_URL: BASE }, + }); + exitCode = await new Promise((resolve) => test.on("exit", (code) => resolve(code ?? 1))); +} finally { + if (site?.pid) { + console.log("Stopping the local AC site started by this run…"); + try { + process.kill(-site.pid, "SIGTERM"); + } catch {} + } +} + +process.exit(exitCode);