diff --git a/.mcp.json b/.mcp.json index d4146ced4..e18ef039d 100644 --- a/.mcp.json +++ b/.mcp.json @@ -63,6 +63,13 @@ "toolchain/mcp/sweep-mcp.mjs" ] }, + "x": { + "type": "stdio", + "command": "node", + "args": [ + "slab/bin/x-mcp.mjs" + ] + }, "roblox": { "type": "stdio", "command": "node", diff --git a/SCREEN.md b/SCREEN.md index 3f7823db7..47091e8fd 100644 --- a/SCREEN.md +++ b/SCREEN.md @@ -19,6 +19,12 @@ the system paints it at `(6, 6)` in the default 6×10 typeface (`lib/disk.mjs`, so: **readouts go along the bottom, or right-aligned.** that's the default. a top bar is a decision, not a reflex. +## drawing API essentials + +`screen` is an object: use `screen.width` and `screen.height`, never `screen[0]`, `screen[1]`, `screen.w` or `screen.h`. +Use named colors or numeric RGB channels with `ink(r, g, b)`; CSS `hsl(...)` strings are not a supported color format. +`circle(x, y, radius, true)` draws a filled circle; omit the final flag for an outline. + ## paint - return nothing to keep animating; return `false` to paint once and freeze (`disk.mjs`: `noPaint = paintOut === false || …`). diff --git a/apple/aesel/.gitignore b/apple/aesel/.gitignore new file mode 100644 index 000000000..390a56308 --- /dev/null +++ b/apple/aesel/.gitignore @@ -0,0 +1,3 @@ + +# Built from the canonical JavaScript session by bundle-session.sh. +Resources/Session/ diff --git a/apple/aesel/Aesel.xcodeproj/project.pbxproj b/apple/aesel/Aesel.xcodeproj/project.pbxproj index 76649b307..24a62a420 100644 --- a/apple/aesel/Aesel.xcodeproj/project.pbxproj +++ b/apple/aesel/Aesel.xcodeproj/project.pbxproj @@ -7,18 +7,38 @@ objects = { /* Begin PBXBuildFile section */ + 06DAB9B425E19C4E02F25A24 /* AeselPieceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72A337044339282DB88573A0 /* AeselPieceView.swift */; }; 1F53641C03321161A7665F3B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505E152C400C5CE69463B39F /* ContentView.swift */; }; 2DB3100BC43BD21115A1BA37 /* AeselApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7CE6904A010A2BEC894D13C /* AeselApp.swift */; }; 3661DCFA19E2594688511C86 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B505C3279E0F8DB5292B86 /* Session.swift */; }; + 45F9F1BEFDE1DA054142C04D /* UNIFONT-COPYRIGHT.txt in Resources */ = {isa = PBXBuildFile; fileRef = BDD34AEBA3C87153FB573037 /* UNIFONT-COPYRIGHT.txt */; }; 6E01FC52B2208E175FFC6985 /* SessionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */; }; + 7AE3B1696F71BFDE82E8B72E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 228E709932A29DC953A73B71 /* Assets.xcassets */; }; + 85656270F7033893DDF7CC12 /* aesel.png in Resources */ = {isa = PBXBuildFile; fileRef = DB0FC013DFC41F9D9094BE2A /* aesel.png */; }; + A2F701E804CFBFA1C1CC5088 /* ac-easel-unifont.ttf in Resources */ = {isa = PBXBuildFile; fileRef = AF88A30677AA0D7671B393F9 /* ac-easel-unifont.ttf */; }; + B637527F3CE86BDA9157F007 /* UNIFONT-OFL-1.1.txt in Resources */ = {isa = PBXBuildFile; fileRef = C1222F5DEDEE9240C904FFA6 /* UNIFONT-OFL-1.1.txt */; }; + B8236CE8844912F069905928 /* AeselScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */; }; + C48716DE150CDF3FE383298E /* Session in Resources */ = {isa = PBXBuildFile; fileRef = 47AE0766266534A8EE665542 /* Session */; }; + C5323C1BB9D8D59F14A1FF74 /* AeselModelPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26176753E43E445E2CF551D /* AeselModelPicker.swift */; }; + FBC8984584385405A83CB639 /* AeselHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2D2E4941E446673BAF4F97 /* AeselHomeView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 13B505C3279E0F8DB5292B86 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; 22509229E839210D9200B4AA /* Aesel.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Aesel.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 228E709932A29DC953A73B71 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 47AE0766266534A8EE665542 /* Session */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Session; path = Resources/Session; sourceTree = SOURCE_ROOT; }; 505E152C400C5CE69463B39F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 72A337044339282DB88573A0 /* AeselPieceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselPieceView.swift; sourceTree = ""; }; + 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselScene.swift; sourceTree = ""; }; + AF88A30677AA0D7671B393F9 /* ac-easel-unifont.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ac-easel-unifont.ttf"; sourceTree = ""; }; B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHost.swift; sourceTree = ""; }; B7CE6904A010A2BEC894D13C /* AeselApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselApp.swift; sourceTree = ""; }; + BDD34AEBA3C87153FB573037 /* UNIFONT-COPYRIGHT.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "UNIFONT-COPYRIGHT.txt"; sourceTree = ""; }; + C1222F5DEDEE9240C904FFA6 /* UNIFONT-OFL-1.1.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "UNIFONT-OFL-1.1.txt"; sourceTree = ""; }; + CC2D2E4941E446673BAF4F97 /* AeselHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselHomeView.swift; sourceTree = ""; }; + DB0FC013DFC41F9D9094BE2A /* aesel.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = aesel.png; sourceTree = ""; }; + F26176753E43E445E2CF551D /* AeselModelPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselModelPicker.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -26,6 +46,10 @@ isa = PBXGroup; children = ( B7CE6904A010A2BEC894D13C /* AeselApp.swift */, + CC2D2E4941E446673BAF4F97 /* AeselHomeView.swift */, + F26176753E43E445E2CF551D /* AeselModelPicker.swift */, + 72A337044339282DB88573A0 /* AeselPieceView.swift */, + 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */, 505E152C400C5CE69463B39F /* ContentView.swift */, 13B505C3279E0F8DB5292B86 /* Session.swift */, B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */, @@ -36,6 +60,8 @@ 34716D2929B4074C97E662AE = { isa = PBXGroup; children = ( + 47AE0766266534A8EE665542 /* Session */, + CF2FDCFF84387B5BBA01E415 /* Resources */, 10821474428BA314BB316CD0 /* Sources */, AB4E40D4AB00182B8B99E0D0 /* Products */, ); @@ -49,6 +75,18 @@ name = Products; sourceTree = ""; }; + CF2FDCFF84387B5BBA01E415 /* Resources */ = { + isa = PBXGroup; + children = ( + AF88A30677AA0D7671B393F9 /* ac-easel-unifont.ttf */, + DB0FC013DFC41F9D9094BE2A /* aesel.png */, + 228E709932A29DC953A73B71 /* Assets.xcassets */, + BDD34AEBA3C87153FB573037 /* UNIFONT-COPYRIGHT.txt */, + C1222F5DEDEE9240C904FFA6 /* UNIFONT-OFL-1.1.txt */, + ); + path = Resources; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -57,6 +95,7 @@ buildConfigurationList = 828F9F6A9A08AA43646DA0CF /* Build configuration list for PBXNativeTarget "Aesel" */; buildPhases = ( 637239781D81E75E1E756D3E /* Sources */, + 9B16354BD258F58AE2915677 /* Resources */, ); buildRules = ( ); @@ -103,12 +142,32 @@ }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + 9B16354BD258F58AE2915677 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7AE3B1696F71BFDE82E8B72E /* Assets.xcassets in Resources */, + C48716DE150CDF3FE383298E /* Session in Resources */, + 45F9F1BEFDE1DA054142C04D /* UNIFONT-COPYRIGHT.txt in Resources */, + B637527F3CE86BDA9157F007 /* UNIFONT-OFL-1.1.txt in Resources */, + A2F701E804CFBFA1C1CC5088 /* ac-easel-unifont.ttf in Resources */, + 85656270F7033893DDF7CC12 /* aesel.png in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 637239781D81E75E1E756D3E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 2DB3100BC43BD21115A1BA37 /* AeselApp.swift in Sources */, + FBC8984584385405A83CB639 /* AeselHomeView.swift in Sources */, + C5323C1BB9D8D59F14A1FF74 /* AeselModelPicker.swift in Sources */, + 06DAB9B425E19C4E02F25A24 /* AeselPieceView.swift in Sources */, + B8236CE8844912F069905928 /* AeselScene.swift in Sources */, 1F53641C03321161A7665F3B /* ContentView.swift in Sources */, 3661DCFA19E2594688511C86 /* Session.swift in Sources */, 6E01FC52B2208E175FFC6985 /* SessionHost.swift in Sources */, @@ -186,7 +245,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.aesel; + PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.easel; PRODUCT_NAME = Aesel; SDKROOT = iphoneos; SUPPORTS_MACCATALYST = NO; @@ -269,7 +328,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.aesel; + PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.easel; PRODUCT_NAME = Aesel; SDKROOT = iphoneos; SUPPORTS_MACCATALYST = NO; diff --git a/apple/aesel/INTEGRATION.md b/apple/aesel/INTEGRATION.md new file mode 100644 index 000000000..099ae0c2d --- /dev/null +++ b/apple/aesel/INTEGRATION.md @@ -0,0 +1,164 @@ +# aesel on Mac and iPhone + +The iPhone app should feel like the same workspace: lowercase `aesel`, the same +pixel font and donkey, purple scene, account label, transcript, composer, and +live artwork preview. The layout adapts to touch, the keyboard, and safe areas. + +```mermaid +flowchart TD + Launch[Open aesel] --> Home[New media or Threads] + Home --> Workspace[aesel workspace] + Workspace --> Account{AC session available?} + Account -->|No| Login[Sign in with Aesthetic Computer] + Login --> Store[iOS Keychain] + Store --> Prompt[Prompt and transcript] + Account -->|Yes| Prompt + Workspace -->|Aesel button| Home + Prompt --> Inference[AC hosted inference] + Inference --> Source[Local piece source] + Source --> Draft[Unpublished draft preview] + Source --> Publish[Existing auto-publish flow] + Publish --> Public[User published piece URL] + Public --> Preview[Default embedded preview] + Public --> AC[Open in Aesthetic Computer] +``` + +## Identity and release + +| Item | Contract | +| --- | --- | +| Visible name | `aesel` in app titles, menus, errors and icon label; `Aesel` in the corner wordmark as requested | +| Code names | `Aesel…` types and `aesel…` UI APIs; old protocol/storage names remain compatible where needed | +| App Store record | Existing app `6812823093` | +| Mac and iOS bundle ID | `computer.aesthetic.easel`; retain this registered identity despite the display-name change | +| Mac review | Explain the temporary loopback OAuth listener; `network.server` is required by current login | +| iPhone icon | Donkey artwork from the desktop, supplied as an iOS AppIcon asset catalog | +| Universal purchase | Add iOS to the same app record; separate builds, metadata, screenshots, and platform reviews | + +Both platforms can be submitted in the same release window. Apple can approve +them separately. “Universal purchase” does not mean a single executable or a +guaranteed simultaneous approval. Use manual release if coordinated availability +is needed. + +## Authentication + +### Working independently + +The phone must sign in to its own AC account. A laptop's `~/.ac-token` is not a +release authentication mechanism. The implemented phone flow uses the +existing AC web sign-in inside an app-owned WebKit view, extracts the resulting +session only from the trusted AC main-frame origin, and stores the app's bearer +credential in Keychain. Cancellation, expiration, logout, and retry must return +to a usable signed-out state. Authentication scripts and tokens must never be +printed in logs or placed in URLs. + +A retained web session can make a later sign-in easier, but an expired access +token is not a valid saved session. Verify expiration/re-authentication on a +real device before release. + +### Integration with the Aesthetic Computer iOS app + +The existing AC iOS app is a WebKit client. Its AC cookies/local storage stay +inside its own app container. aesel cannot simply read them, and matching web +origins do not make two apps' WebKit stores shared. + +The planned durable integration is a shared native account component: + +1. Both apps adopt the same native sign-in contract (prefer browser-based PKCE + through `ASWebAuthenticationSession`, with registered callbacks). +2. Enable a common Keychain access group in both signed app targets and their + provisioning profiles. The two apps keep distinct bundle IDs; only aesel's + Mac/iOS pair share an App Store identity. +3. Store session material under an explicit versioned service/account key. + Validate issuer, audience and expiration; coordinate refresh ownership if a + rotating refresh token is shared so two apps cannot race its rotation. +4. aesel offers “Continue as @handle” when a usable shared account exists. + Fresh sign-in remains available when the AC app is absent. +5. Bridge the native account into each trusted first-party renderer; never + expose refresh tokens to generated pieces or arbitrary navigation. +6. Define local sign-out versus “sign out of AC apps” explicitly. Account + changes must clear the previous account's active inference context. + +This shared-account integration requires an update to **both** iOS apps. It is +not provided merely by adding an entitlement to aesel. Universal purchase also +does not share credentials with the separate Aesthetic Computer app. + +For opening a piece in AC, use its public HTTPS URL first. Universal Links can +later route those links into the AC app after configuring associated domains +and the site's association file. Never carry bearer tokens in those links. + +## Preview and publishing + +After sign-in, the default preview is the user’s current published piece URL, +not a generic `/blank` route. `/blank` is the AC Blank Laptop product page, not +an empty drawing surface. + +An unpublished draft should render from local source inside the embedded AC runtime before +it has a public URL. Show source/load/runtime failures in the preview instead +of leaving an unexplained black rectangle. Reload when the source revision +changes, and preserve the current draft during login, keyboard changes, or +navigation. + +The phone currently retains its existing automatic publication on source writes. +The public URL is a separate result: expose Open/Share/QR only when publication +has returned a valid URL. A draft route or stale revision must not masquerade +as a working public link. An external open uses the HTTPS AC URL, without +private session data. + +## Optional anonymous mode + +A signed-out visitor can view a published public piece or a local starter/draft +without borrowing an account. Keep this separate from authenticated creation: +publishing still requires a handle, and anonymous AI generation would require +an explicit backend quota, abuse controls and a billing decision. No anonymous +AI allowance is enabled by this change. + +## Implemented in this change + +- Native phone scene reuses the desktop font and donkey sprite sheet, with an + iOS icon asset catalog. +- The shared JavaScript session is bundled; normal launches do not require a + laptop server or its token. +- Phone AC sign-in uses a dedicated trusted WebKit login view and Keychain. +- Draft source reaches the embedded AC preview independently of publication. +- Shared credentials with the separate AC iOS app remain planned, not enabled. + +## Shipping gates + +- Real iPhone: sign in, cancel sign-in, sign out, and sign back in with the + intended account; verify token expiration/re-authentication. +- Generate a piece and see its pixels on-device, then edit it and see the next + revision. Exercise preview errors and recovery. +- Verify successful publication, open the resulting URL outside aesel, and confirm it + renders the same revision. +- Relaunch and recover the local draft, transcript, and valid account state. +- Use the app without the development Mac/server; bundle the shared session + runtime and require only production AC services for network work. +- Inspect the icon on the home screen and UI with the keyboard both open and + closed. Keep pixel art crisp and primary controls comfortably tappable. +- Verify the phone presents the same required transcript-sharing disclosure + and account-bound acceptance as the desktop before inference. +- Complete privacy/review metadata, screenshots, and platform-specific review + instructions before the iOS submission. + +## Sources + +- [Add platforms and universal purchase](https://developer.apple.com/help/app-store-connect/create-an-app-record/add-platforms) +- [App Review submissions](https://developer.apple.com/help/app-store-connect/manage-submissions-to-app-review/overview-of-submitting-for-review) +- [Shared Keychain access](https://developer.apple.com/documentation/security/sharing-access-to-keychain-items-among-a-collection-of-apps) +- Existing AC iOS client: `apple/aesthetic.computer/ContentView.swift` +- aesel iOS host: `apple/aesel/Sources/SessionHost.swift` +- Mac sign-in: `easel/src/ac-session.mjs` in `/Users/jas/ac-easel-media` + +## Home, identity and model visibility + +The top-left Aesel button returns to the desktop-style New media / Threads chooser +without replacing the live preview. History persists source, publication state, +transcript and agent conversation separately from account credentials. Piece is +the available mobile engine; Picture, Sound, Paper and Game Boy remain visibly +unavailable until their desktop toolchains have mobile or hosted implementations. +The account handle uses AC's saved per-character colors and alphabet palette. + +The app label remains aesel; the user requested capital A for the corner wordmark. +See [model research](MODEL-RESEARCH.md) for current remote routing, user-owned +Claude/Codex integration possibilities, model costs and the proposed evaluation. diff --git a/apple/aesel/Info.plist b/apple/aesel/Info.plist index 43a0cd528..a32cd41ad 100644 --- a/apple/aesel/Info.plist +++ b/apple/aesel/Info.plist @@ -9,15 +9,17 @@ CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleName - $(PRODUCT_NAME) + aesel CFBundleDisplayName - Aesel + aesel CFBundlePackageType APPL CFBundleShortVersionString 0.1.0 CFBundleVersion 1 + UIAppFonts + ac-easel-unifont.ttf UILaunchScreen UIColorName @@ -27,18 +29,5 @@ UIInterfaceOrientationPortrait - - AeselHostURL - http://localhost:8770/easel/phone/host.html - - NSAppTransportSecurity - - NSAllowsLocalNetworking - - - NSLocalNetworkUsageDescription - Aesel loads its session from a development server on your network. diff --git a/apple/aesel/MODEL-RESEARCH.md b/apple/aesel/MODEL-RESEARCH.md new file mode 100644 index 000000000..0e4b17ceb --- /dev/null +++ b/apple/aesel/MODEL-RESEARCH.md @@ -0,0 +1,101 @@ +# aesel mobile models + +Researched 2026-09-17. User selected Luna as the default and Opus as an explicit premium option. The wider comparison below remains a proposal. + +## Current service and visibility + +`easel/src/ac-server.mjs` now exports `DEFAULT_AC_MODEL = "openai/gpt-5.6-luna"`. +The earlier GLM-4.6 default remains available explicitly. Opus uses +`anthropic/claude-opus-5`; both new IDs were confirmed in OpenRouter’s live model catalog. +New phone threads use Luna; existing threads retain their saved model. +The phone constructs that engine; AC's `/api/easel-inference` authenticates the +AC account, enforces its model allowlist/budget, and relays OpenRouter's Messages +stream. It does not execute the installed Claude or Codex phone app. + +Show the requested model before inference, then the upstream-reported model when +`message_start.message.model` arrives. Record that distinction per thread; older +threads cannot retroactively prove which model served them. An upstream model ID +is provider-reported metadata, not independent proof of model weights. A later +server change should include model, provider, request ID, fallback reason and +usage in a compact response receipt, without saving private prompts in analytics. + +## Using the user's installed apps + +| Route | Documented capability | aesel implication | +| --- | --- | --- | +| Claude iOS App Intent | Ask Claude can process text and return responses through Shortcuts; it uses the app's selected model and the user's Claude usage allowance. | A viable optional user-installed Shortcut: aesel exports a bounded piece request, Ask Claude produces source, aesel imports and previews it. Prototype cancellation, callback and result size before promising parity. | +| Claude Code mobile link | `claude://code/new?q=...` opens a prefilled composer; repo/branch parameters are supported. | Useful explicit handoff to Claude Code. A link alone supplies no streamed edits or automatic result callback into aesel. | +| Codex/ChatGPT mobile | Remote controls work running on a paired desktop host. | Useful companion workflow, but no documented general iOS app-to-app inference endpoint was found. | +| Codex app server | Public protocol supports rich clients, streamed events, history, approvals, and ChatGPT-managed or API-key auth. | A paired aesel desktop/host could run Codex and stream work to the phone. Keep user auth on that host. The installed phone app alone is insufficient. | +| AC-hosted API | AC pays the API provider and applies account quotas. | Best default for a self-contained aesel experience with no extra installed app or computer. | + +Sources: [Claude Shortcuts](https://support.claude.com/en/articles/10263469-use-claude-app-intents-shortcuts-and-widgets-on-ios), +[Claude mobile links](https://support.claude.com/en/articles/14898120-open-the-claude-mobile-app-with-a-link), +[Apple Shortcut callbacks](https://support.apple.com/guide/shortcuts/use-x-callback-url-apdcd7f20a6f/ios), +[ChatGPT Remote](https://learn.chatgpt.com/docs/remote-connections), +[Codex app server](https://learn.chatgpt.com/docs/app-server). + +Proposed Shortcut return path: register an aesel import App Intent, carry a +one-use request identifier tied to the active thread, accept source as data, and +preview before publication. The app should not extract another app's credentials. +This is a user-run automation, not a replacement for the hosted streaming tool loop. + +## Model recommendation + +Start quality evaluation with **Claude Sonnet 5 as the normal hosted candidate**, +and **GPT-5.6 Luna as a low-cost candidate**. Include **GPT-5.6 Terra** as a middle +comparison. This is a workload-specific recommendation to test, not a measured +ranking: none has yet run the same aesel evaluation set here. + +| Model | Proposed role | Standard input / output per million tokens | Example 10K input + 2K output | +| --- | --- | --- | --- | +| GPT-5.6 Luna | inexpensive first pass, only if rendered-result quality holds | $0.20 / $1.20 | $0.0044 | +| GPT-5.6 Terra | balanced coding candidate | $2 / $12 | $0.044 | +| Claude Sonnet 5 | normal quality candidate, near existing Messages integration | $2 / $10 | $0.040 | + +These are direct-provider, uncached, short-context rates; routing fees, reasoning +output, repeated tool calls, retries and longer histories change actual cost. +Sources: [OpenAI pricing](https://developers.openai.com/api/docs/pricing), +[Luna](https://developers.openai.com/api/docs/models/gpt-5.6-luna), +[Terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra), +[Sonnet 5 specifications](https://platform.claude.com/docs/en/models/sonnet-5/overview). + +A phone does not require a small hosted model: computation happens remotely. +Optimize time until a working preview and total cost per successful piece. +A cheap failed generation plus several repairs can cost more than one good answer. +Reserve heavyweight reasoning for explicit difficult repairs, rather than making +every small visual adjustment wait for it. + +## Evaluation and rollout + +Use a fixed set of 20 AC tasks: animated dots, touch drawing, resize/orientation, +color changes, sound with touch unlock, published-piece edits, and repairing bad +API calls. Verify syntax, runtime errors, visible pixels, interactions, persistence, +and source/published parity. Measure first-working-preview latency, success within +two attempts, tokens and dollar cost. Include GLM-4.6 as the current baseline. + +Before changing defaults, verify actual provider/account availability and streaming +compatibility, update the server allowlist, reserve quota by model cost instead of +only raw tokens, and display any fallback. OpenAI direct integration requires a +Responses adapter; an OpenRouter model route must be verified rather than guessed. +Do not spend on an automated comparison or change production merely to answer +this research question. + +## Selected rollout + +Luna is the requested default. Opus 5 is a user-selected premium option, never a +silent fallback. Its provider list price is $5/M input and $25/M output, so the +same 10K-input/2K-output example is $0.10 before extra tool rounds/reasoning. +The iPhone model menu and `/model luna` / `/model opus` preserve conversation +and save the choice per thread. Switching is disabled while work is running. +[Opus specifications](https://platform.claude.com/docs/en/models/opus-5/overview). + +The existing energy meter is a rough model-size estimate, not a dollar meter or +a provider measurement. Luna and Opus sizes are explicitly marked unknown/guessed; +do not use that estimate to charge credits. Paid usage needs provider token/cost +receipts and a reservation ledger; see [credits exploration](CREDITS-EXPLORATION.md). + +Deployment verification: main commit `95f1c58231` was deployed to lith. Bounded +production `/api/easel-inference` checks for Luna and Opus both returned HTTP 200, +the expected upstream-reported model IDs, and `OK`. These are connectivity/stream +checks, not a comparative evaluation of generated AC pieces. diff --git a/apple/aesel/README.md b/apple/aesel/README.md index ff0ed7473..19d74bca0 100644 --- a/apple/aesel/README.md +++ b/apple/aesel/README.md @@ -1,93 +1,65 @@ -# Aesel for iPhone +# aesel for iPhone -A SwiftUI app. The renderer is native; the thinking is not. - -## The division, and why - -Oskiewar's Metal view says the house rule in its own header: *"the engine speaks -the same triangle stream to every host it has ever had — canvas on the web, D3D -on Xbox, Metal here."* One engine, a narrow contract, a renderer per platform. - -Aesel already had that contract before it needed a second renderer. `AcServer` -emits six notification kinds — `turn/started`, `turn/progress`, -`item/agentMessage/delta`, `item/started`, `item/completed`, `turn/completed` — -and the desktop TUI is only one thing that draws them. `Session.swift` is -another. That is the whole port. - -So the agent loop is not rewritten here. `easel/src/ac-server.mjs` and -`easel/src/publish.mjs` run unmodified, inside a `WKWebView` that is never -shown, and SwiftUI draws every pixel the user sees. - -**Why a hidden webview and not JavaScriptCore.** Oskiewar's Mac app hands JSC a -bundled script and drives Metal, and pays for it: JSC is bare ECMAScript, and -`main.swift:569` notes it gets no JIT in a third-party app. That trade is right -for a game, because a webview cannot draw one. Aesel's renderer is text, and its -piece preview has to be a `WKWebView` on every platform regardless — so the -thing Oskiewar was avoiding is something Aesel needs anyway. Hosting the session -in a second, invisible one costs an object nobody sees and buys `fetch`, -streaming `response.body.getReader()`, `TextDecoder` and ES modules, none of -which JSC has and all of which the shared bridge is written against. - -## Running it +Native SwiftUI chrome uses the desktop palette, typeface, mascot scene and slash +commands. A hidden WKWebView runs the same JavaScript AC agent as the desktop; +a separate WKWebView opens the signed-in user's published piece. Unpublished +edits can preview through AC's JavaScript `dropped:piece` interface. Fresh +sessions show the native canvas placeholder until a piece is available. The runtime and model still need an internet connection. ```sh ./run.sh # simulator -./run.sh device # the iPhone plugged into this Mac -./run.sh device "make a bouncing ball" +./run.sh device # USB-connected iPhone ``` -The session is served from this Mac by `easel/phone/serve.mjs`, not bundled, so -a JavaScript edit reaches the phone on relaunch with no rebuild. `run.sh` starts -that server if it is not already up and stamps the current LAN address into -`Info.plist` for device builds — the address is discovered rather than committed -because it changes (this Mac moved from a phone hotspot to Wi-Fi in the middle -of the first build). - -`AESEL_ASK` runs one prompt on launch. It waits for the session to report -`restored` rather than sleeping, so it cannot fire before there is a piece. - -## Two things that bite - -**Readiness is not `didFinish`.** The navigation delegate fires when the -document has loaded, which is before the module script has evaluated, so -`globalThis.aesel` may not exist yet — calling into it there raced and threw -`Can't find variable: aesel`. The page posts a `ready` event after installing -that global, and `SessionHost` treats *that* as the signal. - -**Forbidden headers.** `publish.mjs` sets `User-Agent`, correctly, for Node. -Chrome silently drops it; WebKit leaks it into the CORS preflight, and -`/presigned-upload-url` allows only `Content-Type, Authorization, -X-Requested-With`. The preflight is refused and the whole publish returns the -uninformative "Load failed". `session.mjs` strips forbidden headers for the -publish path rather than changing the shared file, which still wants to identify -itself on the desktop. - -## What is not done - -* **Sign-in is a development shortcut.** The host page asks the laptop for its - own `~/.ac-token`. The real flow is `ASWebAuthenticationSession` against the - same Auth0 client the desktop uses (`hi.aesthetic.computer`, client - `LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt`), which needs a callback URL registered in - Auth0 first — that is an account change, not code. -* **The session is not bundled.** Shipping means copying `easel/src`, - `easel/context` and `easel/phone` into the app and loading them over a custom - scheme, the way Oskiewar's `BundleSchemeHandler` serves its offline fallback. -* **It targets this branch's bridge, not `main`'s.** Main has six tools instead - of one, plus runtime feedback and frame capture, and needs `node:fs/promises`, - `node:util`, a PNG encoder and a *synchronous* `createHash('sha256')` that - `SubtleCrypto` cannot provide. Tracking it matters for more than tidiness — - see below. -* **No app icon, no launch asset, no ASC record.** - -## The model is the weak part, and there is a reason - -Measured on the first three turns: every failure was the model writing a piece -that throws, not the client. It wrote `wipe(color, x, y, w, h)` once and -`ink(...)` without destructuring `ink` another time — `paint` threw after the -first call, so the preview showed a solid colour and looked plausible. - -Worse, it then rewrote the same broken piece twelve times and hit the bridge's -round bound, because **nothing told it the piece was erroring.** `main` has -`runtime-feedback.mjs` and an `ac_preview` tool that close exactly that loop. -That is the strongest argument for tracking main, and it is a correctness -argument rather than a housekeeping one. +The script copies `easel/{src,context,phone}` into the app, generates the Xcode +project and builds with two compiler jobs. It starts no local HTTP server and +shares no laptop credentials. `./bundle-session.sh` refreshes these resources +before a manual Xcode build. Keep the iPhone unlocked for installation/launch; +`DEVICE=` selects a device. `AESEL_HOST` is an optional explicit development +host override; normal launches use the bundled custom scheme with no listener. + +## AC sign-in + +`/login` opens aesthetic.computer's existing login flow inside the app. A +main-frame bridge accepts tokens only from HTTPS aesthetic.computer, while the +login sheet is open, and only from that sheet's WKWebView. The access token goes +into device-only Keychain storage; draft source goes into Documents. Old +prototype tokens borrowed from the laptop are discarded on upgrade. `/logout` +clears aesel's token and web login data. + +This signs into the same AC account. It does **not** share another iOS app's +cookies or Keychain. A retained web login can renew the token when `/login` is +opened again; continuous native refresh and browser SSO are future integration +work. A new native OAuth callback must be registered before adopting +ASWebAuthenticationSession. No unregistered callback is assumed here. + +The account needs an AC handle to publish; missing handles produce a linkable +instruction. Generated writes retain the existing automatic publishing behavior; +`/publish` retries explicitly, and `/open` is available after publication. +Draft preview is separate from the public sharing URL. + +## Release gates + +The bundle identity remains `computer.aesthetic.easel`, matching macOS's existing +App Store record; the visible name is `aesel`. The icon is opaque at 1024 pixels. +An iOS platform can be added to that record; phone functionality, sign-in, +preview, model errors and background/foreground behavior need device acceptance +before uploading. App Review approval is not guaranteed by a successful build. + +The JS bridge signals `ready` after module evaluation, not on `didFinish`. +Publishing strips Node's forbidden `User-Agent` header for WebKit. Bundle guide +text is injected at startup so it does not depend on custom-scheme `fetch`. + +## Threads and media + +The native home screen resumes local threads or starts a Piece. Each saved +thread retains its source, transcript, publication owner and AC model conversation; +account tokens are excluded. The previous single draft migrates on first launch. +Thread switching waits for an interrupted turn and any upload before changing +source. Returning home leaves the current preview alive. + +Picture, Sound, Paper and Game Boy are desktop-only until their actual render +and tool backends are ported; their chooser rows do not create pretend sessions. +The model label distinguishes the requested model from an identifier reported by +the inference stream. Account character colors come from AC's saved palette, +including the `@` character, with the same fallback palette as desktop. diff --git a/apple/aesel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apple/aesel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..ae905a0e5 --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "aesel.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apple/aesel/Resources/Assets.xcassets/AppIcon.appiconset/aesel.png b/apple/aesel/Resources/Assets.xcassets/AppIcon.appiconset/aesel.png new file mode 100644 index 0000000000000000000000000000000000000000..ea053cd70af08b81cddfd8c60387439dc83250a1 GIT binary patch literal 13883 zcmeAS@N?(olHy`uVBq!ia0y~yU||4Z4kiW$h7a|7t}rk#Fct^7J29*~C-ahlfx#j* z!ZXd+mqCkxfq{d8u{{&4fsuiML5cwcm=-X>L|7OWFeBI?$q%9@)EO8|H+s4_hEy=V zy}-!Jz`$^5!FT^$KJyNT!~+eC%xpXo1_`5#(LfkY38T4SG#8BKg3(+snhQpA!DucR z%>|>mU^Ewu=7P~&Fq#WSbHQjX7|jKvxnMLGjOK#TTripoMsvYvE*Q-Pqq$%-7mVhD z(OfW^3r2InXf7Dd1*5rOG#8BKg3(+snhQpA!DucR%>|>mU^Ewu=7P~&Fq#WSbHQjX n7|jKvxnMLG5RnTS&o?r%U0`5j@%UE@+QjAQ>gTe~DWM4foRt2$ literal 0 HcmV?d00001 diff --git a/apple/aesel/Resources/Assets.xcassets/Contents.json b/apple/aesel/Resources/Assets.xcassets/Contents.json new file mode 100644 index 000000000..74d6a722c --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apple/aesel/Resources/UNIFONT-COPYRIGHT.txt b/apple/aesel/Resources/UNIFONT-COPYRIGHT.txt new file mode 100644 index 000000000..2c83410c1 --- /dev/null +++ b/apple/aesel/Resources/UNIFONT-COPYRIGHT.txt @@ -0,0 +1,5 @@ +Copyright (C) 1998-2025 Roman Czyborra, Paul Hardy, Qianqian Fang, Andrew Miller, Johnnie Weaver, David Corbett, Nils Moskopp, Rebecca Bettencourt, Minseo Lee, Ho-Seok Ee, et al. License: SIL Open Font License version 1.1 and GPLv2+: GNU GPL version 2 or later with the GNU Font Embedding Exception. + +This terminal subset and its conversion script are distributed under SIL OFL 1.1. +The surrounding Easel application has its own separate license. +Modified by Aesthetic Computer, 2026-09-15: terminal subset and exact square outlines. diff --git a/apple/aesel/Resources/UNIFONT-OFL-1.1.txt b/apple/aesel/Resources/UNIFONT-OFL-1.1.txt new file mode 100644 index 000000000..84a393463 --- /dev/null +++ b/apple/aesel/Resources/UNIFONT-OFL-1.1.txt @@ -0,0 +1,90 @@ +The SIL Open Font License version 1.1 is copied below, and is also +available with a FAQ at http://scripts.sil.org/OFL. + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apple/aesel/Resources/ac-easel-unifont.ttf b/apple/aesel/Resources/ac-easel-unifont.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1f98eb2e3eba2f4cd2c76ad7b51a81d949127ba5 GIT binary patch literal 736240 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%0APf=*(IAYE4Kf3yAEXAx28}y_Mv0-_22jQ) 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 YUGD^~2b`ec;pFB9zB`if|9=Js0EC=n&Hw-a literal 0 HcmV?d00001 diff --git a/apple/aesel/Resources/aesel.png b/apple/aesel/Resources/aesel.png new file mode 100644 index 0000000000000000000000000000000000000000..c390a640d3b3fb8b0d5160b85a50dbc758bafc7e GIT binary patch literal 53274 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ⅆcf@^ssy5~Fk?o?E*4`)lbPj&G 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 literal 0 HcmV?d00001 diff --git a/apple/aesel/Sources/AeselApp.swift b/apple/aesel/Sources/AeselApp.swift index 41200993b..1d4007d3b 100644 --- a/apple/aesel/Sources/AeselApp.swift +++ b/apple/aesel/Sources/AeselApp.swift @@ -5,6 +5,7 @@ struct AeselApp: App { @State private var session = Session() @State private var host: SessionHost @State private var started = false + @Environment(\.scenePhase) private var scenePhase init() { let session = Session() @@ -16,6 +17,9 @@ struct AeselApp: App { WindowGroup { ContentView(session: session, host: host) .preferredColorScheme(.dark) + .onChange(of: scenePhase) { _, phase in + if phase != .active { host.save() } + } .task { guard !started else { return } started = true @@ -24,24 +28,13 @@ struct AeselApp: App { } } - /// Where the shared session is served from. While iterating this is the - /// laptop running `node easel/phone/serve.mjs --token`, so a JS edit - /// reaches the phone on reload with no rebuild. `AeselHostURL` in - /// Info.plist is the knob; a shipped build points at a bundled copy. - /// Order matters: the environment wins so `run.sh` can point a device at - /// whatever address the laptop has today without editing a tracked file. - /// A LAN address is not stable — this machine moved from a phone hotspot - /// to a Wi-Fi network in the middle of building this, and a baked-in IP - /// would have shipped broken. + /// Release sessions load immutable app resources. Developers can explicitly + /// opt into a hosted session through the launch environment. static var hostURL: URL { if let text = ProcessInfo.processInfo.environment["AESEL_HOST"], let url = URL(string: text) { return url } - if let text = Bundle.main.object(forInfoDictionaryKey: "AeselHostURL") as? String, - let url = URL(string: text) { - return url - } - return URL(string: "http://localhost:8770/easel/phone/host.html")! + return URL(string: "aesel-bundle://app/easel/phone/host.html")! } } diff --git a/apple/aesel/Sources/AeselHomeView.swift b/apple/aesel/Sources/AeselHomeView.swift new file mode 100644 index 000000000..c3f58a300 --- /dev/null +++ b/apple/aesel/Sources/AeselHomeView.swift @@ -0,0 +1,124 @@ +import SwiftUI + +/// The desktop launch chooser, adapted for touch: New media and saved Threads. +struct AeselHomeView: View { + let session: Session + let host: SessionHost + let resume: () -> Void + @State private var tab = 0 + + private let media: [(id: String, name: String)] = [ + ("piece", "Piece"), ("picture", "Picture"), ("sound", "Sound"), + ("paper", "Paper"), ("gameboy", "Game Boy") + ] + + var body: some View { + VStack(spacing: 0) { + HStack { + if !session.route.isEmpty { + Button("/resume", action: resume).foregroundStyle(Paint.dim) + } + Spacer() + if session.signedIn { + AeselHandle(handle: session.handle, colors: session.handleColors) + } else { + Button("/login") { host.signIn() }.foregroundStyle(Paint.dim) + } + } + .font(Paint.font(20)) + .padding(18) + + Spacer(minLength: 24) + AeselWordmark().scaleEffect(1.5).padding(.bottom, 36) + + VStack(alignment: .leading, spacing: 22) { + HStack(spacing: 18) { + tabButton("New media", index: 0) + tabButton("Threads", index: 1) + } + if tab == 0 { + VStack(spacing: 3) { + ForEach(media, id: \.id) { medium in + Button { + host.newSession(medium: medium.id) + resume() + } label: { + HStack(spacing: 12) { + Text(medium.id == "piece" ? "›" : " ") + Text(medium.name) + Spacer(minLength: 0) + if medium.id != "piece" { + Text("desktop").font(Paint.font(17)).foregroundStyle(Paint.dim) + } + } + .padding(.horizontal, 12) + .frame(height: 48) + .foregroundStyle(medium.id == "piece" ? Paint.ink : Paint.dim) + .background(medium.id == "piece" ? Color(rgb: 0xc81e64) : .clear) + } + .disabled(medium.id != "piece" || session.busy) + .accessibilityLabel(medium.id == "piece" ? "New piece" : medium.name + ", available on desktop") + } + } + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + if session.history.isEmpty { + Text("No saved threads").foregroundStyle(Paint.dim).padding(.vertical, 12) + } + ForEach(session.history) { thread in + Button { + host.resumeSession(id: thread.id) + resume() + } label: { + VStack(alignment: .leading, spacing: 5) { + Text("› " + thread.title) + .lineLimit(2) + .foregroundStyle(Paint.ink) + Text(thread.medium + " · " + date(thread.updatedAt)) + .font(Paint.font(17)) + .foregroundStyle(Paint.dim) + if !thread.route.isEmpty { + Text(thread.route).font(Paint.font(17)).foregroundStyle(Paint.dim).lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(thread.id == session.currentSessionID ? Paint.deep.opacity(0.65) : .clear) + } + .disabled(session.busy) + } + } + } + .frame(height: 252) + } + } + .font(Paint.font(22)) + .frame(maxWidth: 380) + .padding(.horizontal, 24) + + Spacer(minLength: 20) + AeselDonkey(busy: session.busy, failed: session.health == .failed) + .padding(.bottom, 10) + Rectangle().fill(Color(rgb: 0x9e7548)).frame(height: 1) + AeselWood().frame(height: 40).ignoresSafeArea(edges: .bottom) + } + .buttonStyle(.plain) + .foregroundStyle(Paint.ink) + .background { AeselCloth().ignoresSafeArea() } + } + + private func tabButton(_ label: String, index: Int) -> some View { + Button { tab = index } label: { + Text(label) + .padding(.horizontal, 8).padding(.vertical, 7) + .foregroundStyle(tab == index ? Paint.ink : Paint.dim) + .background(tab == index ? Color(rgb: 0xc81e64) : .clear) + } + .accessibilityAddTraits(tab == index ? [.isSelected] : []) + } + + private func date(_ text: String) -> String { + String(text.prefix(16)).replacingOccurrences(of: "T", with: " ") + } +} diff --git a/apple/aesel/Sources/AeselModelPicker.swift b/apple/aesel/Sources/AeselModelPicker.swift new file mode 100644 index 000000000..a7675b047 --- /dev/null +++ b/apple/aesel/Sources/AeselModelPicker.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// Select the saved thread's model; the session owns validation and persistence. +struct AeselModelPicker: View { + let session: Session + let host: SessionHost + + var body: some View { + Menu { + ForEach(session.modelChoices) { choice in + Button { + host.setModel(model: choice.id) + } label: { + Label(choice.title + (choice.premium ? " · premium" : "") + "\n" + choice.id, + systemImage: choice.id == session.model ? "checkmark" : choice.premium ? "sparkles" : "circle") + } + } + } label: { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text(session.reportedModel.isEmpty ? session.model + " · requested" : session.reportedModel) + .lineLimit(2) + Text("▾") + } + .font(Paint.font(16)) + .foregroundStyle(Paint.dim) + } + .disabled(session.busy || session.modelChoices.isEmpty) + .accessibilityLabel("Choose model") + .accessibilityValue(session.model) + } +} diff --git a/apple/aesel/Sources/AeselPieceView.swift b/apple/aesel/Sources/AeselPieceView.swift new file mode 100644 index 000000000..792ed29a4 --- /dev/null +++ b/apple/aesel/Sources/AeselPieceView.swift @@ -0,0 +1,124 @@ +import SwiftUI +import WebKit + +/// Preview the current draft through AC's existing dropped-JavaScript path. +/// It does not depend on a successful public upload or a cached handle route. +struct PieceView: View { + let url: URL + let source: String + @State private var failure: String? + @State private var attempt = 0 + var body: some View { + ZStack { + PieceWebView(url: url, source: source, failure: $failure).id(attempt) + .onChange(of: url) { _, _ in failure = nil } + .onChange(of: source) { _, _ in failure = nil } + if let failure { + VStack(spacing: 10) { + Text("Preview could not load").font(Paint.font(20)) + Text(failure).font(Paint.font(16)).multilineTextAlignment(.center) + Button("/retry") { self.failure = nil; attempt += 1 } + .font(Paint.font(18)).foregroundStyle(Paint.you).buttonStyle(.plain) + }.padding().frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Paint.deep) + } + } + } +} + +private struct PieceWebView: UIViewRepresentable { + let url: URL + let source: String + + @Binding var failure: String? + final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var failure: Binding + init(failure: Binding) { self.failure = failure } + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.frameInfo.isMainFrame, let text = message.body as? String else { return } + failure.wrappedValue = text + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + if (error as NSError).code != NSURLErrorCancelled { failure.wrappedValue = error.localizedDescription } + } + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + if (error as NSError).code != NSURLErrorCancelled { failure.wrappedValue = error.localizedDescription } + } + var source = "" + var requestedURL: URL? + func update(_ view: WKWebView) { + guard let data = try? JSONSerialization.data(withJSONObject: [source]), + let json = String(data: data, encoding: .utf8) else { return } + view.evaluateJavaScript("window.__aeselSource = \(json)[0]; window.__aeselRender?.();") { [weak self] _, error in + if let error { self?.failure.wrappedValue = error.localizedDescription } + } + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + update(webView) + } + } + + func makeCoordinator() -> Coordinator { Coordinator(failure: $failure) } + + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.userContentController.add(context.coordinator, name: "previewFailure") + configuration.allowsInlineMediaPlayback = true + configuration.mediaTypesRequiringUserActionForPlayback = [] + configuration.userContentController.addUserScript(WKUserScript(source: """ + (() => { + let ready = false, rendered = '', failed = false; + const reportFailure = message => { + if (failed) return; + failed = true; + window.webkit.messageHandlers.previewFailure.postMessage(String(message).slice(0, 500)); + }; + window.addEventListener('error', event => { + if (event.message) reportFailure(event.message); + }); + window.addEventListener('unhandledrejection', event => { + reportFailure(event.reason?.message || 'The preview encountered an unexpected error.'); + }); + setTimeout(() => { + if (!ready) reportFailure('The AC runtime did not become ready. Check your internet connection and retry.'); + }, 45000); + window.__aeselRender = () => { + const source = window.__aeselSource; + if (!ready || !window.acSEND || !source || source === rendered) return; + rendered = source; + window.acSEND({type: 'dropped:piece', content: {name: 'aesel-preview', source, isKidLisp: false}}); + }; + window.addEventListener('message', event => { + if (!ready && event.data?.type === 'ready') { + ready = true; + window.__aeselRender(); + } + }); + const poll = setInterval(() => { + if (window.preloaded && window.acSEND) { + ready = true; + window.__aeselRender(); + clearInterval(poll); + } + }, 250); + setTimeout(() => clearInterval(poll), 45000); + })(); + """, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = context.coordinator + view.scrollView.isScrollEnabled = false + view.scrollView.contentInsetAdjustmentBehavior = .never + view.isOpaque = false + view.backgroundColor = .clear + view.isMultipleTouchEnabled = true + return view + } + + func updateUIView(_ view: WKWebView, context: Context) { + context.coordinator.source = url.path.hasPrefix("/@") ? "" : source + if context.coordinator.requestedURL != url { + context.coordinator.requestedURL = url + view.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)) + } else { context.coordinator.update(view) } + } +} diff --git a/apple/aesel/Sources/AeselScene.swift b/apple/aesel/Sources/AeselScene.swift new file mode 100644 index 000000000..becb9f732 --- /dev/null +++ b/apple/aesel/Sources/AeselScene.swift @@ -0,0 +1,129 @@ +import SwiftUI +import UIKit + +// The desktop's bitmap font, sprite sheet and palette, shared without redraws. +enum Paint { + static let bg = Color(rgb: 0x463264) + static let deep = Color(rgb: 0x241d35) + static let ink = Color.white + static let dim = Color(rgb: 0xb394ce) + static let rule = Color(rgb: 0x9b72b8) + static let you = Color(rgb: 0xff0088) + static let ac = Color(rgb: 0xff64ff) + static let edit = Color(rgb: 0x77ff55) + static let bad = Color(rgb: 0xff7777) + static func font(_ size: CGFloat = 20) -> Font { + .custom("ACEaselUnifont-Regular", size: size, relativeTo: .body) + } +} + +extension Color { + init(rgb: UInt32) { + self.init(red: Double((rgb >> 16) & 255) / 255, + green: Double((rgb >> 8) & 255) / 255, + blue: Double(rgb & 255) / 255) + } +} + +struct AeselCloth: View { + var body: some View { + Canvas { context, size in + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(Paint.bg)) + for y in stride(from: CGFloat(0), to: size.height, by: 8) { + context.fill(Path(CGRect(x: 0, y: y, width: size.width, height: 2)), with: .color(.white.opacity(0.016))) + } + for x in stride(from: CGFloat(0), to: size.width, by: 8) { + context.fill(Path(CGRect(x: x, y: 0, width: 2, height: size.height)), with: .color(.black.opacity(0.043))) + } + } + .accessibilityHidden(true) + } +} + +struct AeselWood: View { + var body: some View { + Canvas { context, size in + let boards: [UInt32] = [0x453126, 0x50382a, 0x59402e] + for y in stride(from: CGFloat(0), to: size.height, by: 32) { + context.fill(Path(CGRect(x: 0, y: y, width: size.width, height: 32)), with: .color(Color(rgb: boards[Int(y / 32) % 3]))) + for row in 0..<7 { + for x in stride(from: CGFloat(0), to: size.width, by: 4) { + let wave = (sin((Double(x) + Double(row * 19)) / 42) * 2).rounded() * 2 + let rect = CGRect(x: x, y: y + CGFloat(3 + row * 4) + wave, width: 4, height: row % 3 == 0 ? 2 : 1) + context.fill(Path(rect), with: .color(row % 2 == 0 ? .black.opacity(0.15) : Color(rgb: 0xac7e45).opacity(0.15))) + } + } + context.fill(Path(CGRect(x: 0, y: y + 31, width: size.width, height: 1)), with: .color(Color(rgb: 0x241e18))) + } + } + .accessibilityHidden(true) + } +} + +struct AeselWordmark: View { + var text = "aesel" + private let colors: [Color] = [.orange, Paint.ac, Paint.edit, Paint.dim, Paint.you] + var body: some View { + HStack(spacing: 0) { + ForEach(Array(text.enumerated()), id: \.offset) { index, letter in + Text(String(letter)).foregroundStyle(colors[index % colors.count]) + } + } + .font(Paint.font(28)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("aesel") + } +} + +struct AeselDonkey: View { + var busy: Bool + var failed: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + private static let frames: [UIImage] = { + guard let sheet = UIImage(named: "aesel")?.cgImage else { return [] } + return (0..<16).compactMap { index in + sheet.cropping(to: CGRect(x: (index % 4) * 64, y: (index / 4) * 64, width: 64, height: 64)).map { UIImage(cgImage: $0) } + } + }() + + var body: some View { + TimelineView(.animation(minimumInterval: busy ? 0.18 : 0.18, paused: reduceMotion)) { timeline in + let elapsed = timeline.date.timeIntervalSinceReferenceDate + let index = reduceMotion ? 0 : busy ? 8 + Int(elapsed / 0.18) % 4 : failed ? 2 + Int(elapsed / 0.5) % 2 : (elapsed.truncatingRemainder(dividingBy: 1.58) < 1.4 ? 0 : 1) + if Self.frames.indices.contains(index) { + Image(uiImage: Self.frames[index]) + .resizable() + .interpolation(.none) + .scaledToFit() + .scaleEffect(x: -1, y: 1) + } + } + .frame(width: 112, height: 112) + .accessibilityLabel(busy ? "aesel is working" : "aesel the donkey") + } +} + +struct AeselHandle: View { + let handle: String + let colors: [String] + + private var label: AttributedString { + var result = AttributedString() + for (index, character) in ("@" + handle).enumerated() { + var letter = AttributedString(String(character)) + if colors.indices.contains(index), colors[index].count == 7, + let rgb = UInt32(colors[index].dropFirst(), radix: 16) { + letter.foregroundColor = Color(rgb: rgb) + } else { + letter.foregroundColor = Paint.ac + } + result += letter + } + return result + } + + var body: some View { + Text(label).lineLimit(1).truncationMode(.tail) + .accessibilityLabel("@" + handle) + } +} diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index 4f703d0df..6916c7cfc 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -1,198 +1,251 @@ import SwiftUI import WebKit -/// Aesel's palette, taken from the desktop app's window background so the two -/// clients look like one product. -enum Paint { - static let bg = Color(red: 0x46 / 255, green: 0x32 / 255, blue: 0x64 / 255) - static let deep = Color(red: 0x24 / 255, green: 0x1d / 255, blue: 0x35 / 255) - static let ink = Color(red: 0xef / 255, green: 0xe9 / 255, blue: 0xff / 255) - static let dim = Color(red: 0xb3 / 255, green: 0xa4 / 255, blue: 0xcf / 255) - static let rule = Color(red: 0x5e / 255, green: 0x48 / 255, blue: 0x81 / 255) - static let you = Color(red: 0xff / 255, green: 0x4d / 255, blue: 0x88 / 255) - static let ac = Color(red: 0xb5 / 255, green: 0x8c / 255, blue: 0xff / 255) - static let edit = Color(red: 0x6e / 255, green: 0xe7 / 255, blue: 0xa8 / 255) - static let bad = Color(red: 0xff / 255, green: 0x7a / 255, blue: 0x7a / 255) -} - struct ContentView: View { let session: Session let host: SessionHost - @State private var draft = "" + @State private var showHome = true + @State private var showHelp = false + @State private var expandedPreview = false @FocusState private var writing: Bool + @Environment(\.openURL) private var openURL var body: some View { VStack(spacing: 0) { - bar - stage - transcript - composer + if !expandedPreview { title } + if session.previewURL != nil { preview } + if !expandedPreview { + transcript + shelf + } } - .background(Paint.bg) + .overlay { + if showHome { + AeselHomeView(session: session, host: host) { showHome = false } + } + } + .font(Paint.font()) .foregroundStyle(Paint.ink) - // The off-screen session host. Zero-sized and hidden, but in the - // hierarchy so WebKit keeps it running. + .background { AeselCloth().ignoresSafeArea() } .background(HostCarrier(host: host).frame(width: 0, height: 0)) - .overlay { if let fatal = session.fatal { failure(fatal) } } + .onChange(of: session.currentSessionID) { draft = "" } + .sheet(isPresented: $showHelp) { help } + .sheet(isPresented: Binding(get: { session.showSignIn }, set: { session.showSignIn = $0 })) { + VStack(spacing: 0) { + HStack { + AeselWordmark(text: "Aesel") + Spacer() + Button("/close") { session.showSignIn = false } + .font(Paint.font()).foregroundStyle(Paint.dim) + }.padding(16).background(Paint.bg) + ZStack { + SignInCarrier(host: host) + if session.signInLoading { + ProgressView("Opening AC sign-in…") + .font(Paint.font()).tint(Paint.ac) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Paint.bg) + } + if let error = session.signInError { + VStack(spacing: 18) { + Text(error).multilineTextAlignment(.center) + Button("/retry") { host.signIn() }.foregroundStyle(Paint.you) + } + .font(Paint.font()).padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Paint.bg) + } + } + } + } + } - private var bar: some View { - HStack { - Text(session.route.isEmpty ? "Aesel" : session.route) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 12) - Text(session.status) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(colour(session.health)) + private var title: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Button { + writing = false + expandedPreview = false + showHome = true + } label: { AeselWordmark(text: "Aesel") } + .buttonStyle(.plain) + .accessibilityLabel("aesel title screen") + Spacer(minLength: 0) + if session.signedIn { + AeselHandle(handle: session.handle, colors: session.handleColors) + } else { + Button("/login") { host.signIn() }.foregroundStyle(Paint.dim) + .accessibilityLabel("Sign in to Aesthetic Computer") + } } - .font(.system(size: 15, design: .monospaced)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .overlay(alignment: .bottom) { Divider().overlay(Paint.rule) } + .padding(.horizontal, 16).padding(.vertical, 8) + .overlay(alignment: .bottom) { Rectangle().fill(Paint.rule).frame(height: 1).padding(.horizontal, 16) } } - /// The piece, live. A webview because the Aesthetic Computer runtime is one - /// on every platform — that constraint is why hosting the session in a - /// second, hidden webview costs nothing. - private var stage: some View { - ZStack { - Paint.deep + private var preview: some View { + ZStack(alignment: .bottomTrailing) { if let url = session.previewURL { - PieceView(url: url) - } else { - Text("Your piece appears here, live, as it is written.") - .font(.system(size: 13, design: .monospaced)) - .foregroundStyle(Paint.dim) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) + PieceView(url: url, source: session.source) + .frame(maxWidth: .infinity, maxHeight: expandedPreview ? .infinity : writing ? 140 : 240) + .clipped() } + Button { expandedPreview.toggle(); writing = false } label: { + Image(systemName: expandedPreview ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + .font(.system(size: 14)).padding(10).background(Paint.deep.opacity(0.85)) + } + .accessibilityLabel(expandedPreview ? "Return to aesel" : "Expand piece") } - .aspectRatio(4 / 3, contentMode: .fit) - .frame(maxWidth: .infinity) - .clipped() - .overlay(alignment: .bottom) { Divider().overlay(Paint.rule) } + .padding(expandedPreview ? 0 : 5) + .background { AeselWood() } + .overlay { Rectangle().stroke(Color(rgb: 0x9e7548), lineWidth: 1) } + .shadow(color: .black.opacity(0.3), radius: 0, x: 3, y: 3) + .padding(.horizontal, expandedPreview ? 0 : 16).padding(.top, expandedPreview ? 0 : 12) } private var transcript: some View { ScrollViewReader { scroller in ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { + LazyVStack(alignment: .leading, spacing: 14) { + if session.entries.isEmpty { + Text(session.signedIn ? "what shall we make?" : "sign in to make something.") + .foregroundStyle(Paint.dim) + } + if let fatal = session.fatal { + Text(fatal).foregroundStyle(Paint.bad) + Button("/retry") { host.restore() } + } ForEach(session.entries) { entry in - row(entry).id(entry.id) + VStack(alignment: .leading, spacing: 3) { + if entry.kind == .you { Text("› " + entry.text).foregroundStyle(Paint.you) } + else { + Text(entry.text) + .foregroundStyle(colour(entry.kind)) + .textSelection(.enabled) + } + } + .id(entry.id) + .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) + .padding(16) } + .scrollDismissesKeyboard(.interactively) .onChange(of: session.entries.last?.text) { guard let last = session.entries.last else { return } - withAnimation(.easeOut(duration: 0.15)) { - scroller.scrollTo(last.id, anchor: .bottom) - } + scroller.scrollTo(last.id, anchor: .bottom) } } .frame(maxHeight: .infinity) } - private func row(_ entry: Entry) -> some View { - HStack(alignment: .top, spacing: 8) { - if let tag = tag(entry.kind) { - Text(tag) - .font(.system(size: 11, weight: .regular, design: .monospaced)) - .foregroundStyle(colour(entry.kind)) - .frame(width: 34, alignment: .leading) + private var shelf: some View { + VStack(spacing: 0) { + HStack(alignment: .bottom, spacing: 0) { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text("REMOTE").foregroundStyle(.orange) + Text("·") + Text(session.status.uppercased()).foregroundStyle(session.busy ? Paint.ac : session.health == .failed ? Paint.bad : Paint.edit) + } + .font(Paint.font(18)) + if !session.model.isEmpty { + AeselModelPicker(session: session, host: host) + } + Text(session.route.isEmpty ? "aesel" : session.route) + .foregroundStyle(Paint.dim).lineLimit(1).truncationMode(.middle) + } + .padding(.bottom, 10) + Spacer(minLength: 0) + AeselDonkey(busy: session.busy, failed: session.health == .failed) + .frame(height: 86, alignment: .bottom) } - Text(entry.text) - .font(.system(size: 15, design: .monospaced)) - .foregroundStyle(entry.kind == .note || entry.kind == .bad ? colour(entry.kind) : Paint.ink) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - } - } - - private var composer: some View { - HStack(alignment: .bottom, spacing: 8) { - TextField("Describe what to make…", text: $draft, axis: .vertical) - .lineLimit(1 ... 6) - .textFieldStyle(.plain) - .font(.system(size: 16, design: .monospaced)) - .padding(10) - .background(Paint.deep, in: RoundedRectangle(cornerRadius: 10)) - .overlay { RoundedRectangle(cornerRadius: 10).stroke(Paint.rule) } - .focused($writing) - .submitLabel(.send) + .padding(.horizontal, 16) - Button { - if session.busy { - host.stop() - } else { - send() + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 9) { + Text("›").foregroundStyle(Paint.you) + TextField("make something…", text: $draft, axis: .vertical) + .font(Paint.font(22)) + .lineLimit(1...4) + .textFieldStyle(.plain) + .tint(Paint.you) + .focused($writing) + .submitLabel(.send) + .onSubmit { send() } + .autocorrectionDisabled() + Button { session.busy ? host.stop() : send() } label: { + Text(session.busy ? "■" : "↵") + .font(Paint.font(28)) + .frame(width: 44, height: 44) + .foregroundStyle(Paint.you) + } + .disabled(!session.busy && draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityLabel(session.busy ? "Stop" : "Send") + } + .padding(.leading, 12) + .background(Paint.deep.opacity(0.8)) + .overlay { Rectangle().stroke(Paint.rule, lineWidth: 1) } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + Button("/new") { writing = false; showHome = true } + Button("/publish") { host.publish() }.disabled(!session.signedIn || session.busy) + Button("/open") { if let url = session.shareURL { openURL(url) } } + .disabled(session.shareURL == nil) + Button(session.signedIn ? "/logout" : "/login") { session.signedIn ? host.signOut() : host.signIn() } + Button("/help") { showHelp = true } + } + .font(Paint.font(19)).foregroundStyle(Paint.dim) + .buttonStyle(.plain) + .frame(height: 44) } - } label: { - Image(systemName: session.busy ? "stop.fill" : "arrow.right") - .font(.system(size: 18, weight: .semibold)) - .frame(width: 44, height: 44) - .background(session.busy ? Paint.rule : Paint.you, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(.white) } - .disabled(!session.busy && draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .padding(.horizontal, 12).padding(.top, 9) + .background { AeselWood().ignoresSafeArea(edges: .bottom) } + .overlay(alignment: .top) { Rectangle().fill(Color(rgb: 0x9e7548)).frame(height: 1) } } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .overlay(alignment: .top) { Divider().overlay(Paint.rule) } } - private func send() { - let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } - draft = "" - host.ask(text) - } - - private func failure(_ message: String) -> some View { - VStack(spacing: 12) { - Text("Aesel could not reach its session.") - .font(.system(size: 16, weight: .semibold, design: .monospaced)) - Text(message) - .font(.system(size: 13, design: .monospaced)) + private var help: some View { + VStack(alignment: .leading, spacing: 24) { + AeselWordmark() + Text("describe a picture, a sound, or a little world. aesel writes it while you watch.") + Text("/new start a piece\n/publish share your piece\n/open open in your browser\n/login your AC account") .foregroundStyle(Paint.dim) - .multilineTextAlignment(.center) + Button("/close") { showHelp = false }.foregroundStyle(Paint.you) + Spacer() } - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Paint.bg) + .font(Paint.font(22)).padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + .background { AeselCloth().ignoresSafeArea() } + .presentationDetents([.medium]) } - private func tag(_ kind: Entry.Kind) -> String? { - switch kind { - case .you: return "YOU" - case .ac: return "AC" - case .edit: return "EDIT" - case .bad: return "!" - case .note: return nil + private func send() { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + draft = "" + switch text { + case "/new", "/home": writing = false; showHome = true + case "/publish": host.publish() + case "/login": host.signIn() + case "/logout": host.signOut() + case "/open": if let url = session.shareURL { openURL(url) } + case "/help": showHelp = true + default: host.ask(text) } } private func colour(_ kind: Entry.Kind) -> Color { switch kind { case .you: return Paint.you - case .ac: return Paint.ac + case .ac: return Paint.ink case .edit: return Paint.edit case .bad: return Paint.bad case .note: return Paint.dim } } - - private func colour(_ health: Health) -> Color { - switch health { - case .idle: return Paint.dim - case .working: return Paint.ac - case .live: return Paint.edit - case .failed: return Paint.bad - } - } } /// Puts the hidden session webview in the hierarchy without drawing it. @@ -203,27 +256,9 @@ private struct HostCarrier: UIViewRepresentable { func updateUIView(_ view: WKWebView, context: Context) {} } -/// The live piece. Scrolling is off because a piece owns its own gestures. -struct PieceView: UIViewRepresentable { - let url: URL - - func makeUIView(context: Context) -> WKWebView { - let configuration = WKWebViewConfiguration() - configuration.allowsInlineMediaPlayback = true - configuration.mediaTypesRequiringUserActionForPlayback = [] - let view = WKWebView(frame: .zero, configuration: configuration) - view.scrollView.isScrollEnabled = false - view.scrollView.contentInsetAdjustmentBehavior = .never - view.isOpaque = false - view.backgroundColor = .clear - view.isMultipleTouchEnabled = true - return view - } - func updateUIView(_ view: WKWebView, context: Context) { - // The fragment changes on every publish, so compare the whole URL: a - // reload is exactly what a new revision wants. - guard view.url?.absoluteString != url.absoluteString else { return } - view.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)) - } +private struct SignInCarrier: UIViewRepresentable { + let host: SessionHost + func makeUIView(context: Context) -> WKWebView { host.signInView } + func updateUIView(_ view: WKWebView, context: Context) {} } diff --git a/apple/aesel/Sources/Session.swift b/apple/aesel/Sources/Session.swift index a3730e9be..aee7460d2 100644 --- a/apple/aesel/Sources/Session.swift +++ b/apple/aesel/Sources/Session.swift @@ -26,6 +26,20 @@ enum Health: Equatable { case failed } +struct ModelChoice: Identifiable { + let id: String + let title: String + let premium: Bool +} + +struct SessionSummary: Identifiable { + let id: String + let title: String + let medium: String + let route: String + let updatedAt: String +} + /// The whole of what SwiftUI observes. It holds no logic about turns, tools or /// publishing — those live in `easel/phone/session.mjs`, shared with the /// desktop. This is the projection of that session's event stream into @@ -33,13 +47,26 @@ enum Health: Equatable { @Observable final class Session { var entries: [Entry] = [] + var history: [SessionSummary] = [] + var medium = "piece" + var model = "" + var modelChoices: [ModelChoice] = [] + var reportedModel = "" + var currentThreadID = "" + var currentSessionID: String { currentThreadID } var status: String = "starting" var health: Health = .idle var route: String = "" var previewURL: URL? + var shareURL: URL? + var source = "" + var showSignIn = false + var signInLoading = false + var signInError: String? var busy = false var signedIn = false var handle = "" + var handleColors: [String] = [] /// Set when the host page itself could not start — a missing dev server is /// the usual cause, and a blank screen is a bad way to say so. var fatal: String? @@ -65,6 +92,36 @@ final class Session { func receive(_ event: [String: Any]) { guard let type = event["type"] as? String else { return } switch type { + case "model": + model = event["requested"] as? String ?? model + reportedModel = event["reported"] as? String ?? "" + if let choices = event["choices"] as? [[String: Any]] { + modelChoices = choices.compactMap { choice in + guard let id = choice["id"] as? String else { return nil } + return ModelChoice(id: id, title: choice["title"] as? String ?? id, premium: choice["premium"] as? Bool ?? false) + } + } + + case "history": + history = (event["items"] as? [[String: Any]] ?? []).compactMap { item in + guard let id = item["id"] as? String else { return nil } + return SessionSummary(id: id, title: item["title"] as? String ?? "Untitled", + medium: item["medium"] as? String ?? "piece", route: item["route"] as? String ?? "", + updatedAt: item["updatedAt"] as? String ?? "") + } + + case "thread": + reportedModel = "" + currentThreadID = event["id"] as? String ?? "" + medium = event["medium"] as? String ?? "piece" + reset() + for entry in event["events"] as? [[String: Any]] ?? [] { + if ["you", "note", "bad", "bridge"].contains(entry["type"] as? String ?? "") { receive(entry) } + } + + case "hostError": + fatal = event["text"] as? String ?? "The session could not start." + case "ready", "restored": signedIn = event["signedIn"] as? Bool ?? false if type == "restored" { status = signedIn ? "ready" : "signed out" } @@ -76,11 +133,30 @@ final class Session { append(.note, "Signed in, but this account has no @handle yet. Claim one at aesthetic.computer/handle to publish.") } + case "handleColors": + if event["handle"] as? String == handle { handleColors = event["colors"] as? [String] ?? [] } + + case "signedOut": + signedIn = false + handle = "" + handleColors = [] + status = "signed out" + + case "source": + source = event["source"] as? String ?? source + previewURL = URL(string: "https://aesthetic.computer/wipe?nogap=true&nolabel=true&noauth=true") + case "piece": + source = event["source"] as? String ?? source + shareURL = nil + previewURL = nil route = event["route"] as? String ?? "" case "preview": - if let text = event["url"] as? String { previewURL = URL(string: text) } + if let text = event["url"] as? String { + shareURL = URL(string: text) + previewURL = shareURL + } case "status": status = event["text"] as? String ?? status @@ -117,6 +193,10 @@ final class Session { let params = event["params"] as? [String: Any] ?? [:] switch method { + case "model/reported": + model = params["requested"] as? String ?? model + reportedModel = params["reported"] as? String ?? reportedModel + case "turn/started": streamingIndex = nil status = "thinking" diff --git a/apple/aesel/Sources/SessionHost.swift b/apple/aesel/Sources/SessionHost.swift index 99fd0f562..69ee47b78 100644 --- a/apple/aesel/Sources/SessionHost.swift +++ b/apple/aesel/Sources/SessionHost.swift @@ -1,5 +1,6 @@ import Foundation import WebKit +import Security /// Runs the shared JavaScript session in a WKWebView that is never shown. /// @@ -15,6 +16,9 @@ import WebKit @MainActor final class SessionHost: NSObject { private var webView: WKWebView! + private var loginWebView: WKWebView? + private var loginTimeout: Task? + private let bundleHandler = BundleSchemeHandler() private let session: Session private let store: SessionStore /// Calls that arrive before the page finishes loading. Without this, a @@ -34,6 +38,18 @@ final class SessionHost: NSObject { let controller = WKUserContentController() controller.add(self, name: "aesel") + var guides: [String: String] = [:] + for name in ["pieces.md", "screen.md", "hand.md", "kidlisp.md", "api.json"] { + let path = "/easel/context/\(name)" + if let root = Bundle.main.resourceURL, + let text = try? String(contentsOf: root.appendingPathComponent("Session" + path), encoding: .utf8) { + guides[path] = text + } + } + if let data = try? JSONSerialization.data(withJSONObject: guides), let json = String(data: data, encoding: .utf8) { + controller.addUserScript(WKUserScript(source: "globalThis.__aeselGuides = \(json);", injectionTime: .atDocumentStart, forMainFrameOnly: true)) + } + // Seeded before any module runs, so `session.mjs` can restore a piece // and a token on its first tick rather than after a round trip. if let seed = store.seedJSON() { @@ -47,6 +63,7 @@ final class SessionHost: NSObject { } let configuration = WKWebViewConfiguration() + configuration.setURLSchemeHandler(bundleHandler, forURLScheme: "aesel-bundle") configuration.userContentController = controller configuration.defaultWebpagePreferences.allowsContentJavaScript = true @@ -69,18 +86,89 @@ final class SessionHost: NSObject { // WKWebView a Promise earns "JavaScript execution returned a result of an // unsupported type" — a harmless error that reads exactly like a real one. func ask(_ text: String) { call("void aesel.ask(\(quote(text)));") } + func publish() { call("void aesel.publish();") } func stop() { call("void aesel.stop();") } - func newPiece() { call("void aesel.newPiece();") } + func newPiece() { newSession(medium: "piece") } + func newSession(medium: String) { call("void aesel.newPiece(\(quote(medium)));") } + func resumeSession(id: String) { call("void aesel.resumeSession(\(quote(id)));") } + func save() { call("void aesel.save();") } + func setModel(model: String) { call("void aesel.setModel(\(quote(model))); ") } + func setModel(id: String) { setModel(model: id) } func adopt(token: String) { call("void aesel.adoptToken(\(quote(token)));") } func restore() { call("void aesel.restore();") } + var signInView: WKWebView { + if let loginWebView { return loginWebView } + let controller = WKUserContentController() + controller.add(self, name: "acSignIn") + controller.addUserScript(WKUserScript(source: """ + if (location.origin === 'https://aesthetic.computer') { + let delivered = false, requested = false; + const startLogin = new URLSearchParams(location.search).has("aeselLogin"); + setInterval(async () => { + if (delivered) return; + if (startLogin && !requested && window.acLOGIN) { + requested = true; + if (!window.auth0Client || !(await window.auth0Client.isAuthenticated())) { + window.acLOGIN(); + return; + } + } + if (!window.auth0Client) return; + try { + if (!(await window.auth0Client.isAuthenticated())) return; + const token = await window.auth0Client.getTokenSilently(); + if (token) { delivered = true; window.webkit.messageHandlers.acSignIn.postMessage({token}); } + } catch (_) { } + }, 1000); + } + """, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) + let config = WKWebViewConfiguration() + config.userContentController = controller + let view = WKWebView(frame: .zero, configuration: config) + view.navigationDelegate = self + view.alpha = 0 + loginWebView = view + return view + } + + func signIn() { + session.showSignIn = true + session.signInError = nil + session.signInLoading = true + signInView.alpha = 0 + armLoginTimeout() + signInView.load(URLRequest(url: URL(string: "https://aesthetic.computer/?aeselLogin=1")!)) + } + + private func armLoginTimeout() { + loginTimeout?.cancel() + loginTimeout = Task { [weak self] in + try? await Task.sleep(nanoseconds: 45_000_000_000) + guard !Task.isCancelled, let self, self.session.showSignIn, self.session.signInLoading else { return } + self.session.signInLoading = false + self.session.signInError = "AC sign-in did not finish loading. Check your connection and retry." + } + } + + func signOut() { + loginTimeout?.cancel() + call("void aesel.signOut();") + store.clearToken() + session.signedIn = false + session.handle = "" + session.showSignIn = false + loginWebView = nil + WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: .distantPast) { } + } + private func call(_ javascript: String) { guard loaded else { pending.append(javascript) return } webView.evaluateJavaScript(javascript) { _, error in - if let error { NSLog("[aesel] %@ failed: %@", javascript, String(describing: error)) } + if let error { NSLog("[aesel] JavaScript call failed: %@", String(describing: error)) } } } @@ -106,7 +194,20 @@ extension SessionHost: WKScriptMessageHandler { ) { MainActor.assumeIsolated { guard let body = message.body as? [String: Any] else { - NSLog("[aesel] message was not a dictionary: \(message.body)") + NSLog("[aesel] invalid session message") + return + } + if message.name == "acSignIn" { + guard session.showSignIn, message.webView === loginWebView, + message.frameInfo.isMainFrame, + message.frameInfo.securityOrigin.protocol == "https", + message.frameInfo.securityOrigin.host == "aesthetic.computer", + [0, 443].contains(message.frameInfo.securityOrigin.port), + let token = body["token"] as? String, !token.isEmpty else { return } + loginTimeout?.cancel() + adopt(token: token) + session.signInLoading = false + session.showSignIn = false return } let type = body["type"] as? String @@ -146,9 +247,25 @@ extension SessionHost: WKScriptMessageHandler { } extension SessionHost: WKNavigationDelegate { + nonisolated func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + MainActor.assumeIsolated { + guard webView === loginWebView else { return } + session.signInLoading = true + webView.alpha = 0 + armLoginTimeout() + } + } + nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // Deliberately does not mark the host ready; see the `ready` event. - MainActor.assumeIsolated { NSLog("[aesel] host page loaded") } + MainActor.assumeIsolated { + if webView === loginWebView { + let isCallback = webView.url?.host == "aesthetic.computer" + session.signInLoading = isCallback + webView.alpha = isCallback ? 0 : 1 + if isCallback { armLoginTimeout() } else { loginTimeout?.cancel() } + } else { NSLog("[aesel] host page loaded") } + } } nonisolated func webView( @@ -156,7 +273,14 @@ extension SessionHost: WKNavigationDelegate { didFail navigation: WKNavigation!, withError error: Error ) { - MainActor.assumeIsolated { session.fatal = error.localizedDescription } + MainActor.assumeIsolated { + if webView === loginWebView { + if (error as NSError).code == NSURLErrorCancelled { return } + loginTimeout?.cancel() + session.signInLoading = false + session.signInError = error.localizedDescription + } else { session.fatal = error.localizedDescription } + } } nonisolated func webView( @@ -164,7 +288,14 @@ extension SessionHost: WKNavigationDelegate { didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error ) { - MainActor.assumeIsolated { session.fatal = error.localizedDescription } + MainActor.assumeIsolated { + if webView === loginWebView { + if (error as NSError).code == NSURLErrorCancelled { return } + loginTimeout?.cancel() + session.signInLoading = false + session.signInError = error.localizedDescription + } else { session.fatal = error.localizedDescription } + } } } @@ -180,12 +311,27 @@ final class SessionStore { if let data = try? Data(contentsOf: url), let decoded = try? JSONDecoder().decode([String: String].self, from: data) { values = decoded + if let text = values["session"], let oldData = text.data(using: .utf8), + var record = (try? JSONSerialization.jsonObject(with: oldData)) as? [String: Any], + record.removeValue(forKey: "token") != nil, + let clean = try? JSONSerialization.data(withJSONObject: record), + let cleanText = String(data: clean, encoding: .utf8) { + values["session"] = cleanText + if let disk = try? JSONEncoder().encode(values) { try? disk.write(to: url, options: .atomic) } + } } else { values = [:] } } func write(key: String, value: String) { + var value = value + if key == "session", let data = value.data(using: .utf8), + var record = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] { + if let token = record.removeValue(forKey: "token") as? String { saveToken(token) } + if let clean = try? JSONSerialization.data(withJSONObject: record), + let text = String(data: clean, encoding: .utf8) { value = text } + } values[key] = value guard let data = try? JSONEncoder().encode(values) else { return } // Atomic so a crash mid-write cannot leave a session that parses as @@ -193,10 +339,61 @@ final class SessionStore { try? data.write(to: url, options: .atomic) } + private var tokenQuery: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "computer.aesthetic.aesel.session", + kSecAttrAccount as String: "access-token"] + } + + func clearToken() { SecItemDelete(tokenQuery as CFDictionary) } + + private func saveToken(_ token: String) { + clearToken() + guard !token.isEmpty else { return } + var query = tokenQuery + query[kSecValueData as String] = Data(token.utf8) + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + let status = SecItemAdd(query as CFDictionary, nil) + if status != errSecSuccess { NSLog("[aesel] Keychain save failed (%d); sign in again after relaunch", status) } + } + func seedJSON() -> String? { + var values = values + // Migrate earlier development sessions out of Documents on next write. + var record = ((values["session"]?.data(using: .utf8)).flatMap { + try? JSONSerialization.jsonObject(with: $0) as? [String: Any] + }) ?? [:] + var query = tokenQuery + query[kSecReturnData as String] = true + var result: CFTypeRef? + if SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, let token = String(data: data, encoding: .utf8) { + record["token"] = token + } + if let data = try? JSONSerialization.data(withJSONObject: record), + let text = String(data: data, encoding: .utf8) { values["session"] = text } guard !values.isEmpty, let data = try? JSONSerialization.data(withJSONObject: values, options: []), let text = String(data: data, encoding: .utf8) else { return nil } return text } } + + +/// Serves only immutable resources shipped with the app; no listening socket. +final class BundleSchemeHandler: NSObject, WKURLSchemeHandler { + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url, + let root = Bundle.main.resourceURL?.appendingPathComponent("Session") else { return } + let file = root.appendingPathComponent(String(url.path.drop(while: { $0 == "/" }))).standardizedFileURL + guard file.path.hasPrefix(root.standardizedFileURL.path + "/"), let data = try? Data(contentsOf: file) else { + NSLog("[aesel] Missing bundle resource: %@ root: %@", file.path, root.path) + urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist)); return + } + let types = ["html": "text/html", "mjs": "text/javascript", "js": "text/javascript", "md": "text/plain"] + urlSchemeTask.didReceive(URLResponse(url: url, mimeType: types[file.pathExtension] ?? "application/octet-stream", expectedContentLength: data.count, textEncodingName: "utf-8")) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { } +} diff --git a/apple/aesel/bundle-session.sh b/apple/aesel/bundle-session.sh new file mode 100755 index 000000000..136e9723f --- /dev/null +++ b/apple/aesel/bundle-session.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +mkdir -p Resources/Session/easel +for tree in phone src context; do + rsync -a --delete "../../easel/$tree/" "Resources/Session/easel/$tree/" +done diff --git a/apple/aesel/project.yml b/apple/aesel/project.yml index 33602b2d1..8d060a416 100644 --- a/apple/aesel/project.yml +++ b/apple/aesel/project.yml @@ -14,9 +14,15 @@ targets: platform: iOS sources: - Sources + - path: Resources + excludes: + - Session + - path: Resources/Session + type: folder + buildPhase: resources settings: base: - PRODUCT_BUNDLE_IDENTIFIER: computer.aesthetic.aesel + PRODUCT_BUNDLE_IDENTIFIER: computer.aesthetic.easel PRODUCT_NAME: Aesel INFOPLIST_FILE: Info.plist TARGETED_DEVICE_FAMILY: "1" diff --git a/apple/aesel/run.sh b/apple/aesel/run.sh index bff42c707..3ebd7ee45 100755 --- a/apple/aesel/run.sh +++ b/apple/aesel/run.sh @@ -5,45 +5,34 @@ # ./run.sh device → the iPhone plugged into this Mac # ./run.sh device "ask…" → same, running one prompt on launch # -# The app loads its session from `easel/phone/serve.mjs` on this machine rather -# than from a bundled copy, so a JavaScript edit reaches the phone on relaunch -# with no rebuild. That address is a LAN IP, and a LAN IP is not stable — this -# Mac moved from a phone hotspot to Wi-Fi mid-session — so it is discovered at -# build time rather than committed. +# The shared JavaScript session is bundled into the app. set -euo pipefail cd "$(dirname "$0")" REPO=$(cd ../.. && pwd) MODE=${1:-simulator} ASK=${2:-} -PORT=8770 DERIVED=${DERIVED:-/tmp/aesel-dd} - -lan() { ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo 127.0.0.1; } - -if ! curl -fsS -o /dev/null "http://localhost:$PORT/easel/phone/host.html" 2>/dev/null; then - echo "starting the session server…" - (cd "$REPO" && nohup node easel/phone/serve.mjs --port "$PORT" --token >/tmp/aesel-serve.log 2>&1 &) - sleep 2 -fi +./bundle-session.sh xcodegen generate >/dev/null if [ "$MODE" = "device" ]; then - HOST="http://$(lan):$PORT/easel/phone/host.html" - echo "session → $HOST" - # The device has to reach this Mac, so the address is stamped into the built - # Info.plist. The simulator shares the Mac's network and can just use - # localhost, which is why only this branch rewrites the plist. - /usr/libexec/PlistBuddy -c "Set :AeselHostURL $HOST" Info.plist - xcodebuild -project Aesel.xcodeproj -scheme Aesel \ -destination 'generic/platform=iOS' \ -derivedDataPath "$DERIVED" \ - -allowProvisioningUpdates build + -allowProvisioningUpdates -jobs 2 build APP="$DERIVED/Build/Products/Debug-iphoneos/Aesel.app" - DEVICE=$(xcrun devicectl list devices 2>/dev/null | awk '/connected/ {print $(NF-1); exit}') + DEVICE=${DEVICE:-$(xcrun devicectl list devices --json-output - 2>/dev/null | python3 -c ' +import json, sys +for device in json.load(sys.stdin).get("result", {}).get("devices", []): + hardware = device.get("hardwareProperties", {}) + connection = device.get("connectionProperties", {}) + if hardware.get("deviceType") == "iPhone" and connection.get("pairingState") == "paired" and connection.get("transportType") == "wired": + print(device["identifier"]) + break +')} if [ -z "$DEVICE" ]; then echo echo "Built, but no device is connected. Plug the iPhone in, trust this Mac, and re-run." @@ -51,21 +40,21 @@ if [ "$MODE" = "device" ]; then exit 1 fi xcrun devicectl device install app --device "$DEVICE" "$APP" - xcrun devicectl device process launch --device "$DEVICE" computer.aesthetic.aesel + DEVICECTL_CHILD_AESEL_ASK="$ASK" xcrun devicectl device process launch --device "$DEVICE" --terminate-existing computer.aesthetic.easel exit 0 fi xcodebuild -project Aesel.xcodeproj -scheme Aesel \ -sdk iphonesimulator \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ - -derivedDataPath "$DERIVED" build + -derivedDataPath "$DERIVED" -jobs 2 build xcrun simctl boot "iPhone 17 Pro" 2>/dev/null || true open -a Simulator xcrun simctl install booted "$DERIVED/Build/Products/Debug-iphonesimulator/Aesel.app" -xcrun simctl terminate booted computer.aesthetic.aesel 2>/dev/null || true +xcrun simctl terminate booted computer.aesthetic.easel 2>/dev/null || true if [ -n "$ASK" ]; then - SIMCTL_CHILD_AESEL_ASK="$ASK" xcrun simctl launch booted computer.aesthetic.aesel + SIMCTL_CHILD_AESEL_ASK="$ASK" xcrun simctl launch booted computer.aesthetic.easel else - xcrun simctl launch booted computer.aesthetic.aesel + xcrun simctl launch booted computer.aesthetic.easel fi diff --git a/easel/README.md b/easel/README.md index 5c4fe5caf..dd59736d4 100644 --- a/easel/README.md +++ b/easel/README.md @@ -1,4 +1,8 @@ -# Easel +# Aesel + +Aesel by Aesthetic Computer. The canonical command is `aesel`; `ac` and +`easel` remain compatible. The source directory, `EASEL_*` settings, existing +thread paths, and bundle ID remain stable so upgrades preserve user data. A coding interface for the terminal. Lives at `easel/` in the Aesthetic Computer repository. @@ -68,7 +72,7 @@ The allowance measures weighted tokens, not dollars, and is not an atomic spend reservation. Unavailable budget checks refuse inference. New hosted choices require the matching Lith endpoint deployment. -`/about`, or clicking **EASEL**, opens the feature map. Click **@handle** to open +`/about`, or clicking **AESEL**, opens the feature map. Click **@handle** to open your profile in a browser. Header targets highlight on hover in terminals that support mouse reporting. `/mouse off` restores terminal selection; `/mouse on` enables interaction again. `EASEL_MOUSE=0` disables it at launch. @@ -104,7 +108,7 @@ Serving only: no training, no water, and not your own machine. The Claude bridge runs `claude --print --input-format stream-json --output-format stream-json`, the same headless protocol the Claude Agent SDK -speaks, driven directly over a pipe. That is why Easel still has no +speaks, driven directly over a pipe. That is why Aesel still has no dependencies: a subprocess on stdio is the same shape as `codex app-server --stdio`, and it carries streaming, tool calls and approvals without a package tree behind it. Each bridge signs in with the vendor CLI's own credentials @@ -117,7 +121,7 @@ configuration — Codex is pinned to `on-request` approvals and a and `--strict-mcp-config` — so nothing but the person watching can approve a command in a session, and an `a` is never written to a settings file. -On the Claude bridge the session also carries Easel's own tools, served by +On the Claude bridge the session also carries Aesel's own tools, served by `src/tools.mjs` as the one MCP server the strict config admits: `ac_api` (the piece API — runtime signatures, docs and real call sites, read off `lib/disk.mjs` and `lib/graph.mjs` by `bin/build-api-map.mjs` into @@ -137,7 +141,7 @@ written down in [`docs/local-contract.md`](docs/local-contract.md). ## The session's piece, live on a phone -Opening Easel opens a new blank piece. It gets a random pronounceable +Opening Aesel opens a new blank piece. It gets a random pronounceable name, it is a real file in the workspace, and a QR code for it sits in the bottom right of the interface. Scan the code and the piece runs on your phone; every edit the agent makes reaches it a moment later. @@ -180,7 +184,7 @@ comment and declares `setup` or `draw` is taken as Lua at all. ## Account and publishing -Easel reads the shared Aesthetic Computer sign-in at `~/.ac-token`, +Aesel reads the shared Aesthetic Computer sign-in at `~/.ac-token`, the same file `ac-login` and the AC desktop apps use. `/login` runs the Authorization-Code + PKCE flow in your browser with a loopback callback and writes that file; a sign-in or sign-out anywhere in the suite updates the @@ -220,7 +224,7 @@ npm test ## Designing the furniture -Easel does not draw all of itself. The QR, the live card of the piece and the +Aesel does not draw all of itself. The QR, the live card of the piece and the status stone are Slab menubar overlays parked on the terminal, and `frame` filters Slab's own windows out of every capture — its usual job is reading the machine underneath them. So a screenshot taken to judge the card's padding @@ -230,20 +234,20 @@ shows the terminal where the card is. shot to a padded crop, so the menu bar an overlay is said to be flush against is in the same picture. -`easel/bin/design-loop.mjs` is the whole cycle in one command: close the Easel +`easel/bin/design-loop.mjs` is the whole cycle in one command: close the Aesel session, open a fresh one, wait for its overlays to land, photograph them. Fresh because overlays are placed once, when a window appears — editing the placement and reinstalling the menubar does not move what is already on screen, so the only honest check is a session that has never seen the old numbers. ```sh -node easel/bin/design-loop.mjs # restart Easel, then shoot +node easel/bin/design-loop.mjs # restart Aesel, then shoot node easel/bin/design-loop.mjs --shot # shoot what is already open ``` Edit an overlay, run `slab/menubar-swift/install.sh`, then run the loop. -Easel is proprietary. See `LICENSE`. +Aesel is proprietary. See `LICENSE`. On Fish installations with existing `ac` or `aesthetic` functions, the installer preserves them as `ac-repo` and `aesthetic-platform`. diff --git a/easel/SCORE.md b/easel/SCORE.md new file mode 100644 index 000000000..75289abca --- /dev/null +++ b/easel/SCORE.md @@ -0,0 +1,13 @@ +# Piece score + +Build a new piece of aesthetic.computer software. The running artifact is the work. + +- Start from the request and the current piece. Preserve its behavior unless asked to change it. Make the smallest useful visible iteration. +- Lifecycle exports receive AC's API, not a browser canvas context. Ask `ac_api` for an unfamiliar exact symbol. Use `ac_examples` for real call sites, including the bundled API examples outside the monorepo. +- Tool discovery must be narrow: the AC tools are `ac_api`, `ac_examples`, `ac_outline`, `ac_symbol`, `ac_references`, `ac_preview`, and `ac_frame` on the `ac` MCP server. If discovery is needed, match the exact tool name and print only its schema. Never dump `ALL_TOOLS` or match generic terms like "input" or "publish" across every server. Show names with short summaries when listing tools. Preview refresh and auto-publish are managed by Aesel; do not discover unrelated publishing tools for them. +- Navigate JavaScript structurally: `ac_outline` gives AST declarations and class methods; `ac_symbol` reads one declaration; `ac_references` finds identifier occurrences without matching comments or strings. These are syntax tools, not a type checker or scope-aware LSP. Bundled `lib/graph.mjs`, `lib/disk.mjs`, `lib/cam-doll.mjs` and reference pieces are available through these tools. +- Avoid repeatedly paging through runtime files with sed/head/grep. If a tool fails, identify that failure once and use one bounded source lookup. Never describe unavailable tools as successful verification. +- Keep animation in sim/paint and input in act. Use screen dimensions for responsive composition. Follow the bundled SCREEN and HAND guides. +- After an edit, inspect `ac_preview` for errors at the current revision and `ac_frame` for actual pixels. For interactive work, exercise controls and verify the requested outcome. A build passing or nonempty frame is insufficient evidence of playability. +- Show progress and the result in plain language. Do not dump source or tool payloads into chat. When code is needed or requested, use a fenced block with its language (`js`, `lisp`, etc.) so Aesel can highlight it. +- Report what changed, what was checked, and any remaining limitation briefly. Do not claim publishing or visual success until its result is confirmed. diff --git a/easel/art/aesel/actions/easel-donkey-actions-1.png b/easel/art/aesel/actions/easel-donkey-actions-1.png new file mode 100644 index 0000000000000000000000000000000000000000..4e5fd4a0b89385511ac32f26b73c4afa902a1c15 GIT binary patch literal 129356 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 Nc)I$ztaD0e0swAxMcn`Z literal 0 HcmV?d00001 diff --git a/easel/art/aesel/actions/easel-donkey-actions-2.png b/easel/art/aesel/actions/easel-donkey-actions-2.png new file mode 100644 index 0000000000000000000000000000000000000000..a23eb3be1a349f3f85216abd1563ebf77b890d9a GIT binary patch literal 135347 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 z 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 literal 0 HcmV?d00001 diff --git a/easel/art/aesel/actions/easel-donkey-actions-3.png b/easel/art/aesel/actions/easel-donkey-actions-3.png new file mode 100644 index 0000000000000000000000000000000000000000..dba70d09dfe6bf91f8b89cc6a00b624224e51269 GIT binary patch literal 138814 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⪼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 z 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 literal 0 HcmV?d00001 diff --git a/easel/art/aesel/actions/easel-donkey-actions-4.png b/easel/art/aesel/actions/easel-donkey-actions-4.png new file mode 100644 index 0000000000000000000000000000000000000000..3f894636f693cf74c793797b88536069b19d43b4 GIT binary patch literal 137824 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�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 literal 0 HcmV?d00001 diff --git a/easel/art/aesel/actions/index.html b/easel/art/aesel/actions/index.html new file mode 100644 index 000000000..b13a85b3a --- /dev/null +++ b/easel/art/aesel/actions/index.html @@ -0,0 +1 @@ +Aesel — 128 action choreographiesAesel actions 1 through 32Aesel actions 33 through 64Aesel actions 65 through 96Aesel actions 97 through 128 \ No newline at end of file diff --git a/easel/bin/aesel b/easel/bin/aesel new file mode 100755 index 000000000..39a6f2df8 --- /dev/null +++ b/easel/bin/aesel @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Canonical aesel command; the original launcher remains compatible. +set -euo pipefail +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/easel" "$@" diff --git a/easel/bin/build-api-map.mjs b/easel/bin/build-api-map.mjs index c1970cdd2..4446d5c0c 100644 --- a/easel/bin/build-api-map.mjs +++ b/easel/bin/build-api-map.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // build-api-map — the static map of the piece API, read off the runtime source. // -// Every Easel session so far has opened with the same hunt: grep graph.mjs for +// Every aesel session so far has opened with the same hunt: grep graph.mjs for // `function circle(`, sed a window of disk.mjs to see what `$paintApiUnwrapped` // exposes, grep the disks for one piece that already calls `synth(`. Ten // sessions, the same eight commands, a minute or two each before the first @@ -27,10 +27,10 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); -const EASEL = join(HERE, ".."); -const REPO = join(EASEL, ".."); +const aesel = join(HERE, ".."); +const REPO = join(aesel, ".."); const AC = join(REPO, "system", "public", "aesthetic.computer"); -const OUT = join(EASEL, "context", "api.json"); +const OUT = join(aesel, "context", "api.json"); const read = (file) => readFileSync(join(AC, file), "utf8").split("\n"); diff --git a/easel/bin/build-picture-tools.mjs b/easel/bin/build-picture-tools.mjs index 6b1522372..9a6556096 100644 --- a/easel/bin/build-picture-tools.mjs +++ b/easel/bin/build-picture-tools.mjs @@ -1,4 +1,4 @@ -// Bundle AC's actual software drawing functions for the standalone Easel install. +// Bundle AC's actual software drawing functions for the standalone aesel install. import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { resolve, dirname } from 'node:path'; diff --git a/easel/bin/design-loop.mjs b/easel/bin/design-loop.mjs index d56298e27..ecd667373 100755 --- a/easel/bin/design-loop.mjs +++ b/easel/bin/design-loop.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -// design-loop — look at Easel's own furniture, change it, look again. +// design-loop — look at aesel's own furniture, change it, look again. // -// Easel does not draw all of itself. The QR you scan, the little live card of +// aesel does not draw all of itself. The QR you scan, the little live card of // the piece, the stone that carries the session's status: those are Slab // menubar overlays parked on top of the terminal, and they are the first thing // anyone sees. Adjusting them used to be guesswork, for two reasons that @@ -18,11 +18,11 @@ // already on screen in any way you can trust, so the only honest check is // a *fresh* session. That is the restart this tool performs. // -// So: close the Easel session, open a new one, wait for its overlays to exist, +// So: close the aesel session, open a new one, wait for its overlays to exist, // point the camera at them. One command per iteration, and the picture that // comes back is the thing being designed. // -// node easel/bin/design-loop.mjs # restart Easel, then shoot +// node easel/bin/design-loop.mjs # restart aesel, then shoot // node easel/bin/design-loop.mjs --shot # shoot what is already open // node easel/bin/design-loop.mjs --out ~/x.jpg // @@ -70,10 +70,10 @@ async function prox(name, args = {}) { return text; } -/// Every live Easel session on this machine, newest marker first. Read from +/// Every live aesel session on this machine, newest marker first. Read from /// the marker files rather than asked of prox: this runs between a close and a /// launch, when the ledger cache is the one thing guaranteed to be stale. -async function easelSessions() { +async function aeselSessions() { let names = []; try { names = await readdir(MARKERS); } catch { return []; } const out = []; @@ -87,7 +87,7 @@ async function easelSessions() { return out.sort((a, b) => String(b.updated || "").localeCompare(String(a.updated || ""))); } -/// Wait for an Easel session that is not one we already knew about, and that +/// Wait for an aesel session that is not one we already knew about, and that /// has got far enough to own a terminal and a piece. `scan_url` is the signal /// that matters: it is set at the moment the session has an address to encode, /// which is the moment the QR and the preview card come into existence. Waiting @@ -95,7 +95,7 @@ async function easelSessions() { async function waitForFreshSession(known, timeoutMs = 45_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - for (const session of await easelSessions()) { + for (const session of await aeselSessions()) { if (known.has(session.id)) continue; if (session.tty && session.scan_url) return session; } @@ -162,7 +162,7 @@ const out = opt("--out") || join(tmpdir(), `easel-design-${Date.now()}.jpg`); if (flag("--help") || flag("-h")) { console.log([ - "design-loop — restart Easel and photograph its overlays", + "design-loop — restart aesel and photograph its overlays", "", " node easel/bin/design-loop.mjs [--shot] [--out file.jpg] [--cwd dir]", "", @@ -176,25 +176,25 @@ if (flag("--help") || flag("-h")) { process.exit(0); } -let session = (await easelSessions())[0] || null; +let session = (await aeselSessions())[0] || null; if (!flag("--shot")) { const host = await selfHost(); if (!host) throw new Error(`no local ledger at ${LEDGER} — is the Slab menubar running?`); - const known = new Set((await easelSessions()).map((s) => s.id)); + const known = new Set((await aeselSessions()).map((s) => s.id)); if (session) { console.log(`⟲ closing ${host}:easel (${session.piece || session.id.slice(0, 8)})`); await prox("prox_close", { handle: session.id }); } else { - console.log("⟲ no Easel session open — launching a first one"); + console.log("⟲ no aesel session open — launching a first one"); } - console.log("⟳ launching a fresh Easel"); + console.log("⟳ launching a fresh aesel"); await prox("prox_launch", { host, agent: "easel", cwd: opt("--cwd") || REPO, by: "easel:design-loop" }); session = await waitForFreshSession(known); - if (!session) throw new Error("the new Easel never reported a scan URL — nothing to photograph."); + if (!session) throw new Error("the new aesel never reported a scan URL — nothing to photograph."); // The marker is written the moment the address exists; the overlays are // placed on the menubar's next walk of the window list, which is a separate // clock this tool cannot read. Measured at about three seconds on blueberry — @@ -205,7 +205,7 @@ if (!flag("--shot")) { await sleep(Number(opt("--settle")) * 1000 || 4000); } -if (!session) throw new Error("no Easel session is open — drop --shot to launch one."); +if (!session) throw new Error("no aesel session is open — drop --shot to launch one."); if (!(await focusTty(session.tty))) { console.log(`! could not focus /dev/${session.tty} — shooting whatever is frontmost`); } diff --git a/easel/bin/easel b/easel/bin/easel index d33ca9cb8..f0bf0aebf 100755 --- a/easel/bin/easel +++ b/easel/bin/easel @@ -33,7 +33,7 @@ Usage: ac [directory] [--runtime mjs|lisp|processing] aesthetic login | logout | whoami aesthetic publish [slug] -Open the Easel terminal interface in a workspace, or manage the +Open the aesel terminal interface in a workspace, or manage the shared Aesthetic Computer sign-in (~/.ac-token) and publish a piece under your @handle at https://aesthetic.computer/@handle/slug. @@ -65,7 +65,7 @@ has_command() { } doctor() { - printf 'Easel %s\n' "$VERSION" + printf 'aesel %s\n' "$VERSION" printf 'interface: %s\n' "$PROJECT_DIR/src/tui.mjs" printf 'control plane: local\n' printf 'telemetry: off\n' @@ -106,7 +106,7 @@ case "${1:-}" in exit 0 ;; --version|-V|version) - printf 'Easel %s\n' "$VERSION" + printf 'aesel %s\n' "$VERSION" exit 0 ;; doctor) diff --git a/easel/bin/easel-desktop b/easel/bin/easel-desktop index ac37b2521..c5485bc92 100755 --- a/easel/bin/easel-desktop +++ b/easel/bin/easel-desktop @@ -4,22 +4,22 @@ set -euo pipefail usage() { cat <<'HELP' -Usage: easel [directory] [--backend ac|claude|codex] [--model NAME] +Usage: aesel [directory] [--backend ac|claude|codex] [--model NAME] [--piece FILE] [--resume THREAD_ID] [--no-autopublish] - easel --cwd DIRECTORY [options] + aesel --cwd DIRECTORY [options] ac [directory] [options] -Open the standalone Easel app. With no directory, reopen its last workspace. -Use `easel .` to select the current directory when starting the app. -If Easel is already running, bring its existing window forward; launch options +Open the standalone aesel app. With no directory, reopen its last workspace. +Use `aesel .` to select the current directory when starting the app. +If aesel is already running, bring its existing window forward; launch options apply when the app starts. This command does not create a second instance. `ac` opens the same making interface inside your terminal, with optional Slab. -Desktop installation: ~/Applications/Easel.app (or EASEL_DESKTOP_APP). +Desktop installation: ~/Applications/aesel.app (or EASEL_DESKTOP_APP). HELP } -fail() { printf 'easel: %s\n' "$*" >&2; exit 1; } +fail() { printf 'aesel: %s\n' "$*" >&2; exit 1; } workspace='' arguments=() while (($#)); do @@ -43,14 +43,18 @@ while (($#)); do [[ -z "$workspace" ]] || fail 'supply one workspace directory' workspace="$1"; shift fi ;; - -*) fail "unknown option: $1 (see easel --help)" ;; + -*) fail "unknown option: $1 (see aesel --help)" ;; *) [[ -z "$workspace" ]] || fail 'supply one workspace directory' workspace="$1"; shift ;; esac done [[ "$(uname -s)" == Darwin ]] || fail 'the standalone launcher currently requires macOS; use ac for the terminal interface' -app_path="${EASEL_DESKTOP_APP:-$HOME/Applications/Easel.app}" -[[ -d "$app_path" ]] || fail "Easel.app is not installed at $app_path. Install the standalone app or run ac." +app_path="${AESEL_DESKTOP_APP:-${EASEL_DESKTOP_APP:-$HOME/Applications/aesel.app}}" +# Existing installations keep working until the renamed app is installed. +if [[ ! -d "$app_path" && -z "${AESEL_DESKTOP_APP:-}${EASEL_DESKTOP_APP:-}" && -d "$HOME/Applications/Easel.app" ]]; then + app_path="$HOME/Applications/Easel.app" +fi +[[ -d "$app_path" ]] || fail "aesel.app is not installed at $app_path. Install the standalone app or run ac." if [[ -n "$workspace" ]]; then [[ -d "$workspace" ]] || fail "workspace directory does not exist: $workspace" workspace="$(cd -- "$workspace" && pwd -P)" diff --git a/easel/bin/frame-ocr b/easel/bin/frame-ocr index c86827a4ccb2d593480affb2999a92a0a6dde5e2..288fa42445825f74dc693f55c2cc7b9f1c0896b5 100755 GIT binary patch delta 19999 zcmbQy!M5TL>jV)-iHV|znN0L2-f|OqRB(Mx=?1CxU26sQVM zsHtE+SpNhj1;kAPh!2r9U{(A0jKjsvx-qD$5HE3J@Qx5FA(utO}w>0w8u*KvjVF5d9li z6@(muAhI4{O$-bUL6aY`_Ay?VT*&4wdZ7^_2@6ROAENaIn}X^Ms4N3Gau^sud}gRI zAc~EFfgxeCF8gUlj>$jS-5DDuJ979lE|@%#BS5eLqyU5=j?$=`{E0)!^MwQhgE0d` zg8%~q!wU&U1_K5L1||ju1_@Bgkz{0;$;iMk!HkhX!G(c=n}LBL0AvCK1H&6A2C03= zz`(E?6x*x<#*7RPCI$*mp1`Rg-zJ>K6*|q}Qg5MgXxRhrZ$=CZ3N|;Z@*WC#?Chz9a6_;ROXaEH%$mJkcF)#>$HT~z263ZVBqs9>IKDA+W4hoGg_SBIY*j1E6nFpK?QU}(7Xnww$D zH)e)a(hc!HGL#*DzGUWDWx&AjLExRkWN9JU`g7b2Q@%1Ytl|L4OELW9P()GllYz

82%McR6!0=%T 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 delta 21731 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 Dq3CQp diff --git a/easel/bin/frame-ocr.swift b/easel/bin/frame-ocr.swift index 5ce5ef645..85e86a6c9 100644 --- a/easel/bin/frame-ocr.swift +++ b/easel/bin/frame-ocr.swift @@ -20,7 +20,9 @@ do { width > 0, height > 0, width <= 1_048_576 / height, let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { fail("Image exceeds the one-megapixel capture limit or cannot be decoded.") } let request = VNRecognizeTextRequest() - request.recognitionLevel = .accurate + // Fast OCR avoids the accurate recognizer's expensive first-use model compilation. + // Confidence remains the native Vision score; callers must not treat it as calibrated. + request.recognitionLevel = .fast request.usesLanguageCorrection = false if #available(macOS 13.0, *) { request.revision = VNRecognizeTextRequestRevision3 } try VNImageRequestHandler(cgImage: image, options: [:]).perform([request]) diff --git a/easel/bin/pack.mjs b/easel/bin/pack.mjs index e50ebea77..0f7ee0b7e 100755 --- a/easel/bin/pack.mjs +++ b/easel/bin/pack.mjs @@ -16,17 +16,17 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); -const EASEL = join(HERE, ".."); -const REPO = join(EASEL, ".."); +const aesel = join(HERE, ".."); +const REPO = join(aesel, ".."); const OUT = join(REPO, "system", "public", "easel.tar.gz"); const MANIFEST = join(REPO, "system", "public", "easel.json"); // Written into the tarball so an install can tell what it is. Its absence is // how a git checkout knows never to overwrite itself with a release. -const STAMP = join(EASEL, "install.json"); +const STAMP = join(aesel, "install.json"); const INCLUDE = ["bin", "src", "shell", "context", "media", "package.json", "README.md", "LICENSE", "install.json"]; -const version = JSON.parse(readFileSync(join(EASEL, "package.json"), "utf8")).version; +const version = JSON.parse(readFileSync(join(aesel, "package.json"), "utf8")).version; // The stamp is part of the archive, so it is written before tarring and removed // after: a working checkout must not acquire one by having run this script. @@ -34,7 +34,7 @@ writeFileSync(STAMP, JSON.stringify({ version, packedAt: new Date().toISOString( for (const entry of INCLUDE) { try { - statSync(join(EASEL, entry)); + statSync(join(aesel, entry)); } catch { console.error(`missing: ${entry}`); process.exit(1); @@ -55,7 +55,7 @@ try { execFileSync("tar", [ ...extraFlags, "-czf", OUT, - "-C", EASEL, + "-C", aesel, ...INCLUDE, ], { stdio: "inherit" }); @@ -64,7 +64,7 @@ rmSync(STAMP, { force: true }); const bytes = readFileSync(OUT); const sha256 = createHash("sha256").update(bytes).digest("hex"); -// What a running Easel fetches to decide whether it is behind. Kept to the four +// What a running aesel fetches to decide whether it is behind. Kept to the four // facts an updater needs, so it stays cheap enough to poll once a day. writeFileSync( MANIFEST, diff --git a/easel/bin/sync-context.mjs b/easel/bin/sync-context.mjs index e54725f21..ca2578ac8 100755 --- a/easel/bin/sync-context.mjs +++ b/easel/bin/sync-context.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node // sync-context — copy the Aesthetic Computer authoring guides into easel/context/. // -// Easel tells the model how to write an AC piece by naming the repo's guides and +// aesel tells the model how to write an AC piece by naming the repo's guides and // asking it to read them. That works inside the monorepo and nowhere else: the -// lookup is existsSync against the working directory, so an installed Easel +// lookup is existsSync against the working directory, so an installed aesel // opened on someone's Desktop passes along no AC knowledge at all. It becomes a // general-purpose editor that happens to publish to a URL. // @@ -23,14 +23,15 @@ import { fileURLToPath } from "node:url"; import { build as buildApiMap } from "./build-api-map.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); -const EASEL = join(HERE, ".."); -const REPO = join(EASEL, ".."); -const OUT = join(EASEL, "context"); +const aesel = join(HERE, ".."); +const REPO = join(aesel, ".."); +const OUT = join(aesel, "context"); // What a model needs to write a piece, and nothing else. Deliberately not the // whole repository: this is the knowledge that is about Aesthetic Computer // rather than about this checkout. export const BUNDLE = [ + ["easel/SCORE.md", "score.md", "the aesel piece workflow"], ["SCREEN.md", "screen.md", "how a piece draws on the AC canvas"], ["HAND.md", "hand.md", "how the code reads"], ["system/public/aesthetic.computer/disks/CLAUDE.md", "pieces.md", "the piece authoring guide"], @@ -38,7 +39,7 @@ export const BUNDLE = [ ]; const header = (from, subject) => - `\n\n`; + `\n\n`; export function build() { mkdirSync(OUT, { recursive: true }); @@ -47,7 +48,7 @@ export function build() { return { path: join(OUT, to), body, from, to, subject }; }); // The API map travels the same way, for the same reason: it is read off the - // runtime source, which an installed Easel does not have. + // runtime source, which an installed aesel does not have. guides.push({ path: join(OUT, "api.json"), body: buildApiMap(), @@ -55,6 +56,10 @@ export function build() { to: "api.json", subject: "the piece API map", }); + for(const file of ["lib/cam-doll.mjs","lib/graph.mjs","lib/disk.mjs","lib/num.mjs","lib/geo.mjs","lib/ui.mjs","disks/camera.mjs","disks/1v1.mjs","disks/notepat.mjs","disks/blank.mjs"]){ + const to="reference/"+file,from="system/public/aesthetic.computer/"+file; + guides.push({path:join(OUT,to),body:readFileSync(join(REPO,from),"utf8"),from,to}); + } return guides; } @@ -68,6 +73,7 @@ for (const file of build()) { if (check) { console.error(`stale: context/${file.to} no longer matches ${file.from}`); } else { + mkdirSync(dirname(file.path),{recursive:true}); writeFileSync(file.path, file.body); console.log(`wrote context/${file.to} (${(file.body.length / 1024).toFixed(1)} KB)`); } diff --git a/easel/context/api.json b/easel/context/api.json index 07e481e39..585483446 100644 --- a/easel/context/api.json +++ b/easel/context/api.json @@ -120,7 +120,8 @@ "source": "lib/graph.mjs:2797", "examples": [ "disks/flap.mjs:50 if (store[`flap~${num}`]) stamp(store[`flap~${num}`], screen.width / 2, screen.height / 2);", - "disks/graphics.mjs:28 stamp(" + "disks/graphics.mjs:28 stamp(", + "disks/kokazo.mjs:243 stamp(ink, color, x, y, jx, jy, r);" ] }, { @@ -654,7 +655,7 @@ "path": "sound.synth", "signature": "sound.synth({ tone = 440, type = \"square\", duration = 0.1, beats = undefined, attack = 0.01, decay = 0.9, volume, pan = 0, immediate = false, probe = null, generator = null, })", "doc": "Play a synthesized tone. `tone` is Hz or a note name like \"c4\"; returns a voice with .kill() and .update().", - "source": "lib/disk.mjs:13270", + "source": "lib/disk.mjs:13283", "examples": [ "disks/$.mjs:646 sound.synth({", "disks/1but.mjs:66 sound.synth({ type: \"triangle\", tone: 1047, attack: 0, decay: 0.1, duration: 0.1, volume: 0.25 });", @@ -666,7 +667,7 @@ "path": "sound.play", "signature": "sound.play(sfx, options, callbacks)", "doc": "Play a loaded sample or sfx by id.", - "source": "lib/disk.mjs:13185", + "source": "lib/disk.mjs:13198", "examples": [ "disks/1v1.mjs:695 bgmPlaying = sound.play(bgmSfx, { loop: true, volume: 0.4 });", "disks/1v1.mjs:763 bgmPlaying = sound.play(bgmSfx, { loop: true, volume: 0.4 });", @@ -730,7 +731,7 @@ "examples": [ "disks/bgm.mjs:26 if (params.length === 0) params[0] = num.randInt(trackCount - 1);", "disks/crayon.mjs:86 let numDots = num.randInt(minNumDots, maxNumDots);", - "disks/doodle.mjs:76 const x = points[i][0] + -1 + num.randInt(2);" + "disks/dafu.mjs:101 const speed = num.randInt(20) / 10 + 0.4;" ] }, { diff --git a/easel/context/hand.md b/easel/context/hand.md index 2ede44e7e..45a93fd4a 100644 --- a/easel/context/hand.md +++ b/easel/context/hand.md @@ -1,5 +1,5 @@ # The Hand — a code style guide for Aesthetic Computer diff --git a/easel/context/kidlisp.md b/easel/context/kidlisp.md index 755856822..f182a4cd3 100644 --- a/easel/context/kidlisp.md +++ b/easel/context/kidlisp.md @@ -1,5 +1,5 @@ # KidLisp diff --git a/easel/context/pieces.md b/easel/context/pieces.md index 0ff4328a8..82d48dddb 100644 --- a/easel/context/pieces.md +++ b/easel/context/pieces.md @@ -1,5 +1,5 @@ # Pieces — Authoring Guide diff --git a/easel/context/reference/disks/1v1.mjs b/easel/context/reference/disks/1v1.mjs new file mode 100644 index 000000000..ae358775e --- /dev/null +++ b/easel/context/reference/disks/1v1.mjs @@ -0,0 +1,2295 @@ +// 1v1, 2024.12.24.10.00 - Fixed self.id.slice error +// Multiplayer Quake-like FPS game. + +/* #region 📚 README + A real-time multiplayer first-person shooter. + Two players battle in a 3D arena with simple hitscan weapons. +#endregion */ + +/* #region 🏁 TODO + - [] Add UDP support for lower latency + - [] Add sound effects (gunshots, hits) + - [] Add death/respawn system + - [] Add kill counter and match timer + - [] Better player models (not just boxes) + + Done + - [x] Clone fps.mjs as foundation + - [x] Add socket networking + - [x] Basic player state management + - [x] Add gamepad debug HUD panel +#endregion */ + +// 🎮 Gamepad mapping utilities (shared with gamepad.mjs) +import { + getButtonName, + getAxisName, + getButtonColors, + getAxisColors, + getGamepadMapping +} from "../lib/gamepad-mappings.mjs"; + +// 📱 QR code for join URL +import { qrcode as qr } from "../dep/@akamfoad/qr/qr.mjs"; + +// 🎮 Mini gamepad diagram for HUD (simplified from gamepad.mjs) +function drawMiniControllerDiagram(ink, gp, x, y) { + const baseX = x; + const baseY = y; + const gamepadId = gp.id || "standard"; + const is8BitDo = gamepadId.includes("8BitDo") || gamepadId.includes("2dc8"); + + // Get button colors from mapping + const getColors = (btnIndex) => { + const colors = getButtonColors(gamepadId, btnIndex); + const isPressed = gp.pressedButtons.includes(btnIndex); + return isPressed ? colors.active : colors.inactive; + }; + + const width = is8BitDo ? 40 : 48; + const height = is8BitDo ? 20 : 24; + + // Check if any input is active + const hasActiveInput = gp.pressedButtons.length > 0 || + Object.values(gp.axes).some(v => Math.abs(parseFloat(v)) > 0.3); + + // Background + ink(hasActiveInput ? "slategray" : "dimgray").box(baseX, baseY, width, height); + + if (is8BitDo) { + // 8BitDo Micro layout - D-pad left, face buttons right + // D-Pad (using axes) + const dpadX = baseX + 8; + const dpadY = baseY + 10; + const dpadSize = 4; + + const axisX = parseFloat(gp.axes["0"] || 0); + const axisY = parseFloat(gp.axes["1"] || 0); + + ink(axisY < -0.3 ? "lime" : "gray").box(dpadX, dpadY - 5, dpadSize, dpadSize); // Up + ink(axisY > 0.3 ? "lime" : "gray").box(dpadX, dpadY + 5, dpadSize, dpadSize); // Down + ink(axisX < -0.3 ? "lime" : "gray").box(dpadX - 5, dpadY, dpadSize, dpadSize); // Left + ink(axisX > 0.3 ? "lime" : "gray").box(dpadX + 5, dpadY, dpadSize, dpadSize); // Right + ink("black").box(dpadX, dpadY, dpadSize, dpadSize); // Center + + // Face buttons (ABXY diamond) - right side + const faceX = baseX + 30; + const faceY = baseY + 10; + const btnSize = 4; + + ink(getColors(3)).box(faceX, faceY - 5, btnSize, btnSize); // X (top) + ink(getColors(1)).box(faceX, faceY + 5, btnSize, btnSize); // B (bottom) + ink(getColors(4)).box(faceX - 5, faceY, btnSize, btnSize); // Y (left) + ink(getColors(0)).box(faceX + 5, faceY, btnSize, btnSize); // A (right) + + // Shoulder buttons + ink(getColors(6)).box(baseX, baseY, 12, 2); // L + ink(getColors(7)).box(baseX + 28, baseY, 12, 2); // R + } else { + // Standard Xbox-style layout + // Left stick position + const lStickX = baseX + 10; + const lStickY = baseY + 8; + const axisX = parseFloat(gp.axes["0"] || 0); + const axisY = parseFloat(gp.axes["1"] || 0); + const lStickActive = Math.abs(axisX) > 0.1 || Math.abs(axisY) > 0.1; + + ink(lStickActive ? "cyan" : "gray").box(lStickX - 3, lStickY - 3, 6, 6, "line"); + const dotX = lStickX + Math.round(axisX * 2); + const dotY = lStickY + Math.round(axisY * 2); + ink("cyan").box(dotX, dotY, 1, 1); + + // D-pad (buttons 12-15) + const dpadX = baseX + 10; + const dpadY = baseY + 18; + const dpadSize = 3; + + ink(getColors(12)).box(dpadX, dpadY - 4, dpadSize, dpadSize); // Up + ink(getColors(13)).box(dpadX, dpadY + 4, dpadSize, dpadSize); // Down + ink(getColors(14)).box(dpadX - 4, dpadY, dpadSize, dpadSize); // Left + ink(getColors(15)).box(dpadX + 4, dpadY, dpadSize, dpadSize); // Right + + // Face buttons (right side) + const faceX = baseX + 38; + const faceY = baseY + 10; + const btnSize = 4; + + ink(getColors(3)).box(faceX, faceY - 5, btnSize, btnSize); // Y (top) + ink(getColors(0)).box(faceX, faceY + 5, btnSize, btnSize); // A (bottom) + ink(getColors(2)).box(faceX - 5, faceY, btnSize, btnSize); // X (left) + ink(getColors(1)).box(faceX + 5, faceY, btnSize, btnSize); // B (right) + + // Right stick + const rStickX = baseX + 28; + const rStickY = baseY + 18; + const axis2 = parseFloat(gp.axes["2"] || 0); + const axis3 = parseFloat(gp.axes["3"] || 0); + const rStickActive = Math.abs(axis2) > 0.1 || Math.abs(axis3) > 0.1; + + ink(rStickActive ? "yellow" : "gray").box(rStickX - 3, rStickY - 3, 6, 6, "line"); + const dot2X = rStickX + Math.round(axis2 * 2); + const dot2Y = rStickY + Math.round(axis3 * 2); + ink("yellow").box(dot2X, dot2Y, 1, 1); + + // Bumpers/Triggers + ink(getColors(4)).box(baseX + 2, baseY, 10, 2); // LB + ink(getColors(6)).box(baseX + 2, baseY + 2, 8, 2); // LT + ink(getColors(5)).box(baseX + 36, baseY, 10, 2); // RB + ink(getColors(7)).box(baseX + 38, baseY + 2, 8, 2); // RT + } +} + +// Draw mini keyboard controls display (WASD + Arrows + Mouse) - compact version +function drawKeyboardControls(ink, baseX, baseY, pressedKeys = {}) { + const keySize = 8; + const keySpacing = 1; + const hudFont = "MatrixChunky8"; + + // Helper to draw a key + const drawKey = (x, y, label, isPressed) => { + const bgColor = isPressed ? [255, 255, 0, 220] : [40, 40, 40, 180]; + const textColor = isPressed ? [0, 0, 0] : [200, 200, 200]; + ink(...bgColor).box(x, y, keySize, keySize); + ink(100, 100, 100).box(x, y, keySize, keySize, "outline"); + ink(...textColor).write(label, { x: x + 2, y: y + 1 }, undefined, undefined, false, hudFont); + }; + + // Row 1: W and UP arrow (with labels inline) + let row1Y = baseY; + ink(100, 100, 100).write("W", { x: baseX, y: row1Y + 1 }, undefined, undefined, false, hudFont); + drawKey(baseX + 8, row1Y, "^", pressedKeys.w); + + ink(100, 100, 100).write("^", { x: baseX + 35, y: row1Y + 1 }, undefined, undefined, false, hudFont); + drawKey(baseX + 43, row1Y, "^", pressedKeys.arrowup); + + // Row 2: ASD and arrow keys + let row2Y = baseY + keySize + keySpacing; + drawKey(baseX, row2Y, "A", pressedKeys.a); + drawKey(baseX + keySize + keySpacing, row2Y, "S", pressedKeys.s); + drawKey(baseX + (keySize + keySpacing) * 2, row2Y, "D", pressedKeys.d); + + drawKey(baseX + 35, row2Y, "<", pressedKeys.arrowleft); + drawKey(baseX + 35 + keySize + keySpacing, row2Y, "v", pressedKeys.arrowdown); + drawKey(baseX + 35 + (keySize + keySpacing) * 2, row2Y, ">", pressedKeys.arrowright); + + // Row 3: Space and Shift + let row3Y = baseY + (keySize + keySpacing) * 2 + 2; + const spaceBg = pressedKeys.space ? [255, 255, 0, 220] : [40, 40, 40, 180]; + const spaceText = pressedKeys.space ? [0, 0, 0] : [200, 200, 200]; + ink(...spaceBg).box(baseX, row3Y, 28, keySize); + ink(100, 100, 100).box(baseX, row3Y, 28, keySize, "outline"); + ink(...spaceText).write("SPC", { x: baseX + 6, y: row3Y + 1 }, undefined, undefined, false, hudFont); + + const shiftBg = pressedKeys.shift ? [255, 255, 0, 220] : [40, 40, 40, 180]; + const shiftText = pressedKeys.shift ? [0, 0, 0] : [200, 200, 200]; + ink(...shiftBg).box(baseX + 32, row3Y, 20, keySize); + ink(100, 100, 100).box(baseX + 32, row3Y, 20, keySize, "outline"); + ink(...shiftText).write("SH", { x: baseX + 36, y: row3Y + 1 }, undefined, undefined, false, hudFont); + + // Mouse icon (compact) + const mouseX = baseX + 58; + const mouseY = baseY; + ink(60, 60, 60).box(mouseX, mouseY, 10, 16); + ink(80, 80, 80).box(mouseX, mouseY, 10, 16, "outline"); + ink(pressedKeys.lmb ? "yellow" : "gray").box(mouseX + 1, mouseY + 1, 4, 5); + ink(pressedKeys.rmb ? "yellow" : "gray").box(mouseX + 5, mouseY + 1, 4, 5); +} + +// 📱 Draw join QR code and IP in bottom-right corner +function drawJoinQR({ ink, box, write, screen }) { + if (!joinQRCells || !showJoinQR) return; + + const hudFont = "MatrixChunky8"; + + // Calculate QR size - compact but still scannable + const scale = 1; // Small scale for compact display + const qrSize = joinQRCells.length * scale; + const margin = 8; + const textHeight = 20; // Space for URL and host label + + // Position in bottom-right corner + const ox = screen.width - qrSize - margin; + const oy = screen.height - qrSize - margin - textHeight; + + // Semi-transparent background + ink(0, 0, 0, 180).box(ox - 4, oy - 4, qrSize + 8, qrSize + textHeight + 8); + + // Draw QR code cells + for (let y = 0; y < joinQRCells.length; y++) { + for (let x = 0; x < joinQRCells.length; x++) { + const isBlack = joinQRCells[y][x]; + ink(isBlack ? 0 : 255).box(ox + x * scale, oy + y * scale, scale); + } + } + + // White border around QR + ink(255, 255, 255).box(ox - 1, oy - 1, qrSize + 2, qrSize + 2, "outline"); + + // Display URL below QR (strip https:// for brevity) + // Right-align text to prevent cutoff (approx 6px per char) + const displayIP = joinURL?.replace('https://', '').replace('/1v1', '') || ''; + const ipTextWidth = displayIP.length * 6; + const ipX = screen.width - margin - Math.max(ipTextWidth, qrSize); + ink(255, 255, 0).write(displayIP, { x: ipX, y: oy + qrSize + 4 }, undefined, undefined, false, hudFont); + + // Show host machine label if available (also right-aligned) + if (hostMachineInfo?.hostLabel) { + const labelWidth = hostMachineInfo.hostLabel.length * 6; + const labelX = screen.width - margin - Math.max(labelWidth, qrSize); + ink(200, 200, 200).write(hostMachineInfo.hostLabel, + { x: labelX, y: oy + qrSize + 12 }, undefined, undefined, false, hudFont); + } else if (hostMachineInfo?.hostName) { + const nameWidth = hostMachineInfo.hostName.length * 6; + const nameX = screen.width - margin - Math.max(nameWidth, qrSize); + ink(180, 180, 180).write(hostMachineInfo.hostName, + { x: nameX, y: oy + qrSize + 12 }, undefined, undefined, false, hudFont); + } +} + +// Track keyboard state for display +let pressedKeys = {}; + +// Helper function to create face geometry (eyes + mouth) for character visualization +// Creates primitive lines that form a simple face on the front of the camera box +function createFaceGeometry(boxSize, r, g, b) { + // Face is on the FRONT of the box (negative Z) + const frontZ = -boxSize - 0.01; // Slightly in front of the box + + // Eye positions (two circles/diamonds made of lines) + const eyeY = boxSize * 0.3; // Eyes above center + const eyeSpacing = boxSize * 0.5; // Distance between eyes + const eyeSize = boxSize * 0.2; // Size of each eye + + // Mouth position (curved line or simple smile) + const mouthY = -boxSize * 0.3; // Below center + const mouthWidth = boxSize * 0.6; + const mouthCurve = boxSize * 0.15; + + const positions = [ + // Left eye (diamond shape) + [-eyeSpacing, eyeY + eyeSize, frontZ, 1], [-eyeSpacing - eyeSize, eyeY, frontZ, 1], + [-eyeSpacing - eyeSize, eyeY, frontZ, 1], [-eyeSpacing, eyeY - eyeSize, frontZ, 1], + [-eyeSpacing, eyeY - eyeSize, frontZ, 1], [-eyeSpacing + eyeSize, eyeY, frontZ, 1], + [-eyeSpacing + eyeSize, eyeY, frontZ, 1], [-eyeSpacing, eyeY + eyeSize, frontZ, 1], + + // Right eye (diamond shape) + [eyeSpacing, eyeY + eyeSize, frontZ, 1], [eyeSpacing - eyeSize, eyeY, frontZ, 1], + [eyeSpacing - eyeSize, eyeY, frontZ, 1], [eyeSpacing, eyeY - eyeSize, frontZ, 1], + [eyeSpacing, eyeY - eyeSize, frontZ, 1], [eyeSpacing + eyeSize, eyeY, frontZ, 1], + [eyeSpacing + eyeSize, eyeY, frontZ, 1], [eyeSpacing, eyeY + eyeSize, frontZ, 1], + + // Pupils (small dots represented as tiny X) + [-eyeSpacing - eyeSize*0.2, eyeY - eyeSize*0.2, frontZ - 0.01, 1], [-eyeSpacing + eyeSize*0.2, eyeY + eyeSize*0.2, frontZ - 0.01, 1], + [-eyeSpacing + eyeSize*0.2, eyeY - eyeSize*0.2, frontZ - 0.01, 1], [-eyeSpacing - eyeSize*0.2, eyeY + eyeSize*0.2, frontZ - 0.01, 1], + [eyeSpacing - eyeSize*0.2, eyeY - eyeSize*0.2, frontZ - 0.01, 1], [eyeSpacing + eyeSize*0.2, eyeY + eyeSize*0.2, frontZ - 0.01, 1], + [eyeSpacing + eyeSize*0.2, eyeY - eyeSize*0.2, frontZ - 0.01, 1], [eyeSpacing - eyeSize*0.2, eyeY + eyeSize*0.2, frontZ - 0.01, 1], + + // Mouth (smile made of 3 line segments for curve) + [-mouthWidth, mouthY, frontZ, 1], [-mouthWidth * 0.3, mouthY - mouthCurve, frontZ, 1], + [-mouthWidth * 0.3, mouthY - mouthCurve, frontZ, 1], [mouthWidth * 0.3, mouthY - mouthCurve, frontZ, 1], + [mouthWidth * 0.3, mouthY - mouthCurve, frontZ, 1], [mouthWidth, mouthY, frontZ, 1], + ]; + + // Colors: eyes in player color, pupils in white, mouth in player color + const eyeColor = [r, g, b, 1]; + const pupilColor = [1, 1, 1, 1]; // White pupils + const mouthColor = [r, g, b, 1]; + + const colors = [ + // Left eye (8 vertices = 4 lines) + eyeColor, eyeColor, eyeColor, eyeColor, eyeColor, eyeColor, eyeColor, eyeColor, + // Right eye (8 vertices = 4 lines) + eyeColor, eyeColor, eyeColor, eyeColor, eyeColor, eyeColor, eyeColor, eyeColor, + // Pupils (8 vertices = 4 lines for 2 X's) + pupilColor, pupilColor, pupilColor, pupilColor, pupilColor, pupilColor, pupilColor, pupilColor, + // Mouth (6 vertices = 3 lines) + mouthColor, mouthColor, mouthColor, mouthColor, mouthColor, mouthColor, + ]; + + return { positions, colors }; +} + +// Networking +let server; +let self = { + id: null, + handle: "player", + pos: { x: 0, y: 1.6, z: 0 }, + rot: { x: 0, y: 0, z: 0 }, + vel: { x: 0, y: 0, z: 0 }, + health: 100, + kills: 0, + deaths: 0, + lastShot: 0, +}; +let others = {}; // Map of other players by ID +let gameState = "connecting"; // connecting, lobby, playing, dead, gameover +let frameCount = 0; // Global frame counter for throttling + +// 🤖 Bot system - runs around inside the main square for players to follow +let bot = { + enabled: true, + pos: { x: 0, y: -0.5, z: 0 }, // Start at center, match player eye height (Y=-0.5) + rot: { x: 0, y: 0, z: 0 }, + vel: { x: 0, y: 0, z: 0 }, + health: 100, + state: "patrol", // patrol, chase, flee, dead + targetPos: null, + // Patrol points inside the main square (ground is -3 to +3 on X/Z) + patrolPoints: [ + { x: 2, z: 2 }, // Front-right + { x: -2, z: 2 }, // Front-left + { x: -2, z: -2 }, // Back-left + { x: 2, z: -2 }, // Back-right + { x: 0, z: 0 }, // Center (adds variety) + ], + patrolIndex: 0, + speed: 0.015, // Slow, chill movement + turnSpeed: 1.5, // Slow, smooth turning + pauseTimer: 0, // Pause at waypoints + lastStateChange: 0, + respawnTimer: 0, +}; +let botModel = null; // Visual representation + +// 🎭 Spectator mode - activated when same handle joins from another tab/device +let isSpectator = false; +let spectatorReason = null; // Why we're in spectator mode + +// 🩰 Network connectivity tracking +let udpConnected = false; +let wsConnected = false; +let lastUdpReceiveTime = 0; +let lastWsReceiveTime = 0; +let udpMessageCount = 0; +let wsMessageCount = 0; +let networkDebugLog = []; // Rolling log of recent network events +const MAX_DEBUG_LOG = 20; + +// 🎮 Gamepad state tracking for HUD display +let showGamepadPanel = true; // Toggle with 'G' key (default ON) +const connectedGamepads = {}; + +// 📱 Join QR code state +let joinQRCells = null; // QR code cell grid +let joinURL = null; // URL displayed below QR +let showJoinQR = true; // Toggle with 'Q' key (default ON) +let hostMachineInfo = null; // Host machine identity (MacBook, Thinkpad, etc) + +// 🎵 Background Music +let bgmSfx = null; // The preloaded audio handle +let bgmPlaying = null; // The playing audio instance +let bgmLoaded = false; // Track if audio is loaded + +// Action mappings - what each control does in FPS mode +const FPS_ACTIONS = { + buttons: { + // 8BitDo Micro mappings (when no right stick) + "8bitdo_0": "Look Right", // A - right face button + "8bitdo_1": "Look Down", // B - bottom face button + "8bitdo_3": "Look Up", // X - top face button + "8bitdo_4": "Look Left", // Y - left face button + "8bitdo_6": "L Shoulder", // L + "8bitdo_7": "R Shoulder", // R + "8bitdo_10": "Select", // Select + "8bitdo_11": "Start", // Start + // Standard controller mappings + "standard_0": "A/Jump", + "standard_1": "B/Back", + "standard_2": "X/Action", + "standard_3": "Y/Reload", + "standard_4": "LB", + "standard_5": "RB", + "standard_6": "LT", + "standard_7": "RT/Shoot", + "standard_10": "LS Click", + "standard_11": "RS Click", + "standard_12": "D-Up", + "standard_13": "D-Down", + "standard_14": "D-Left", + "standard_15": "D-Right", + }, + axes: { + // For 8BitDo Micro (D-pad on axes) + "8bitdo_0": { neg: "Strafe Left", pos: "Strafe Right" }, + "8bitdo_1": { neg: "Move Forward", pos: "Move Back" }, + // Standard controller (left stick move, right stick look) + "standard_0": { neg: "Strafe Left", pos: "Strafe Right" }, + "standard_1": { neg: "Move Forward", pos: "Move Back" }, + "standard_2": { neg: "Look Left", pos: "Look Right" }, + "standard_3": { neg: "Look Up", pos: "Look Down" }, + } +}; + +function logNetwork(msg, level = "info") { + const entry = { time: Date.now(), msg, level }; + networkDebugLog.push(entry); + if (networkDebugLog.length > MAX_DEBUG_LOG) networkDebugLog.shift(); + const prefix = level === "error" ? "❌" : level === "warn" ? "⚠️" : "📡"; + console.log(`${prefix} [1v1 NET] ${msg}`); +} + +// 3D Scene +let cube, triangle, filledTriangle, texturedQuad, quadTexture, groundPlane, groundTexture, groundWireframe, penLocked = false; +// Camera frustums are now created dynamically per player in playerBoxes +let showWireframes = true; // Toggle with 'V' key (start with wireframes ON) +let graphAPI; // Store graph API reference +let graphInstance; // Store graph instance for camera access +let systemInstance; // Store system reference for render stats access +let showDebugPanel = false; // Toggle with 'P' key (start OFF) +let frameTimes = []; // Track frame times for FPS calculation +let lastFrameTime = performance.now(); +let paintingTextureFetchPromise; // Track ongoing painting texture fetch +let paintingTextureLoaded = false; // Track if we've loaded the painting + +// 3D Text (sign) - stored from boot for use in networking callbacks +let globalSign; +let globalGlyphs; + +// 🩰 UDP channel for low-latency position updates +let udpChannel; + +// Combat +const SHOOT_COOLDOWN = 200; // ms between shots +const DAMAGE_PER_HIT = 20; +let playerBoxes = {}; // Visual representation of other players + +// 🎯 Target tracking system - for looking at other players/bot +let currentTarget = null; // Current target to look at (player id, "bot", or null) +let targetList = []; // List of available targets +let isAutoTracking = false; // Whether to continuously face the target +const TURN_SPEED = 3; // Degrees per frame when auto-tracking + +// Calculate yaw angle to face a target position from our position +function getAngleToTarget(fromPos, toPos) { + const dx = toPos.x - fromPos.x; + const dz = toPos.z - fromPos.z; + // atan2 gives angle in radians, convert to degrees + // NEGATE because camera.rotY is internally negated in the transform matrix + // This makes rotY=0 look toward +Z, rotY=90 look toward -X + return -Math.atan2(dx, dz) * (180 / Math.PI); +} + +// Get the position of the current target +function getTargetPosition() { + if (!currentTarget) return null; + + if (currentTarget === "bot" && bot.enabled) { + return bot.pos; + } + + // Find player by id + const player = others[currentTarget]; + if (player) { + return player.pos; + } + + return null; +} + +// Update the list of available targets (other players + bot) +function updateTargetList() { + targetList = []; + + // Add bot if enabled + if (bot.enabled && bot.state !== "dead") { + targetList.push("bot"); + } + + // Add all other players + for (const id of Object.keys(others)) { + targetList.push(id); + } + + // If current target is no longer valid, clear it + if (currentTarget && !targetList.includes(currentTarget)) { + currentTarget = null; + isAutoTracking = false; + } +} + +// Cycle to next target +function cycleTarget() { + updateTargetList(); + + if (targetList.length === 0) { + currentTarget = null; + logNetwork("No targets available"); + return; + } + + const currentIndex = currentTarget ? targetList.indexOf(currentTarget) : -1; + const nextIndex = (currentIndex + 1) % targetList.length; + currentTarget = targetList[nextIndex]; + + const targetName = currentTarget === "bot" ? "🤖 BOT" : (others[currentTarget]?.handle || currentTarget); + logNetwork(`🎯 Target: ${targetName}`); +} + +// Snap camera to face current target instantly +function snapToTarget() { + if (!currentTarget || !graphInstance) return; + + const targetPos = getTargetPosition(); + if (!targetPos) return; + + const angle = getAngleToTarget(self.pos, targetPos); + graphInstance.rotY = angle; + + const targetName = currentTarget === "bot" ? "🤖 BOT" : (others[currentTarget]?.handle || currentTarget); + logNetwork(`👁️ Snapped to ${targetName}`); +} + +// Toggle auto-tracking mode +function toggleAutoTrack() { + if (!currentTarget) { + // If no target, select one first + cycleTarget(); + } + isAutoTracking = !isAutoTracking; + logNetwork(`🎯 Auto-track: ${isAutoTracking ? "ON" : "OFF"}`); +} + +// Function to log detailed scene debug info +function logSceneDebug() { + if (!graphInstance) { + console.error("❌ Camera not available"); + return; + } + + // Access render stats directly from system.fps.renderStats + const stats = systemInstance?.fps?.renderStats; + const hasStats = stats && typeof stats.originalTriangles === 'number'; + + // Build warning messages + let warnings = []; + if (hasStats) { + if (stats.pixelsDrawn > 50000) { + warnings.push("High pixel count - large triangle(s) filling screen"); + } + if (stats.subdividedTriangles > 10) { + warnings.push("High subdivision - triangle edge at screen boundary"); + } + } + + console.log( + "%c FPS %c·%c Scene Debug %c\n\n" + + + "%c Camera \n" + + `%c position %c(${graphInstance.x.toFixed(2)}, ${graphInstance.y.toFixed(2)}, ${graphInstance.z.toFixed(2)})%c\n` + + `%c looking %cpitch ${graphInstance.rotX.toFixed(1)}° · yaw ${graphInstance.rotY.toFixed(1)}° · roll ${graphInstance.rotZ.toFixed(1)}°%c\n\n` + + + "%c Objects \n" + + `%c cube %cat (${cube.position[0]}, ${cube.position[1]}, ${cube.position[2]}) · spinning · wireframe%c\n` + + `%c triangle %cat (${triangle.position[0]}, ${triangle.position[1]}, ${triangle.position[2]}) · gradient · wireframe%c\n` + + `%c triangle %cat (${filledTriangle.position[0]}, ${filledTriangle.position[1]}, ${filledTriangle.position[2]}) · filled · gradient%c\n` + + `%c quad %cat (${texturedQuad.position[0]}, ${texturedQuad.position[1]}, ${texturedQuad.position[2]}) · textured · subdivided%c\n` + + `%c ground %cat (${groundPlane.position[0]}, ${groundPlane.position[1]}, ${groundPlane.position[2]}) · 6×6 units · textured%c\n\n` + + + (hasStats + ? "%c Performance \n" + + `%c triangles %c${stats.originalTriangles} original · ${stats.clippedTriangles} clipped · ${stats.subdividedTriangles} subdivided · ${stats.trianglesRejected || 0} rejected%c\n` + + `%c pixels %c${stats.pixelsDrawn?.toLocaleString() || 0}${stats.pixelsDrawn > 50000 ? " %c⚠%c" : "%c %c"}%c drawn this frame%c\n` + + `%c wireframe %c${stats.wireframeSegmentsTotal} segments%c\n` + : "%c Performance \n" + + `%c waiting for first frame to render%c\n` + ) + + + (warnings.length > 0 + ? "\n%c ⚠ Warning \n" + + `%c ${warnings.join("\n ")}%c\n` + : "" + ), + + // Title styles - pink and cyan like Aesthetic.Computer + "background: rgba(199, 21, 133, 0.8); color: rgb(252, 231, 243); font-weight: bold; font-size: 11px; padding: 2px 6px; border-radius: 3px 0 0 3px;", + "color: #4ecdc4; font-weight: bold; font-size: 11px;", + "background: rgba(78, 205, 196, 0.8); color: rgb(10, 20, 40); font-weight: bold; font-size: 11px; padding: 2px 6px; border-radius: 0 3px 3px 0;", + "", + + // Camera section - cyan theme + "color: #4ecdc4; font-weight: bold; font-size: 12px;", + "color: #6c757d; font-size: 10px;", "color: #adb5bd; font-size: 10px;", "", + "color: #6c757d; font-size: 10px;", "color: #adb5bd; font-size: 10px;", "", + + // Objects section - pink theme + "color: #ff6b9d; font-weight: bold; font-size: 12px;", + "color: #6c757d; font-size: 10px;", "color: #adb5bd; font-size: 10px;", "", + "color: #6c757d; font-size: 10px;", "color: #adb5bd; font-size: 10px;", "", + "color: #6c757d; font-size: 10px;", "color: #adb5bd; font-size: 10px;", "", + "color: #6c757d; font-size: 10px;", "color: #ffc107; font-size: 10px;", "", + "color: #6c757d; font-size: 10px;", "color: #adb5bd; font-size: 10px;", "", + + // Performance section - green/yellow theme + ...(hasStats + ? [ + "color: #6ee7b7; font-weight: bold; font-size: 12px;", + "color: #6c757d; font-size: 10px;", "color: #adb5bd; font-size: 10px;", "", + "color: #6c757d; font-size: 10px;", "color: #adb5bd; font-size: 10px;", + stats.pixelsDrawn > 50000 ? "color: #ffc107; font-weight: bold;" : "", "", + "color: #6c757d; font-size: 10px;", "", + "color: #6c757d; font-size: 10px;", "color: #adb5bd; font-size: 10px;", "" + ] + : [ + "color: #6ee7b7; font-weight: bold; font-size: 12px;", + "color: #6c757d; font-size: 10px; font-style: italic;", "" + ] + ), + + // Warning section - yellow/red + ...(warnings.length > 0 + ? [ + "color: #ffc107; font-weight: bold; font-size: 12px;", + "color: #ffb74d; font-size: 10px;", "" + ] + : [] + ) + ); +} + + +function boot({ Form, CUBEL, QUAD, penLock, system, get, net: { socket, udp, host, lan, devIdentity, preload }, handle, help, sign, glyphs, platform, sound }) { + console.log("🎮 1v1 boot", { hasForm: !!Form, hasSocket: !!socket, hasHandle: !!handle, hasSign: !!sign, hasUdp: !!udp }); + + // Store sign and glyphs for use in networking callbacks + globalSign = sign; + globalGlyphs = glyphs; + + penLock(); + + // Store system and graph instance for camera and stats access + systemInstance = system; + graphInstance = system?.fps?.doll?.cam; + + // 📱 Generate join QR code + if (lan) { + joinURL = `${lan}/1v1`; + } else if (host?.startsWith("localhost")) { + joinURL = "https://local.aesthetic.computer/1v1"; + } else if (host) { + joinURL = `https://${host}/1v1`; + } + + if (joinURL) { + try { + // Use low error correction (L) for smaller QR code + joinQRCells = qr(joinURL, { errorCorrectLevel: 1 }).modules; // 1 = L (7% recovery) + console.log("📱 Join QR generated:", joinURL, `(${joinQRCells?.length}x${joinQRCells?.length} low-EC)`); + } catch (e) { + console.warn("📱 Failed to generate QR:", e); + } + } + + // Store host machine identity if available + hostMachineInfo = devIdentity || null; + if (hostMachineInfo) { + console.log("🖥️ Host machine:", hostMachineInfo.hostLabel || hostMachineInfo.hostName); + } + + // 🎵 Load background music (using simple name like booted-by.mjs) + preload("oskie/tokyo-90bpm").then((sfx) => { + bgmSfx = sfx; + bgmLoaded = true; + console.log("🎵 1v1: Background music loaded"); + // Start playing immediately if we're already connected + if (wsConnected && !bgmPlaying && sound) { + bgmPlaying = sound.play(bgmSfx, { loop: true, volume: 0.4 }); + console.log("🎵 1v1: Background music started (post-load)"); + } + }).catch((err) => { + console.warn("🎵 1v1: Failed to load background music:", err); + }); + + // Set player handle - use logged-in handle or generate unique guest handle + // Add random suffix to prevent collisions in split view (same browser, two tabs) + const randomSuffix = Math.floor(Math.random() * 1000); + self.handle = handle() || help.choose("red", "blue", "green", "yellow", "orange", "purple", "cyan", "pink") + "_" + randomSuffix; + + // 🩰 Initialize UDP for low-latency position updates + udpChannel = udp((type, content) => { + udpMessageCount++; + lastUdpReceiveTime = Date.now(); + // Log occasionally (~5%) to avoid spamming + if (Math.random() < 0.05) { + logNetwork(`UDP recv: ${type} from ${content?.handle || 'unknown'}`); + } + if (type === "1v1:move") { + // Parse content if it's a string + const data = typeof content === 'string' ? JSON.parse(content) : content; + + // Find player by handle (UDP doesn't use WebSocket IDs) + if (data.handle && data.handle !== self.handle) { + const playerId = Object.keys(others).find(id => others[id].handle === data.handle); + if (playerId && others[playerId]) { + // Update position and rotation - paint() will use these to move frustums + others[playerId].pos = data.pos; + others[playerId].rot = data.rot; + } else if (Math.random() < 0.01) { + // Log occasionally when we receive UDP for an unknown player + // This happens if UDP arrives before WebSocket join + logNetwork(`UDP move from unknown handle: ${data.handle} (known: ${Object.keys(others).map(id => others[id].handle).join(', ')})`, "warn"); + } + } + } + }); + logNetwork(`UDP channel initialized, connected: ${udpChannel?.connected}`); + udpConnected = udpChannel?.connected || false; + + // Initialize WebSocket networking (for reliable events: join/leave/combat) + + // Initialize WebSocket networking (for reliable events: join/leave/combat) + server = socket((id, type, content) => { + // Track WebSocket connectivity + wsMessageCount++; + lastWsReceiveTime = Date.now(); + + if (type === "left") { + logNetwork(`Player left: ${others[id]?.handle || id}`); + delete others[id]; + delete playerBoxes[id]; + } + + if (type === "joined") { + logNetwork(`Player joined notification: ${id}`); + } + + if (type.startsWith("connected")) { + self.id = id; + wsConnected = true; + gameState = "lobby"; + logNetwork(`WebSocket connected as: ${self.handle} (${id})`); + + // 🎵 Start background music when connected (if loaded) + if (bgmSfx && !bgmPlaying && sound) { + bgmPlaying = sound.play(bgmSfx, { loop: true, volume: 0.4 }); + console.log("🎵 1v1: Background music started"); + } + + // Send initial join message + server.send("1v1:join", { + handle: self.handle, + pos: self.pos, + rot: self.rot, + health: self.health, + }); + } + + // Handle messages from other players + if (server.id !== id) { + if (type === "1v1:join") { + // Only respond if we don't already know about this player + const isNewPlayer = !others[id]; + + logNetwork(`Player join request: ${content.handle} (${id})`); + + // 🎭 Check if this is a duplicate handle (same user from another tab/device) + if (content.handle === self.handle) { + isSpectator = true; + spectatorReason = "Same handle connected from another location"; + logNetwork(`SPECTATOR MODE: ${spectatorReason}`, "warn"); + } + + others[id] = { + handle: content.handle, + pos: content.pos || { x: 0, y: 1.6, z: 0 }, + rot: content.rot || { x: 0, y: 0, z: 0 }, + health: content.health || 100, + }; + + // Only send our state back to truly NEW players (not in response to their response) + if (isNewPlayer) { + server.send("1v1:state", { + handle: self.handle, + pos: self.pos, + rot: self.rot, + health: self.health, + }); + } + + // Create CAMERA FRUSTUM visualization for this player + // Shows where their camera is and what direction they're looking + if (!playerBoxes[id]) { + // Assign a color to this player based on index + const playerColors = [ + [0, 1, 0], // Lime + [1, 0.5, 0], // Orange + [0, 1, 1], // Cyan + [1, 0, 1], // Magenta + [1, 1, 0], // Yellow + [0.5, 0.5, 1], // Light blue + ]; + const colorIndex = Object.keys(playerBoxes).length % playerColors.length; + const playerColor = playerColors[colorIndex]; + const [r, g, b] = playerColor; + + // Camera frustum visualization + // A small box at camera position + pyramid showing view direction + const boxSize = 0.15; // Size of camera "head" box + const frustumLength = 0.5; // How far the view cone extends + const frustumSpread = 0.3; // Width of frustum at far end + + playerBoxes[id] = { + color: playerColor, + // Camera box (cube wireframe at camera position) + cameraBox: new Form( + { type: "line", positions: [ + // Front face + [-boxSize, -boxSize, -boxSize, 1], [boxSize, -boxSize, -boxSize, 1], + [boxSize, -boxSize, -boxSize, 1], [boxSize, boxSize, -boxSize, 1], + [boxSize, boxSize, -boxSize, 1], [-boxSize, boxSize, -boxSize, 1], + [-boxSize, boxSize, -boxSize, 1], [-boxSize, -boxSize, -boxSize, 1], + // Back face + [-boxSize, -boxSize, boxSize, 1], [boxSize, -boxSize, boxSize, 1], + [boxSize, -boxSize, boxSize, 1], [boxSize, boxSize, boxSize, 1], + [boxSize, boxSize, boxSize, 1], [-boxSize, boxSize, boxSize, 1], + [-boxSize, boxSize, boxSize, 1], [-boxSize, -boxSize, boxSize, 1], + // Connecting edges + [-boxSize, -boxSize, -boxSize, 1], [-boxSize, -boxSize, boxSize, 1], + [boxSize, -boxSize, -boxSize, 1], [boxSize, -boxSize, boxSize, 1], + [boxSize, boxSize, -boxSize, 1], [boxSize, boxSize, boxSize, 1], + [-boxSize, boxSize, -boxSize, 1], [-boxSize, boxSize, boxSize, 1], + ], colors: [ + // All edges in player color + [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], + [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], + [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], + [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], + [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], + [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], [r, g, b, 1], + ]}, + { pos: [content.pos.x, content.pos.y, content.pos.z], scale: 1 } + ), + // View frustum (pyramid pointing in look direction, -Z is forward) + frustum: new Form( + { type: "line", positions: [ + // Lines from camera to frustum corners + [0, 0, 0, 1], [-frustumSpread, -frustumSpread, -frustumLength, 1], + [0, 0, 0, 1], [frustumSpread, -frustumSpread, -frustumLength, 1], + [0, 0, 0, 1], [frustumSpread, frustumSpread, -frustumLength, 1], + [0, 0, 0, 1], [-frustumSpread, frustumSpread, -frustumLength, 1], + // Far plane rectangle + [-frustumSpread, -frustumSpread, -frustumLength, 1], [frustumSpread, -frustumSpread, -frustumLength, 1], + [frustumSpread, -frustumSpread, -frustumLength, 1], [frustumSpread, frustumSpread, -frustumLength, 1], + [frustumSpread, frustumSpread, -frustumLength, 1], [-frustumSpread, frustumSpread, -frustumLength, 1], + [-frustumSpread, frustumSpread, -frustumLength, 1], [-frustumSpread, -frustumSpread, -frustumLength, 1], + ], colors: [ + // Frustum edges - use red for visibility + [1, 0, 0, 1], [1, 0, 0, 1], + [1, 0, 0, 1], [1, 0, 0, 1], + [1, 0, 0, 1], [1, 0, 0, 1], + [1, 0, 0, 1], [1, 0, 0, 1], + // Far plane in player color + [r, g, b, 1], [r, g, b, 1], + [r, g, b, 1], [r, g, b, 1], + [r, g, b, 1], [r, g, b, 1], + [r, g, b, 1], [r, g, b, 1], + ]}, + { pos: [content.pos.x, content.pos.y, content.pos.z], scale: 1 } + ), + // Vertical line to ground (so you can see where they are standing) + groundLine: new Form( + { type: "line", positions: [ + [0, 0, 0, 1], [0, -2, 0, 1], // Line down to ground + ], colors: [ + [r, g, b, 0.5], [r, g, b, 0.5], // Semi-transparent + ]}, + { pos: [content.pos.x, content.pos.y, content.pos.z], scale: 1 } + ), + // Face (eyes + mouth on front of box) + face: new Form( + { type: "line", ...createFaceGeometry(boxSize, r, g, b) }, + { pos: [content.pos.x, content.pos.y, content.pos.z], scale: 1 } + ), + // Name sign above player (using 3D text) + nameSign: globalSign ? globalSign(content.handle || id.slice(0, 6), { + scale: 0.03, + color: playerColor, + align: "center", + glyphs: globalGlyphs?.("MatrixChunky8") || {}, + }) : null, + }; + console.log("🎮 Created camera frustum for:", id, content.handle, "color:", playerColor); + } + + gameState = "playing"; + } + + // Handle state response (sent by existing players to new joiners) + // This does NOT trigger a response back - breaks the infinite loop + if (type === "1v1:state") { + console.log(`Player state received:`, content.handle, id); + + // Update or create player entry + if (!others[id]) { + others[id] = { + handle: content.handle, + pos: content.pos || { x: 0, y: 1.6, z: 0 }, + rot: content.rot || { x: 0, y: 0, z: 0 }, + health: content.health || 100, + }; + + // Create player visualization (same as in 1v1:join) + if (!playerBoxes[id]) { + const playerColors = [ + [0, 1, 0], [1, 0.5, 0], [0, 1, 1], [1, 0, 1], [1, 1, 0], [0.5, 0.5, 1], + ]; + const colorIndex = Object.keys(playerBoxes).length % playerColors.length; + const playerColor = playerColors[colorIndex]; + const [r, g, b] = playerColor; + const boxSize = 0.15; + const frustumLength = 0.5; + const frustumSpread = 0.3; + + playerBoxes[id] = { + color: playerColor, + cameraBox: new Form( + { type: "line", positions: [ + [-boxSize, -boxSize, -boxSize, 1], [boxSize, -boxSize, -boxSize, 1], + [boxSize, -boxSize, -boxSize, 1], [boxSize, boxSize, -boxSize, 1], + [boxSize, boxSize, -boxSize, 1], [-boxSize, boxSize, -boxSize, 1], + [-boxSize, boxSize, -boxSize, 1], [-boxSize, -boxSize, -boxSize, 1], + [-boxSize, -boxSize, boxSize, 1], [boxSize, -boxSize, boxSize, 1], + [boxSize, -boxSize, boxSize, 1], [boxSize, boxSize, boxSize, 1], + [boxSize, boxSize, boxSize, 1], [-boxSize, boxSize, boxSize, 1], + [-boxSize, boxSize, boxSize, 1], [-boxSize, -boxSize, boxSize, 1], + [-boxSize, -boxSize, -boxSize, 1], [-boxSize, -boxSize, boxSize, 1], + [boxSize, -boxSize, -boxSize, 1], [boxSize, -boxSize, boxSize, 1], + [boxSize, boxSize, -boxSize, 1], [boxSize, boxSize, boxSize, 1], + [-boxSize, boxSize, -boxSize, 1], [-boxSize, boxSize, boxSize, 1], + ], colors: Array(24).fill([r, g, b, 1])}, + { pos: [content.pos.x, content.pos.y, content.pos.z], scale: 1 } + ), + frustum: new Form( + { type: "line", positions: [ + [0, 0, 0, 1], [-frustumSpread, -frustumSpread, -frustumLength, 1], + [0, 0, 0, 1], [frustumSpread, -frustumSpread, -frustumLength, 1], + [0, 0, 0, 1], [frustumSpread, frustumSpread, -frustumLength, 1], + [0, 0, 0, 1], [-frustumSpread, frustumSpread, -frustumLength, 1], + [-frustumSpread, -frustumSpread, -frustumLength, 1], [frustumSpread, -frustumSpread, -frustumLength, 1], + [frustumSpread, -frustumSpread, -frustumLength, 1], [frustumSpread, frustumSpread, -frustumLength, 1], + [frustumSpread, frustumSpread, -frustumLength, 1], [-frustumSpread, frustumSpread, -frustumLength, 1], + [-frustumSpread, frustumSpread, -frustumLength, 1], [-frustumSpread, -frustumSpread, -frustumLength, 1], + ], colors: Array(16).fill([1, 0, 0, 1])}, + { pos: [content.pos.x, content.pos.y, content.pos.z], scale: 1 } + ), + groundLine: new Form( + { type: "line", positions: [[0, 0, 0, 1], [0, -2, 0, 1]], colors: [[r, g, b, 0.5], [r, g, b, 0.5]]}, + { pos: [content.pos.x, content.pos.y, content.pos.z], scale: 1 } + ), + face: new Form( + { type: "line", ...createFaceGeometry(boxSize, r, g, b) }, + { pos: [content.pos.x, content.pos.y, content.pos.z], scale: 1 } + ), + nameSign: globalSign ? globalSign(content.handle || id.slice(0, 6), { + scale: 0.03, color: playerColor, align: "center", + glyphs: globalGlyphs?.("MatrixChunky8") || {}, + }) : null, + }; + console.log("🎮 Created visualization for existing player:", id, content.handle); + } + } else { + // Just update position if we already know them + others[id].pos = content.pos; + others[id].rot = content.rot; + others[id].health = content.health; + } + + gameState = "playing"; + } + + // WebSocket fallback for position (UDP is primary, but WS works if UDP fails) + if (type === "1v1:move") { + // Log occasionally for debugging + if (Math.random() < 0.05) { + logNetwork(`WS recv 1v1:move from ${content.handle || id} pos=${content.pos?.x?.toFixed(1)},${content.pos?.y?.toFixed(1)},${content.pos?.z?.toFixed(1)}`); + } + if (others[id]) { + others[id].pos = content.pos; + others[id].rot = content.rot; + + // Update player box position + if (playerBoxes[id]) { + playerBoxes[id].position = [content.pos.x, content.pos.y, content.pos.z]; + } + } else { + // We got a move from someone we don't know about yet + logNetwork(`WS 1v1:move from unknown player ${id}, handle=${content.handle}`, "warn"); + } + } + + if (type === "1v1:shoot") { + console.log("💥 Player shot:", id); + // TODO: Show muzzle flash or projectile + } + + if (type === "1v1:hit") { + if (content.targetId === self.id) { + self.health -= content.damage; + console.log(`💔 Hit! Health: ${self.health}`); + + if (self.health <= 0) { + self.health = 0; + self.deaths++; + gameState = "dead"; + console.log("💀 You died!"); + + // Notify killer + server.send("1v1:death", { killerId: id }); + } + } + } + + if (type === "1v1:death" && content.killerId === self.id) { + self.kills++; + console.log(`💀 Kill! Total: ${self.kills}`); + } + } + }); + + // Enable clipped wireframes by default if available + // Note: graphAPI will be set in paint function + + // Create a gradient quad (6 vertices = 2 triangles) + const quadPositions = [ + // Triangle 1 + [-1, -1, 0, 1], // Bottom Left + [-1, 1, 0, 1], // Top Left + [1, 1, 0, 1], // Top Right + // Triangle 2 + [-1, -1, 0, 1], // Bottom Left + [1, 1, 0, 1], // Top Right + [1, -1, 0, 1], // Bottom Right + ]; + + // Gradient colors: corners go Red, Green, Blue, Yellow + const quadColors = [ + [1.0, 0.0, 0.0, 1.0], // Bottom Left: RED + [0.0, 1.0, 0.0, 1.0], // Top Left: GREEN + [0.0, 0.0, 1.0, 1.0], // Top Right: BLUE + [1.0, 0.0, 0.0, 1.0], // Bottom Left: RED (repeated) + [0.0, 0.0, 1.0, 1.0], // Top Right: BLUE (repeated) + [1.0, 1.0, 0.0, 1.0], // Bottom Right: YELLOW + ]; + + texturedQuad = new Form( + { type: "triangle", positions: quadPositions, colors: quadColors }, + { pos: [-2, 0.5, -6], rot: [0, 0, 0], scale: 1 } + ); + + // Create a simple ground plane - just one quad (2 triangles) + // Very small size to avoid clipping issues + const groundSize = 3; // Very small ground plane (6x6 units total) + + const groundPositions = [ + // Triangle 1 (counter-clockwise winding when viewed from above) + [-groundSize, -1.5, -groundSize, 1], // Back Left + [-groundSize, -1.5, groundSize, 1], // Front Left + [groundSize, -1.5, groundSize, 1], // Front Right + // Triangle 2 + [-groundSize, -1.5, -groundSize, 1], // Back Left + [groundSize, -1.5, groundSize, 1], // Front Right + [groundSize, -1.5, -groundSize, 1], // Back Right + ]; + + // UV coordinates tile the texture across the ground + const groundTexCoords = [ + [0, 0], + [0, 2], // Tile 2x in each direction + [2, 2], + [0, 0], + [2, 2], + [2, 0] + ]; + + // White colors (6 vertices = 2 triangles) + const groundColors = [ + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0] + ]; + + groundPlane = new Form( + { type: "triangle", positions: groundPositions, colors: groundColors, texCoords: groundTexCoords }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 } + ); + + // Disable fading for the ground plane so it stays visible + groundPlane.noFade = true; + + console.log("🌍 Ground plane created:", groundPositions.length / 3, "triangles"); + + // Create wireframe version of ground plane for debugging + const wireframePositions = []; + const wireframeColors = []; + + // Convert each triangle to 3 lines (only 2 triangles now) + for (let i = 0; i < groundPositions.length; i += 3) { + const v0 = groundPositions[i]; + const v1 = groundPositions[i + 1]; + const v2 = groundPositions[i + 2]; + + // Line 1: v0 to v1 + wireframePositions.push(v0, v1); + wireframeColors.push([1.0, 1.0, 0.0, 1.0], [1.0, 1.0, 0.0, 1.0]); // Yellow + + // Line 2: v1 to v2 + wireframePositions.push(v1, v2); + wireframeColors.push([1.0, 1.0, 0.0, 1.0], [1.0, 1.0, 0.0, 1.0]); + + // Line 3: v2 to v0 + wireframePositions.push(v2, v0); + wireframeColors.push([1.0, 1.0, 0.0, 1.0], [1.0, 1.0, 0.0, 1.0]); + } + + groundWireframe = new Form( + { type: "line", positions: wireframePositions, colors: wireframeColors }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 } + ); + groundWireframe.noFade = true; + + // Create a spinning wireframe cube + cube = new Form( + CUBEL, + { pos: [0, 0.5, -4], rot: [0, 0, 0], scale: 1 }, + ); + + // Create a gradient wireframe triangle to the right (as line pairs) + const trianglePositions = [ + // Line 1: Top to Bottom-right + [0, 0.6, 0, 1], [0.5, -0.3, 0, 1], + // Line 2: Bottom-right to Bottom-left + [0.5, -0.3, 0, 1], [-0.5, -0.3, 0, 1], + // Line 3: Bottom-left to Top + [-0.5, -0.3, 0, 1], [0, 0.6, 0, 1], + ]; + + const triangleColors = [ + [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], // RED to GREEN + [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], // GREEN to BLUE + [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 1.0], // BLUE to RED + ]; + + triangle = new Form( + { type: "line", positions: trianglePositions, colors: triangleColors }, + { pos: [2, 0.5, -4], rot: [0, 0, 0], scale: 0.7 } + ); + + // Create a FILLED gradient triangle on the LEFT side of the cube + const filledTriPositions = [ + [0, 0.6, 0, 1], // Top vertex + [0.5, -0.3, 0, 1], // Bottom-right vertex + [-0.5, -0.3, 0, 1], // Bottom-left vertex + ]; + + const filledTriColors = [ + [1.0, 0.0, 0.0, 1.0], // RED at top + [0.0, 1.0, 0.0, 1.0], // GREEN at bottom-right + [0.0, 0.0, 1.0, 1.0], // BLUE at bottom-left + ]; + + filledTriangle = new Form( + { type: "triangle", positions: filledTriPositions, colors: filledTriColors }, + { pos: [-2, 0.5, -4], rot: [0, 0, 0], scale: 0.7 } + ); + + // 🤖 Create bot model (red/orange color scheme to distinguish from players) + if (bot.enabled) { + const boxSize = 0.2; + const frustumLength = 0.6; + const frustumSpread = 0.35; + const r = 1, g = 0.3, b = 0; // Orange-red color + + botModel = { + color: [r, g, b], + // Camera box (cube wireframe at bot position) + cameraBox: new Form( + { type: "line", positions: [ + // Front face + [-boxSize, -boxSize, -boxSize, 1], [boxSize, -boxSize, -boxSize, 1], + [boxSize, -boxSize, -boxSize, 1], [boxSize, boxSize, -boxSize, 1], + [boxSize, boxSize, -boxSize, 1], [-boxSize, boxSize, -boxSize, 1], + [-boxSize, boxSize, -boxSize, 1], [-boxSize, -boxSize, -boxSize, 1], + // Back face + [-boxSize, -boxSize, boxSize, 1], [boxSize, -boxSize, boxSize, 1], + [boxSize, -boxSize, boxSize, 1], [boxSize, boxSize, boxSize, 1], + [boxSize, boxSize, boxSize, 1], [-boxSize, boxSize, boxSize, 1], + [-boxSize, boxSize, boxSize, 1], [-boxSize, -boxSize, boxSize, 1], + // Connecting edges + [-boxSize, -boxSize, -boxSize, 1], [-boxSize, -boxSize, boxSize, 1], + [boxSize, -boxSize, -boxSize, 1], [boxSize, -boxSize, boxSize, 1], + [boxSize, boxSize, -boxSize, 1], [boxSize, boxSize, boxSize, 1], + [-boxSize, boxSize, -boxSize, 1], [-boxSize, boxSize, boxSize, 1], + ], colors: Array(24).fill([r, g, b, 1])}, + { pos: [bot.pos.x, bot.pos.y, bot.pos.z], scale: 1 } + ), + // View frustum (shows where bot is looking) + frustum: new Form( + { type: "line", positions: [ + [0, 0, 0, 1], [-frustumSpread, -frustumSpread, -frustumLength, 1], + [0, 0, 0, 1], [frustumSpread, -frustumSpread, -frustumLength, 1], + [0, 0, 0, 1], [frustumSpread, frustumSpread, -frustumLength, 1], + [0, 0, 0, 1], [-frustumSpread, frustumSpread, -frustumLength, 1], + [-frustumSpread, -frustumSpread, -frustumLength, 1], [frustumSpread, -frustumSpread, -frustumLength, 1], + [frustumSpread, -frustumSpread, -frustumLength, 1], [frustumSpread, frustumSpread, -frustumLength, 1], + [frustumSpread, frustumSpread, -frustumLength, 1], [-frustumSpread, frustumSpread, -frustumLength, 1], + [-frustumSpread, frustumSpread, -frustumLength, 1], [-frustumSpread, -frustumSpread, -frustumLength, 1], + ], colors: Array(16).fill([1, 0.5, 0, 1])}, // Bright orange frustum + { pos: [bot.pos.x, bot.pos.y, bot.pos.z], scale: 1 } + ), + // Ground line + groundLine: new Form( + { type: "line", positions: [[0, 0, 0, 1], [0, -2, 0, 1]], colors: [[r, g, b, 0.5], [r, g, b, 0.5]]}, + { pos: [bot.pos.x, bot.pos.y, bot.pos.z], scale: 1 } + ), + // Face (eyes + mouth) + face: new Form( + { type: "line", ...createFaceGeometry(boxSize, r, g, b) }, + { pos: [bot.pos.x, bot.pos.y, bot.pos.z], scale: 1 } + ), + // Name sign + nameSign: sign ? sign("🤖 BOT", { + scale: 0.03, color: [r, g, b], align: "center", + glyphs: glyphs?.("MatrixChunky8") || {}, + }) : null, + }; + console.log("🤖 Bot model created at", bot.pos); + } + + // Player camera frustums are created dynamically when players join + + // Load the latest painting texture from TV endpoint + if (get) { + console.log("🎨 Starting to load painting from TV endpoint..."); + loadLatestPaintingTexture(get); + } +} + +function sim() { + frameCount++; // Increment global frame counter + + // 🎯 Auto-tracking: smoothly turn toward current target + if (isAutoTracking && currentTarget && graphInstance) { + const targetPos = getTargetPosition(); + if (targetPos) { + const targetAngle = getAngleToTarget(self.pos, targetPos); + let angleDiff = targetAngle - graphInstance.rotY; + + // Normalize angle difference to -180 to 180 + while (angleDiff > 180) angleDiff -= 360; + while (angleDiff < -180) angleDiff += 360; + + // Smoothly turn toward target + if (Math.abs(angleDiff) > 1) { + const turnAmount = Math.sign(angleDiff) * Math.min(Math.abs(angleDiff), TURN_SPEED); + graphInstance.rotY += turnAmount; + + // Normalize rotation + while (graphInstance.rotY > 180) graphInstance.rotY -= 360; + while (graphInstance.rotY < -180) graphInstance.rotY += 360; + } + } + } + + // Update target list periodically (every 30 frames) + if (frameCount % 30 === 0) { + updateTargetList(); + } + + // Network update - send position via UDP for low latency + // Send in both lobby and playing states so players can see each other + if ((gameState === "playing" || gameState === "lobby") && graphInstance) { + self.pos = { + x: graphInstance.x, + y: graphInstance.y, + z: graphInstance.z + }; + self.rot = { + x: graphInstance.rotX, + y: graphInstance.rotY, + z: graphInstance.rotZ + }; + + // Send position updates - try UDP first (low latency), fall back to WebSocket + // 🎭 Skip sending updates if we're in spectator mode (but don't skip rest of sim) + if (!isSpectator) { + const moveData = { + handle: self.handle, // Use handle to identify player (links WS and UDP) + pos: self.pos, + rot: self.rot, + }; + + // Track UDP connected state for HUD + udpConnected = udpChannel?.connected || false; + + if (udpChannel?.connected) { + // 🩰 Send position over UDP (low latency, unreliable but fast) + // Throttle to every 2 frames (~30 updates/sec at 60fps) to reduce spam + if (frameCount % 2 === 0) { + // Log every ~60 frames (roughly once per second at 60fps) + if (frameCount % 60 === 0) { + logNetwork(`UDP send: pos=${moveData.pos.x.toFixed(1)},${moveData.pos.y.toFixed(1)},${moveData.pos.z.toFixed(1)}`); + } + udpChannel.send("1v1:move", moveData); + } + } else if (server) { + // 🕸️ Fallback: Send position over WebSocket (reliable but higher latency) + // Only send every 3rd frame to reduce bandwidth (~20 updates/sec at 60fps) + if (frameCount % 3 === 0) { + server.send("1v1:move", moveData); + } + } + } + } + + // Rotate the cube around its local center + cube.rotation[0] += 0.3; + cube.rotation[1] += 0.5; + + // Rotate the wireframe triangle around its local center + triangle.rotation[2] += 1.0; // Fast spin around Z axis + triangle.rotation[1] += 0.2; // Slight Y wobble + + // Rotate the filled triangle differently + filledTriangle.rotation[0] += 0.4; // X axis spin + filledTriangle.rotation[2] += 0.6; // Z axis spin + + // Rotate the textured quad + texturedQuad.rotation[1] += 0.5; // Spin on Y axis + + // 🤖 Bot AI simulation - bot runs around for players to follow + if (bot.enabled && botModel) { + // Handle respawn timer + if (bot.state === "dead") { + bot.respawnTimer--; + if (bot.respawnTimer <= 0) { + bot.state = "patrol"; + bot.health = 100; + bot.pos = { ...bot.patrolPoints[0], y: -0.5 }; // Match player eye height + console.log("🤖 Bot respawned!"); + } + } else { + // Bot just patrols freely - players follow it (chill behavior) + + // Check if bot is pausing at a waypoint + if (bot.pauseTimer > 0) { + bot.pauseTimer--; + } else { + // Move toward current patrol point + const target = bot.patrolPoints[bot.patrolIndex]; + const tdx = target.x - bot.pos.x; + const tdz = target.z - bot.pos.z; + const distToTarget = Math.sqrt(tdx * tdx + tdz * tdz); + + if (distToTarget < 0.3) { + // Reached patrol point - pause for a bit, then pick next + bot.pauseTimer = 60 + Math.floor(Math.random() * 120); // 1-3 seconds pause + if (Math.random() < 0.3) { + // Occasionally skip to a random point for unpredictability + bot.patrolIndex = Math.floor(Math.random() * bot.patrolPoints.length); + } else { + bot.patrolIndex = (bot.patrolIndex + 1) % bot.patrolPoints.length; + } + } else { + // Calculate target angle and smoothly turn toward it + const targetAngle = Math.atan2(tdx, tdz) * (180 / Math.PI); + let angleDiff = targetAngle - bot.rot.y; + // Normalize angle difference to -180 to 180 + while (angleDiff > 180) angleDiff -= 360; + while (angleDiff < -180) angleDiff += 360; + + // Smooth turning - turn toward target + bot.rot.y += Math.sign(angleDiff) * Math.min(Math.abs(angleDiff), bot.turnSpeed); + // Normalize rotation + while (bot.rot.y > 180) bot.rot.y -= 360; + while (bot.rot.y < -180) bot.rot.y += 360; + + // Only move forward once mostly facing the target (within 30 degrees) + if (Math.abs(angleDiff) < 30) { + // Move in the direction bot is FACING (rot.y) + bot.pos.x += Math.sin(bot.rot.y * Math.PI / 180) * bot.speed; + bot.pos.z += Math.cos(bot.rot.y * Math.PI / 180) * bot.speed; + } + + // Keep bot inside arena bounds (-2.8 to 2.8 to stay safely inside) + bot.pos.x = Math.max(-2.8, Math.min(2.8, bot.pos.x)); + bot.pos.z = Math.max(-2.8, Math.min(2.8, bot.pos.z)); + } + } + + // Update bot model positions + const px = bot.pos.x; + const py = -bot.pos.y; // Negate Y like other players + const pz = bot.pos.z; + const pitch = 0; + const yaw = bot.rot.y; + + botModel.cameraBox.position = [px, py, pz]; + botModel.frustum.position = [px, py, pz]; + botModel.frustum.rotation = [pitch, yaw, 0]; + botModel.groundLine.position = [px, py, pz]; + if (botModel.face) { + botModel.face.position = [px, py, pz]; + botModel.face.rotation = [pitch, yaw, 0]; + } + if (botModel.nameSign) { + botModel.nameSign.position = [px, py + 0.4, pz]; + botModel.nameSign.rotation = [0, (self.rot?.y || 0) + 180, 0]; + } + } + } +} + +function paint({ wipe, ink, painting, screen, line: drawLine, box: drawBox, clearWireframeBuffer, drawBufferedWireframes, getRenderStats, setShowClippedWireframes }) { + // Store render stats function for debug logging + if (!graphAPI) { + graphAPI = { getRenderStats }; + } + + // Calculate FPS + const now = performance.now(); + const deltaTime = now - lastFrameTime; + lastFrameTime = now; + + // Keep last 60 frame times for smoothed FPS + frameTimes.push(deltaTime); + if (frameTimes.length > 60) frameTimes.shift(); + + const avgFrameTime = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length; + const currentFPS = Math.round(1000 / avgFrameTime); + + // FIRST: Set wireframe visibility BEFORE any rendering happens + if (setShowClippedWireframes) { + setShowClippedWireframes(showWireframes); + } + + // SECOND: Clear wireframe buffer at start of frame + if (clearWireframeBuffer) { + clearWireframeBuffer(); + } + + if (!paint.frameCount) paint.frameCount = 0; + paint.frameCount++; + + // Create ground texture with a checkerboard pattern if it doesn't exist + if (!groundTexture) { + const texSize = 64; + groundTexture = painting(texSize, texSize, (api) => { + const { wipe, ink, box, line } = api; + + // Fill with dark yellow/orange + wipe(100, 80, 0); // Darker yellow base + + // Draw dark blue checkerboard squares + const squareSize = 8; + ink(0, 0, 80); // Dark blue ink + for (let y = 0; y < texSize / squareSize; y++) { + for (let x = 0; x < texSize / squareSize; x++) { + if ((x + y) % 2 === 1) { + box(x * squareSize, y * squareSize, squareSize, squareSize); + } + } + } + }); + + groundPlane.texture = groundTexture; + console.log("🎨 Ground texture created:", groundTexture?.width, "x", groundTexture?.height, "=", groundTexture?.pixels?.length, "bytes"); + console.log("🎨 First few pixels:", groundTexture?.pixels?.slice(0, 16)); + console.log("🎨 Ground plane has texture:", !!groundPlane.texture); + } + + // Create checkerboard texture for quad if it doesn't exist + if (!quadTexture) { + // Use a placeholder checkerboard texture while loading + const texSize = 64; + quadTexture = painting(texSize, texSize, (api) => { + const { wipe, ink, box } = api; + wipe(50, 50, 50); // Dark gray base + + // Draw checkerboard pattern + const squareSize = 8; + for (let y = 0; y < texSize / squareSize; y++) { + for (let x = 0; x < texSize / squareSize; x++) { + if ((x + y) % 2 === 0) { + ink(200, 200, 200); // Light gray squares + box(x * squareSize, y * squareSize, squareSize, squareSize); + } + } + } + }); + + // Assign texture to quad - now with perspective-correct mapping! + texturedQuad.texture = quadTexture; + } + + // Dark background for FPS arena + wipe(20, 20, 30) + .form(groundPlane); // Ground plane only + + // Render camera debug markers (height reference) + // Player camera frustums are rendered below in the playerBoxes loop + + // Render other players as camera frustums + for (const [id, playerModel] of Object.entries(playerBoxes)) { + const other = others[id]; + if (other && playerModel) { + const px = other.pos.x; + const py = -other.pos.y; // NEGATE Y - camera Y is inverted in the coordinate system + const pz = other.pos.z; + const pitch = -(other.rot?.x || 0); // NEGATED - camera pitch is inverted + const yaw = other.rot?.y || 0; // Player's yaw (look left/right) + + // Position camera box at player's exact camera position + playerModel.cameraBox.position = [px, py, pz]; + + // Frustum follows camera position and FULL rotation (pitch + yaw) + playerModel.frustum.position = [px, py, pz]; + playerModel.frustum.rotation = [pitch, yaw, 0]; // Pitch and yaw + + // Ground line shows where they're standing + playerModel.groundLine.position = [px, py, pz]; + + // Face follows camera box position and rotation (so it looks where they look) + if (playerModel.face) { + playerModel.face.position = [px, py, pz]; + playerModel.face.rotation = [pitch, yaw, 0]; // Face looks where player looks + } + + // Name sign above player (billboard - always face camera) + if (playerModel.nameSign) { + playerModel.nameSign.position = [px, py + 0.35, pz]; // Above the camera box + // Billboard rotation: face toward our camera by using inverse of our yaw + playerModel.nameSign.rotation = [0, (self.rot?.y || 0) + 180, 0]; + } + + // Render camera visualization (colors come from vertex colors) + ink(255, 255, 255).form(playerModel.cameraBox); + ink(255, 255, 255).form(playerModel.frustum); + ink(255, 255, 255).form(playerModel.groundLine); + + // Render face (eyes + mouth) + if (playerModel.face) { + ink(255, 255, 255).form(playerModel.face); + } + + // Render name sign + if (playerModel.nameSign) { + ink(255, 255, 255).form(playerModel.nameSign); + } + } + } + + // 🤖 Render bot + if (bot.enabled && botModel && bot.state !== "dead") { + ink(255, 255, 255).form(botModel.cameraBox); + ink(255, 255, 255).form(botModel.frustum); + ink(255, 255, 255).form(botModel.groundLine); + if (botModel.face) { + ink(255, 255, 255).form(botModel.face); + } + if (botModel.nameSign) { + ink(255, 255, 255).form(botModel.nameSign); + } + } + + // Draw wireframes on top AFTER all other forms to ensure they're always visible + if (showWireframes) { + // Note: Ground plane wireframes are now generated automatically via buffered system + // No need to render separate groundWireframe form + + // Draw all buffered clipped wireframes + if (drawBufferedWireframes) { + drawBufferedWireframes(); + } + } + + const hudFont = "MatrixChunky8"; + + // === 2D HUD OVERLAY === + + // === TOP RIGHT: FPS counter (above minimap) === + const fpsColor = currentFPS >= 55 ? [0, 255, 0] : currentFPS >= 30 ? [255, 255, 0] : [255, 0, 0]; + const fpsText = `${currentFPS} FPS`; + // Right-align FPS text (approx 6px per char in MatrixChunky8) + const fpsTextWidth = fpsText.length * 6; + ink(...fpsColor).write(fpsText, { x: screen.width - fpsTextWidth - 8, y: 4 }, undefined, undefined, false, hudFont); + + // Draw crosshair FIRST (center of screen) + const centerX = screen.width / 2; + const centerY = screen.height / 2; + const crosshairSize = 8; + ink(255, 255, 255, 200) + .line(centerX - crosshairSize, centerY, centerX + crosshairSize, centerY) + .line(centerX, centerY - crosshairSize, centerX, centerY + crosshairSize); + + // === TOP LEFT: Network status + game state (offset to avoid prompt label) === + const hudStartY = 24; // Start below prompt HUD corner label + const netNow = Date.now(); + + // UDP status + const udpStatus = udpChannel?.connected ? "UDP:ON" : "UDP:OFF"; + const udpAge = lastUdpReceiveTime ? Math.floor((netNow - lastUdpReceiveTime) / 1000) : "?"; + const udpColor = udpChannel?.connected ? (udpAge < 5 ? [0, 255, 0] : [255, 255, 0]) : [255, 0, 0]; + ink(...udpColor).write(`${udpStatus} (${udpMessageCount}msg ${udpAge}s)`, { x: 6, y: hudStartY }, undefined, undefined, false, hudFont); + + // WebSocket status + const wsStatus = wsConnected ? "WS:ON" : "WS:OFF"; + const wsAge = lastWsReceiveTime ? Math.floor((netNow - lastWsReceiveTime) / 1000) : "?"; + const wsColor = wsConnected ? (wsAge < 10 ? [0, 255, 0] : [255, 255, 0]) : [255, 0, 0]; + ink(...wsColor).write(`${wsStatus} (${wsMessageCount}msg ${wsAge}s)`, { x: 6, y: hudStartY + 10 }, undefined, undefined, false, hudFont); + + // Identity/handle + const selfIdStr = typeof self.id === "string" ? self.id.slice(0, 6) : "..."; + ink(150, 150, 255).write(`${self.handle} (${selfIdStr})`, { x: 6, y: hudStartY + 20 }, undefined, undefined, false, hudFont); + + // Game state + spectator mode + const stateColor = isSpectator ? [255, 165, 0] : [255, 255, 255]; + const stateText = isSpectator ? `${gameState.toUpperCase()} [SPECTATOR]` : gameState.toUpperCase(); + ink(...stateColor).write(stateText, { x: 6, y: hudStartY + 30 }, undefined, undefined, false, hudFont); + + // 🎯 Target tracking indicator + if (currentTarget || targetList.length > 0) { + const targetName = currentTarget + ? (currentTarget === "bot" ? "🤖BOT" : (others[currentTarget]?.handle || "???")) + : "---"; + const trackIcon = isAutoTracking ? "🎯" : "👁️"; + const targetColor = isAutoTracking ? [0, 255, 255] : [200, 200, 200]; + ink(...targetColor).write(`${trackIcon} ${targetName} [Tab/T/F]`, { x: 6, y: hudStartY + 40 }, undefined, undefined, false, hudFont); + } + + // Gamepad status indicator (small) + const gpCount = Object.keys(connectedGamepads).length; + if (gpCount > 0) { + const gpIndicator = showGamepadPanel ? `🎮${gpCount}` : `🎮${gpCount} [G]`; + ink(0, 200, 200).write(gpIndicator, { x: 6, y: hudStartY + 50 }, undefined, undefined, false, hudFont); + } + + // === TOP RIGHT: MINIMAP === + const playerCount = Object.keys(others).length; + const rightMargin = 6; + + // Minimap settings + const mapSize = 60; // 60x60 pixel minimap + const mapX = screen.width - mapSize - rightMargin; + const mapY = 4; + const mapScale = mapSize / 6; // Arena is 6 units (-3 to +3) + const mapCenterX = mapX + mapSize / 2; + const mapCenterY = mapY + mapSize / 2; + + // Minimap background (semi-transparent) + ink(0, 0, 0, 180).box(mapX, mapY, mapSize, mapSize); + ink(40, 40, 40).box(mapX, mapY, mapSize, mapSize, "outline"); + + // Arena boundary outline (the ground plane is -3 to +3) + ink(60, 60, 60).box(mapX + 2, mapY + 2, mapSize - 4, mapSize - 4, "outline"); + + // Helper to convert world coords to minimap coords + const worldToMap = (wx, wz) => ({ + mx: mapCenterX + wx * mapScale, + my: mapCenterY - wz * mapScale // Flip Z for top-down view (north = up) + }); + + // Draw self (green triangle pointing in look direction) + const selfMap = worldToMap(self.pos.x, self.pos.z); + const selfAngle = (self.rot?.y || 0) * Math.PI / 180; + const triSize = 4; + // Triangle pointing in direction of rotation + const tipX = selfMap.mx + Math.sin(selfAngle) * triSize; + const tipY = selfMap.my - Math.cos(selfAngle) * triSize; + const baseAngle1 = selfAngle + 2.5; // ~140 degrees back + const baseAngle2 = selfAngle - 2.5; + ink(0, 255, 0).line(tipX, tipY, selfMap.mx + Math.sin(baseAngle1) * triSize * 0.6, selfMap.my - Math.cos(baseAngle1) * triSize * 0.6); + ink(0, 255, 0).line(tipX, tipY, selfMap.mx + Math.sin(baseAngle2) * triSize * 0.6, selfMap.my - Math.cos(baseAngle2) * triSize * 0.6); + ink(0, 255, 0).box(selfMap.mx - 1, selfMap.my - 1, 2, 2); // Center dot + + // Draw other players (colored dots) + for (const [id, other] of Object.entries(others)) { + const pMap = worldToMap(other.pos.x, other.pos.z); + const pColor = playerBoxes[id]?.color; + if (pColor) { + ink(pColor[0] * 255, pColor[1] * 255, pColor[2] * 255).box(pMap.mx - 2, pMap.my - 2, 4, 4); + } else { + ink(255, 150, 50).box(pMap.mx - 2, pMap.my - 2, 4, 4); + } + } + + // Draw bot (orange/yellow pulsing dot) + if (bot.enabled && bot.state !== "dead") { + const botMap = worldToMap(bot.pos.x, bot.pos.z); + const pulse = Math.sin(Date.now() / 200) * 0.3 + 0.7; // Pulsing effect + ink(255 * pulse, 165 * pulse, 0).box(botMap.mx - 2, botMap.my - 2, 4, 4); + // Show bot facing direction + const botAngle = bot.rot.y * Math.PI / 180; + const botDirX = botMap.mx + Math.sin(botAngle) * 5; + const botDirY = botMap.my - Math.cos(botAngle) * 5; + ink(255, 165, 0, 150).line(botMap.mx, botMap.my, botDirX, botDirY); + } + + // Player count below minimap + const countText = `${playerCount + 1}P`; + ink(255, 255, 0).write(countText, { x: mapX + mapSize - countText.length * 4, y: mapY + mapSize + 2 }, undefined, undefined, false, hudFont); + + // === TOP CENTER: Health bar === + const healthBarWidth = 100; + const healthBarHeight = 10; + const healthBarX = centerX - healthBarWidth / 2; + const healthBarY = 4; // Top of screen + const healthPercent = self.health / 100; + + // Background + ink(30, 30, 30, 200).box(healthBarX, healthBarY, healthBarWidth, healthBarHeight); + + // Fill color based on health + const healthColor = healthPercent > 0.5 ? [0, 220, 0] : healthPercent > 0.25 ? [220, 220, 0] : [220, 0, 0]; + ink(...healthColor).box(healthBarX + 1, healthBarY + 1, (healthBarWidth - 2) * healthPercent, healthBarHeight - 2); + + // HP text centered below bar + const hpText = `HP ${Math.ceil(self.health)}`; + ink(255, 255, 255).write(hpText, { x: centerX - hpText.length * 2, y: healthBarY + healthBarHeight + 2 }, undefined, undefined, false, hudFont); + + // === BOTTOM RIGHT: K/D === + const kdText = `K${self.kills} D${self.deaths}`; + ink(255, 255, 255).write(kdText, { x: screen.width - rightMargin - kdText.length * 4, y: screen.height - 14 }, undefined, undefined, false, hudFont); + + // === BOTTOM CENTER: Instructions when solo === + if (playerCount === 0) { + const soloText = "WAITING..."; + ink(150, 150, 150).write(soloText, { x: centerX - soloText.length * 2, y: screen.height - 14 }, undefined, undefined, false, hudFont); + } + + // Show "Waiting for opponent" message in lobby + if (gameState === "lobby") { + ink(255, 255, 0, 200).write("WAITING FOR OPPONENT...", { + x: screen.width / 2 - 100, + y: screen.height / 2 + }); + } + + // Show death screen + if (gameState === "dead") { + ink(255, 0, 0, 200).write("YOU DIED", { + x: screen.width / 2 - 40, + y: screen.height / 2 - 20 + }); + ink(255, 255, 255, 150).write("Respawning in 3s...", { + x: screen.width / 2 - 70, + y: screen.height / 2 + 10 + }); + } + + // Draw debug panel in top-right corner + if (showDebugPanel) { + const stats = system?.fps?.renderStats || { + originalTriangles: 0, + clippedTriangles: 0, + subdividedTriangles: 0, + wireframeSegmentsTotal: 0, + wireframeSegmentsTextured: 0, + wireframeSegmentsGradient: 0, + pixelsDrawn: 0, + trianglesRejected: 0, + }; + + const charWidth = 4; + const lineHeight = 8; + const title = "FPS DEBUG"; + const panelPadding = 6; + const spacingAfterTitle = 2; + const marginFromEdge = 8; + + const lines = [ + { color: "lime", text: `FPS: ${currentFPS}` }, + { color: "orange", text: `Frame: ${avgFrameTime.toFixed(2)}ms` }, + { color: "red", text: `Pixels: ${stats.pixelsDrawn}` }, + { color: "yellow", text: `Wireframes: ${showWireframes ? "ON" : "OFF"}` }, + { color: "cyan", text: `Original Tris: ${stats.originalTriangles}` }, + { color: "magenta", text: `Clipped Tris: ${stats.clippedTriangles}` }, + { color: "yellow", text: `Subdivided: ${stats.subdividedTriangles}` }, + { color: "red", text: `Rejected: ${stats.trianglesRejected || 0}` }, + { color: "white", text: `WF Total: ${stats.wireframeSegmentsTotal}` }, + { color: "white", text: `- Textured: ${stats.wireframeSegmentsTextured}` }, + { color: "white", text: `- Gradient: ${stats.wireframeSegmentsGradient}` }, + { color: "gray", text: "V: Wireframe" }, + { color: "gray", text: "P: Panel" }, + { color: "gray", text: "G: Gamepad" }, + { color: "gray", text: "Q: QR Code" }, + { color: "gray", text: "L: Log Debug" }, + ]; + + const measureWidth = (text) => text.length * charWidth; + const maxLineWidth = Math.max(measureWidth(title), ...lines.map((line) => measureWidth(line.text))); + const minPanelWidth = 120; + const panelWidth = Math.max(minPanelWidth, panelPadding * 2 + maxLineWidth); + const panelHeight = panelPadding * 2 + lineHeight + spacingAfterTitle + lines.length * lineHeight; + const panelX = screen.width - panelWidth - marginFromEdge; + const panelY = 10; + + // Semi-transparent background + ink(0, 0, 0, 180); + drawBox(panelX, panelY, panelWidth, panelHeight); + + // Panel border + ink(255, 255, 0, 255); + drawLine(panelX, panelY, panelX + panelWidth, panelY); + drawLine(panelX + panelWidth, panelY, panelX + panelWidth, panelY + panelHeight); + drawLine(panelX + panelWidth, panelY + panelHeight, panelX, panelY + panelHeight); + drawLine(panelX, panelY + panelHeight, panelX, panelY); + + // Title + let textY = panelY + panelPadding; + ink("white").write(title, { x: panelX + panelPadding, y: textY }, undefined, undefined, false, debugFont); + textY += lineHeight + spacingAfterTitle; + + // Content lines + lines.forEach(({ color, text }) => { + ink(color).write(text, { x: panelX + panelPadding, y: textY }, undefined, undefined, false, debugFont); + textY += lineHeight; + }); + } + + // === GAMEPAD/KEYBOARD CONTROLS PANEL - VERY BOTTOM LEFT (toggle with 'G' key) === + if (showGamepadPanel) { + const gpPanelX = 6; + const kbPanelHeight = 38; // Compact height for new layout + const kbPanelWidth = 75; // Compact width + const gpPanelY = screen.height - kbPanelHeight - 4; // Very bottom, small margin + const gpCharWidth = 4; + const gpLineHeight = 9; + const gpPadding = 4; + + const gpIndices = Object.keys(connectedGamepads).map(k => parseInt(k)); + + if (gpIndices.length === 0) { + // No gamepad - show keyboard controls instead + ink(0, 0, 0, 180).box(gpPanelX, gpPanelY, kbPanelWidth, kbPanelHeight); + ink(0, 200, 200, 255) + .line(gpPanelX, gpPanelY, gpPanelX + kbPanelWidth, gpPanelY) + .line(gpPanelX + kbPanelWidth, gpPanelY, gpPanelX + kbPanelWidth, gpPanelY + kbPanelHeight) + .line(gpPanelX + kbPanelWidth, gpPanelY + kbPanelHeight, gpPanelX, gpPanelY + kbPanelHeight) + .line(gpPanelX, gpPanelY + kbPanelHeight, gpPanelX, gpPanelY); + drawKeyboardControls(ink, gpPanelX + gpPadding, gpPanelY + gpPadding, pressedKeys); + } else { + // Draw gamepad info for each connected controller + gpIndices.forEach((gpIndex, slotNum) => { + const gp = connectedGamepads[gpIndex]; + if (!gp) return; + + const slotY = gpPanelY + slotNum * 120; + const mapping = getGamepadMapping(gp.id || "standard"); + const is8BitDo = gp.id?.includes("8BitDo") || gp.id?.includes("2dc8"); + const controllerType = is8BitDo ? "8bitdo" : "standard"; + + // Build content + const contentLines = []; + + // Controller type + let displayName = gp.id || "Unknown"; + if (displayName.includes("8BitDo Micro") || (displayName.includes("2dc8") && displayName.includes("9020"))) { + displayName = "8BitDo Micro"; + } else if (displayName.includes("045e") && displayName.includes("0b13")) { + displayName = "Xbox Controller"; + } else { + displayName = displayName.slice(0, 24); + } + contentLines.push({ color: "cyan", text: displayName }); + + // Pressed buttons with mapped actions + if (gp.pressedButtons.length > 0) { + const btnParts = gp.pressedButtons.map(bi => { + const btnName = getButtonName(gp.id, bi); + const actionKey = `${controllerType}_${bi}`; + const action = FPS_ACTIONS.buttons[actionKey] || ""; + return action ? `${btnName}→${action}` : btnName; + }); + contentLines.push({ color: "lime", text: `BTN: ${btnParts.join(" ")}` }); + } else { + contentLines.push({ color: "gray", text: "BTN: (none)" }); + } + + // Active axes with mapped actions + const activeAxes = Object.keys(gp.axes); + if (activeAxes.length > 0) { + activeAxes.forEach(ai => { + const axisIndex = parseInt(ai); + const value = parseFloat(gp.axes[ai]); + const axisName = getAxisName(gp.id, axisIndex); + const actionKey = `${controllerType}_${axisIndex}`; + const axisAction = FPS_ACTIONS.axes[actionKey]; + let actionStr = ""; + if (axisAction) { + actionStr = value < 0 ? `→${axisAction.neg}` : `→${axisAction.pos}`; + } + + // Visual bar for axis value + const barWidth = 20; + const barFill = Math.abs(value) * barWidth; + contentLines.push({ + color: value < 0 ? "yellow" : "orange", + text: `${axisName}: ${value > 0 ? "+" : ""}${value}${actionStr}`, + axisValue: value + }); + }); + } else { + contentLines.push({ color: "gray", text: "AXES: (centered)" }); + } + + // Calculate panel size - include space for mini diagram + const diagramHeight = is8BitDo ? 24 : 28; + const maxTextWidth = Math.max(...contentLines.map(l => l.text.length)) * gpCharWidth; + const gpPanelWidth = Math.max(160, maxTextWidth + gpPadding * 2); + const gpPanelHeight = gpPadding * 2 + contentLines.length * gpLineHeight + gpLineHeight + diagramHeight + 4; // +title + diagram + + // Draw panel background + ink(0, 0, 0, 200).box(gpPanelX, slotY, gpPanelWidth, gpPanelHeight); + + // Panel border (cyan for gamepad theme) + ink(0, 200, 200, 255) + .line(gpPanelX, slotY, gpPanelX + gpPanelWidth, slotY) + .line(gpPanelX + gpPanelWidth, slotY, gpPanelX + gpPanelWidth, slotY + gpPanelHeight) + .line(gpPanelX + gpPanelWidth, slotY + gpPanelHeight, gpPanelX, slotY + gpPanelHeight) + .line(gpPanelX, slotY + gpPanelHeight, gpPanelX, slotY); + + // Title + let gpTextY = slotY + gpPadding; + ink("white").write(`🎮 P${gpIndex + 1}`, { x: gpPanelX + gpPadding, y: gpTextY }, undefined, undefined, false, hudFont); + gpTextY += gpLineHeight; + + // Mini controller diagram + const diagramX = gpPanelX + gpPadding; + drawMiniControllerDiagram(ink, gp, diagramX, gpTextY); + gpTextY += diagramHeight + 4; + + // Content + contentLines.forEach(({ color, text, axisValue }) => { + ink(color).write(text, { x: gpPanelX + gpPadding, y: gpTextY }, undefined, undefined, false, hudFont); + + // Draw axis visual bar if present + if (axisValue !== undefined) { + const barX = gpPanelX + gpPanelWidth - 30; + const barY = gpTextY + 1; + const barWidth = 24; + const barHeight = 6; + const centerX = barX + barWidth / 2; + + // Background bar + ink(40, 40, 40).box(barX, barY, barWidth, barHeight); + + // Value indicator + const fillWidth = Math.abs(axisValue) * (barWidth / 2); + if (axisValue < 0) { + ink("yellow").box(centerX - fillWidth, barY, fillWidth, barHeight); + } else { + ink("orange").box(centerX, barY, fillWidth, barHeight); + } + + // Center line + ink("white").line(centerX, barY, centerX, barY + barHeight); + } + + gpTextY += gpLineHeight; + }); + }); + } + } + + // Network status is now in top left - this section removed + + // 📱 Draw join QR code in bottom-right corner + drawJoinQR({ ink, box: drawBox, write: (text, opts, ...rest) => ink(255).write(text, opts, ...rest), screen }); + + // Note: Crosshair is now rendered via DOM element in bios.mjs when pointer lock is enabled +} + +function act({ event: e, penLock, setShowClippedWireframes }) { + if (e.is("pen:locked")) penLocked = true; + if (e.is("pen:unlocked")) penLocked = false; + if (!penLocked && e.is("touch")) penLock(); + + // Shooting with left mouse button + if (e.is("draw") && gameState === "playing") { + const now = performance.now(); + if (now - self.lastShot >= SHOOT_COOLDOWN) { + self.lastShot = now; + shoot(); + } + } + + // Toggle wireframe mode with 'V' key + if (e.is("keyboard:down:v")) { + showWireframes = !showWireframes; + // Toggle clipped triangle wireframes if available + if (setShowClippedWireframes) { + setShowClippedWireframes(showWireframes); + } + } + + // Toggle debug panel with 'P' key + if (e.is("keyboard:down:p")) { + showDebugPanel = !showDebugPanel; + } + + // Toggle gamepad panel with 'G' key + if (e.is("keyboard:down:g")) { + showGamepadPanel = !showGamepadPanel; + console.log(`🎮 Gamepad panel: ${showGamepadPanel ? "ON" : "OFF"}`); + } + + // Toggle join QR code with 'Q' key + if (e.is("keyboard:down:q")) { + showJoinQR = !showJoinQR; + console.log(`📱 Join QR: ${showJoinQR ? "ON" : "OFF"}`); + } + + // Log scene debug info with 'L' key + if (e.is("keyboard:down:l")) { + logSceneDebug(); + } + + // 🎯 Target tracking controls + // Tab - cycle through targets (other players + bot) + if (e.is("keyboard:down:Tab")) { + cycleTarget(); + } + + // T - toggle auto-tracking (continuously face target) + if (e.is("keyboard:down:t")) { + toggleAutoTrack(); + } + + // F - snap to face current target instantly + if (e.is("keyboard:down:f")) { + if (currentTarget) { + snapToTarget(); + } else { + // If no target, select one first then snap + cycleTarget(); + if (currentTarget) snapToTarget(); + } + } + + // ⌨️ Track keyboard state for HUD display + if (e.is("keyboard:down:w")) pressedKeys.w = true; + if (e.is("keyboard:up:w")) pressedKeys.w = false; + if (e.is("keyboard:down:a")) pressedKeys.a = true; + if (e.is("keyboard:up:a")) pressedKeys.a = false; + if (e.is("keyboard:down:s")) pressedKeys.s = true; + if (e.is("keyboard:up:s")) pressedKeys.s = false; + if (e.is("keyboard:down:d")) pressedKeys.d = true; + if (e.is("keyboard:up:d")) pressedKeys.d = false; + if (e.is("keyboard:down:ArrowUp")) pressedKeys.arrowup = true; + if (e.is("keyboard:up:ArrowUp")) pressedKeys.arrowup = false; + if (e.is("keyboard:down:ArrowDown")) pressedKeys.arrowdown = true; + if (e.is("keyboard:up:ArrowDown")) pressedKeys.arrowdown = false; + if (e.is("keyboard:down:ArrowLeft")) pressedKeys.arrowleft = true; + if (e.is("keyboard:up:ArrowLeft")) pressedKeys.arrowleft = false; + if (e.is("keyboard:down:ArrowRight")) pressedKeys.arrowright = true; + if (e.is("keyboard:up:ArrowRight")) pressedKeys.arrowright = false; + if (e.is("keyboard:down: ")) pressedKeys.space = true; + if (e.is("keyboard:up: ")) pressedKeys.space = false; + if (e.is("keyboard:down:Shift")) pressedKeys.shift = true; + if (e.is("keyboard:up:Shift")) pressedKeys.shift = false; + if (e.is("draw")) pressedKeys.lmb = true; + if (e.is("lift")) pressedKeys.lmb = false; + + // 🎮 Track gamepad state for HUD display + if (e.is("gamepad")) { + const gpIndex = e.gamepad; + + // Initialize gamepad entry if needed + if (!connectedGamepads[gpIndex]) { + connectedGamepads[gpIndex] = { + id: e.gamepadId || null, + pressedButtons: [], + axes: {}, + lastEvent: null, + }; + console.log(`🎮 [1v1] Gamepad ${gpIndex} connected:`, e.gamepadId); + // Auto-show panel when gamepad connects + showGamepadPanel = true; + } + + const gp = connectedGamepads[gpIndex]; + + // Update gamepad ID if available + if (e.gamepadId && !gp.id) { + gp.id = e.gamepadId; + } + + // Track last event + gp.lastEvent = e.name; + + // Track button state + if (e.button !== undefined) { + const buttonIndex = e.button; + if (e.action === "push") { + if (!gp.pressedButtons.includes(buttonIndex)) { + gp.pressedButtons.push(buttonIndex); + } + } else if (e.action === "release") { + gp.pressedButtons = gp.pressedButtons.filter(b => b !== buttonIndex); + } + } + + // Track axis state + if (e.axis !== undefined) { + const axisIndex = e.axis; + const value = e.value; + + if (Math.abs(value) > 0.1) { + gp.axes[axisIndex] = value.toFixed(2); + } else { + delete gp.axes[axisIndex]; + } + } + } +} + +// Raycast shooting - check if we hit another player or the bot +function shoot() { + if (!graphInstance) return; + + console.log("🔫 Shooting!"); + if (server) { + server.send("1v1:shoot", { timestamp: performance.now() }); + } + + // Simple raycast from camera forward + const camPos = { x: graphInstance.x, y: graphInstance.y, z: graphInstance.z }; + const camRot = { x: graphInstance.rotX, y: graphInstance.rotY, z: graphInstance.rotZ }; + + // Convert rotation to direction vector (simplified) + const yawRad = (camRot.y * Math.PI) / 180; + const pitchRad = (camRot.x * Math.PI) / 180; + + const dir = { + x: Math.cos(pitchRad) * Math.sin(yawRad), + y: -Math.sin(pitchRad), + z: Math.cos(pitchRad) * Math.cos(yawRad), + }; + + // Check collision with other players (simple sphere test) + const MAX_RANGE = 50; + let hitPlayerId = null; + let hitBot = false; + let minDist = MAX_RANGE; + + // Check hit against bot first + if (bot.enabled && bot.state !== "dead") { + const dx = bot.pos.x - camPos.x; + const dy = bot.pos.y - camPos.y; + const dz = bot.pos.z - camPos.z; + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + + if (dist <= MAX_RANGE) { + const dot = dx * dir.x + dy * dir.y + dz * dir.z; + if (dot > 0) { + const hitRadius = 0.5; + const perpDist = Math.sqrt(dx * dx + dy * dy + dz * dz - dot * dot); + + if (perpDist < hitRadius && dist < minDist) { + minDist = dist; + hitBot = true; + hitPlayerId = null; + } + } + } + } + + // Check hit against other players + for (const [id, other] of Object.entries(others)) { + const dx = other.pos.x - camPos.x; + const dy = other.pos.y - camPos.y; + const dz = other.pos.z - camPos.z; + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + + if (dist > MAX_RANGE) continue; + + // Check if player is in front of camera (dot product) + const dot = dx * dir.x + dy * dir.y + dz * dir.z; + if (dot <= 0) continue; // Behind us + + // Simple sphere collision (player radius ~0.5) + const hitRadius = 0.5; + const perpDist = Math.sqrt(dx * dx + dy * dy + dz * dz - dot * dot); + + if (perpDist < hitRadius && dist < minDist) { + minDist = dist; + hitPlayerId = id; + hitBot = false; + } + } + + // Handle bot hit + if (hitBot) { + console.log("🤖💥 HIT BOT!"); + bot.health -= DAMAGE_PER_HIT; + if (bot.health <= 0) { + bot.state = "dead"; + bot.respawnTimer = 180; // 3 seconds at 60fps + self.kills++; + console.log("🤖💀 Bot killed! Kills:", self.kills); + } + } + + // Handle player hit + if (hitPlayerId && server) { + console.log("💥 HIT player:", hitPlayerId); + server.send("1v1:hit", { + targetId: hitPlayerId, + damage: DAMAGE_PER_HIT, + }); + } +} + +// Load the latest painting from the TV endpoint and apply it to the textured quad +async function loadLatestPaintingTexture(get) { + if (!texturedQuad || !get) { + console.warn("Cannot load texture: missing quad or get API"); + return; + } + + try { + console.log("🖼️ Fetching from TV endpoint..."); + const response = await fetch("/api/tv?types=painting&limit=1"); + if (!response.ok) { + console.warn("TV endpoint returned", response.status); + return; + } + + const payload = await response.json(); + console.log("🖼️ TV payload:", payload); + const latestPainting = payload?.media?.paintings?.[0]; + + if (!latestPainting) { + console.warn("No painting found in TV feed"); + return; + } + + const paintingSlug = latestPainting.slug; + const paintingHandle = latestPainting.owner?.handle?.replace(/^@/, "") || "anon"; + + console.log("🖼️ Loading painting:", paintingSlug, "by", paintingHandle); + + // Use get.painting() like painting.mjs and profile.mjs do + const got = await get.painting(paintingSlug).by(paintingHandle); + if (!got?.img) { + console.warn("Failed to load painting image"); + return; + } + + // Replace the placeholder texture with the actual painting + quadTexture = got.img; + texturedQuad.texture = got.img; + paintingTextureLoaded = true; + + console.log("🖼️ Successfully loaded painting texture:", got.img.width, "x", got.img.height); + } catch (error) { + console.error("Failed to load latest painting texture:", error); + } finally { + paintingTextureFetchPromise = null; + } +} + +export const system = "fps"; +export { boot, sim, paint, act }; diff --git a/easel/context/reference/disks/blank.mjs b/easel/context/reference/disks/blank.mjs new file mode 100644 index 000000000..23710d381 --- /dev/null +++ b/easel/context/reference/disks/blank.mjs @@ -0,0 +1,869 @@ +// blank, 26.03.20 +// AC Blank — AC Blank Laptop product page & checkout +// Checkout is handled by Shopify (shop.aesthetic.computer). Earlier this page +// ran Stripe directly because we hadn't re-enabled Shopify; that path is gone. + +const { floor, sin, cos, abs, min, max, PI, sqrt } = Math; + +// Module state +let amount = 12800; // Kept in sync with the Shopify variant price (USD cents). +const SHOP_URL = + "https://shop.aesthetic.computer/products/laptops_ac-blank-laptop_26-4-17-12-51"; +let thanks = false; + +// UI elements +let buyBtn = null; +let manualBtn = null; +let paperBtn = null; +let osBtn = null; +let userHandle = null; + +// Handle cycling (when not logged in) +let allHandles = []; +let cycleIndex = 0; +let cycleTimer = 0; +const CYCLE_INTERVAL = 90; // frames between handle switches + +// Whitelist handles to highlight +const HIGHLIGHT_HANDLES = ["sat", "fifi", "prutti"]; + +// Handle colors cache: Map | null> +const handleColors = new Map(); +let currentDisplayColors = null; // colors for the currently displayed handle + +async function fetchHandleColor(handle) { + const clean = handle.startsWith("@") ? handle.slice(1) : handle; + if (handleColors.has(clean)) return handleColors.get(clean); + try { + const res = await fetch( + `/.netlify/functions/handle-colors?handle=${encodeURIComponent(clean)}`, + ); + if (res.ok) { + const data = await res.json(); + handleColors.set(clean, data.colors || null); + return data.colors || null; + } + } catch {} + handleColors.set(clean, null); + return null; +} + +const MANUAL_URL = + "https://download.lenovo.com/pccbbs/mobiles_pdf/tp_11e-yoga_gen6_ug_en.pdf"; +const PAPER_URL = + "https://papers.aesthetic.computer/plorking-the-planet-26-arxiv-cards.pdf"; +const DESCRIPTION_PLAIN = + "Receive a @jeffrey approved, refurbished Thinkpad 11e Yoga Gen 6 pre-flashed with AC Native OS and Live USB recovery stick."; +const DESCRIPTION = + "Receive a \\255,100,255\\@jeffrey\\reset\\ approved, refurbished Thinkpad 11e Yoga Gen 6 pre-flashed with AC Native OS and Live USB recovery stick."; + +// Animation +let frame = 0; + +const charH = 16; + +function displayAmount(amt) { + return `$${(amt / 100).toFixed(0)}`; +} + +function getBuyText() { + return `BUY LAPTOP ${displayAmount(amount)}`; +} + +async function boot({ params, ui, screen, cursor, hud, api, handle }) { + cursor("native"); + hud.labelBack(); + + if (params[0] === "thanks") { + thanks = true; + return; + } + + userHandle = handle(); + setupButtons(ui, screen); + if (!userHandle) fetchHandles(screen); + // Prefetch colors for logged-in user + if (userHandle) fetchHandleColor(userHandle); + // Prefetch colors for whitelisted handles + HIGHLIGHT_HANDLES.forEach((h) => fetchHandleColor(h)); +} + +// Max chars that fit on the laptop screen text line: "hi @handle" +// MatrixChunky8 is ~4px per char, screen plane is ~56px wide → ~14 chars +const MAX_SCREEN_CHARS = 14; + +async function fetchHandles(screen) { + try { + const res = await fetch("/api/handles?tenant=aesthetic"); + if (!res.ok) return; + const data = await res.json(); + allHandles = (data.handles || []) + .map((h) => h.handle) + .filter(Boolean) + .filter((h) => `hi @${h}`.length <= MAX_SCREEN_CHARS); + // Put highlighted handles first, then shuffle the rest + const highlighted = []; + const rest = []; + for (const h of allHandles) { + if (HIGHLIGHT_HANDLES.includes(h.toLowerCase())) highlighted.push(h); + else rest.push(h); + } + for (let i = rest.length - 1; i > 0; i--) { + const j = floor(Math.random() * (i + 1)); + [rest[i], rest[j]] = [rest[j], rest[i]]; + } + allHandles = [...highlighted, ...rest]; + // Prefetch colors for all displayed handles + allHandles.forEach((h) => fetchHandleColor(h)); + } catch {} +} + +function setupButtons(ui, screen) { + buyBtn = new ui.TextButton(getBuyText(), { center: "x", bottom: 20, screen }); + osBtn = new ui.TextButton("AC Native OS", { x: 6, bottom: 20, screen }); + paperBtn = new ui.TextButton("PLORK'ing the Planet", { x: 6, bottom: 20, screen }); + manualBtn = new ui.TextButton("ThinkPad 11e Yoga Manual", { x: 6, bottom: 20 + (paperBtn.height || 14) + 4, screen }); +} + +// Checkout lives on Shopify now — no pre-flight request needed. + +function paint($) { + const { wipe, ink, line, screen, dark: isDark, tri, text } = $; + frame += 1; + const w = screen.width; + const h = screen.height; + const wide = w / h > 1.5; + + // Theme colors + const bg = isDark ? [14, 12, 20] : [232, 228, 238]; + const fg = isDark ? 255 : 20; + const fgDim = isDark ? 120 : 100; + + + wipe(...bg); + + // Animated backdrop — drifting primary/secondary color bands + { + const t = frame * 0.01; + const alpha = isDark ? 35 : 20; + // Red, blue, yellow — primary colors that pop against the plain laptop + const palette = [ + [200, 30, 30], // red + [30, 50, 200], // blue + [220, 200, 20], // yellow + [20, 180, 60], // green + [180, 40, 180], // magenta + ]; + for (let i = 0; i < palette.length; i++) { + const phase = i * (PI * 2) / palette.length; + const bw = floor(w * 0.5); + const bh = floor(h * 0.35); + const bx = max(0, min(w - bw, floor(w * 0.5 + sin(t + phase) * w * 0.4 - bw / 2))); + const by = max(0, min(h - bh, floor(h * 0.5 + cos(t * 0.7 + phase) * h * 0.35 - bh / 2))); + const [cr, cg, cb] = palette[i]; + ink(cr, cg, cb, alpha).box(bx, by, bw, bh, "fill"); + } + } + + // Cycling color shadow (computed early, used later for text + buttons) + const shadowOff = 1; + const shadowAlpha = isDark ? 180 : 120; + const st = frame * 0.02; + const sr = floor(180 + sin(st) * 75); + const sg = floor(180 + sin(st + PI * 0.66) * 75); + const sb = floor(180 + sin(st + PI * 1.33) * 75); + + // Thanks page (early return) + if (thanks) { + const cy = floor(h / 2); + ink(sr, sg, sb, shadowAlpha).write("your blank is coming.", { center: "x", y: cy - 30 + shadowOff, screen }); + ink(fg).write("your blank is coming.", { center: "x", y: cy - 30, screen }); + ink(sr, sg, sb, shadowAlpha).write("we'll be in touch.", { center: "x", y: cy + shadowOff, screen }); + ink(fgDim).write("we'll be in touch.", { center: "x", y: cy, screen }); + return; + } + + // Compute button zone height + const btnH = buyBtn ? buyBtn.height + 6 : 26; + const uiZoneH = wide ? btnH + 16 : btnH * 2 + 20; + const contentBottom = h - uiZoneH - 20; + + // 💻 Wireframe laptop (turntable swivel) + { + const cx = floor(w / 2); + const cy = floor(h * (wide ? 0.68 : 0.54)); // push lower in widescreen to avoid overlap + // Keep laptop fully within viewport (account for rotation + lid overshoot) + const availW = w * 0.42; + const availH = (contentBottom - 20) * 0.32; + const size = min(availW, availH); + const fov = size * 5.5; + + // Slow turntable + gentle tilt variation (see it from all angles) + const ay = frame * 0.006; + const ax = 0.3 + sin(frame * 0.003) * 0.25 + sin(frame * 0.0017) * 0.15; + + // Animated hinge: cycle through closed → laptop → flat → tablet + const closedAngle = 0.02; + const laptopAngle = PI * 0.67; + const flatAngle = PI; + const tabletAngle = PI * 2 - 0.02; + const keyframes = [closedAngle, laptopAngle, flatAngle, tabletAngle, laptopAngle]; + const totalPhases = keyframes.length; + const phaseLen = 180; + const t = (frame % (totalPhases * phaseLen)) / phaseLen; + const phase = floor(t); + const frac = t - phase; + const ease = frac < 0.5 ? 2 * frac * frac : 1 - 2 * (1 - frac) * (1 - frac); + const from = keyframes[phase % totalPhases]; + const to = keyframes[(phase + 1) % totalPhases]; + const hingeAngle = from + ease * (to - from); + + // Dimensions from spec: 293mm × 207mm × 19.9mm + const hw = 1.44, hh = 0.07, hd = 1.0; + const lidThick = hh * 0.9; // lid thinner than base (display panel) + + // Barrel hinge: attachment point travels a semicircular arc + // from base top-back (closed) to base bottom-back (tablet). + // Barrel radius = half total stack height (base + lid clearance) + const R = hh + lidThick / 2; + + // Attachment point on the barrel circle + // hingeAngle 0 → top-back (0, 0), hingeAngle 2π → bottom-back (2*hh, 0) + const attachY = R * (1 - cos(hingeAngle / 2)); + const attachZ = R * sin(hingeAngle / 2); + + // Base slab: y 0 (top) to 2*hh (bottom), z 0 (back) to 2*hd (front) + const base = [ + [-hw, 0, 0], [hw, 0, 0], [hw, 2 * hh, 0], [-hw, 2 * hh, 0], + [-hw, 0, 2 * hd], [hw, 0, 2 * hd], [hw, 2 * hh, 2 * hd], [-hw, 2 * hh, 2 * hd], + ]; + + // Lid local: screen face at y=0, outer face at y=-lidThick + // z from 0 (hinge edge) to 2*hd (front edge) + const cosH = cos(hingeAngle), sinH = sin(hingeAngle); + const lidLocal = [ + [-hw, 0, 0], [hw, 0, 0], + [hw, -lidThick, 0], [-hw, -lidThick, 0], + [-hw, 0, 2 * hd], [hw, 0, 2 * hd], + [hw, -lidThick, 2 * hd], [-hw, -lidThick, 2 * hd], + ]; + + // Lid transform: rotate by hingeAngle (2:1 vs barrel sweep), translate to attachment + const lid = lidLocal.map(([lx, ly, lz]) => { + const ry = ly * cosH - lz * sinH; + const rz = ly * sinH + lz * cosH; + return [lx, attachY + ry, attachZ + rz]; + }); + + // Hinge barrels — centered at barrel center, rotate at barrel sweep rate + const barrelCY = R; // = hh + lidThick/2 + const barrelCZ = 0; + const barrelW = 0.28; + const barrelH = hh * 1.6; + const barrelD = hh * 1.4; + const barrelPositions = [-hw * 0.65, hw * 0.65]; + const barrelSweep = hingeAngle / 2; + const cB = cos(barrelSweep), sB = sin(barrelSweep); + const hingeVerts = []; + for (const bx of barrelPositions) { + const bw = barrelW / 2, bh = barrelH / 2, bd = barrelD / 2; + const barrelLocal = [ + [bx - bw, -bh, -bd], [bx + bw, -bh, -bd], + [bx + bw, bh, -bd], [bx - bw, bh, -bd], + [bx - bw, -bh, bd], [bx + bw, -bh, bd], + [bx + bw, bh, bd], [bx - bw, bh, bd], + ]; + hingeVerts.push(barrelLocal.map(([vx, vy, vz]) => { + const ry = vy * cB - vz * sB; + const rz = vy * sB + vz * cB; + return [vx, barrelCY + ry, barrelCZ + rz]; + })); + } + + const halfEdges = [ + [0, 1], [1, 2], [2, 3], [3, 0], + [4, 5], [5, 6], [6, 7], [7, 4], + [0, 4], [1, 5], [2, 6], [3, 7], + ]; + + const project = ([x, y, z]) => { + let rx = x * cos(ay) - z * sin(ay); + let rz = x * sin(ay) + z * cos(ay); + let ry = y * cos(ax) - rz * sin(ax); + rz = y * sin(ax) + rz * cos(ax); + const scale = fov / (fov + rz * size); + return [cx + rx * size * scale, cy + ry * size * scale, rz]; + }; + + const projBase = base.map(project); + const projLid = lid.map(project); + + // 🎨 Z-sorted rendering: collect all faces, sort back-to-front, draw + const faceQuads = [ + [0, 1, 5, 4], [3, 2, 6, 7], [0, 3, 7, 4], + [1, 2, 6, 5], [0, 1, 2, 3], [4, 5, 6, 7], + ]; + // ThinkPad black/dark gray palette + const baseColor = isDark ? [28, 28, 30] : [42, 42, 45]; + const lidColor = isDark ? [24, 24, 26] : [38, 38, 40]; + const keyColor = isDark ? [38, 38, 42] : [52, 52, 56]; + + // Collect all drawable quads with z-depth + const drawList = []; + + // 3D face normal backface culling — works regardless of vertex winding + // Compute view direction (camera looks toward +z in world, before rotations) + // After ay (Y-rot) and ax (X-rot), the view direction is: + const viewDirX = sin(ay) * cos(ax); + const viewDirY = sin(ax); + const viewDirZ = cos(ay) * cos(ax); + + const addFaces = (verts3d, proj, color, tag) => { + const frontFaces = new Set(); + // Compute box center for outward normal correction + let cx3 = 0, cy3 = 0, cz3 = 0; + for (const v of verts3d) { cx3 += v[0]; cy3 += v[1]; cz3 += v[2]; } + cx3 /= verts3d.length; cy3 /= verts3d.length; cz3 /= verts3d.length; + + for (let fi = 0; fi < faceQuads.length; fi++) { + const [a, b, c, d] = faceQuads[fi]; + // Face center + const fcx = (verts3d[a][0] + verts3d[b][0] + verts3d[c][0] + verts3d[d][0]) / 4; + const fcy = (verts3d[a][1] + verts3d[b][1] + verts3d[c][1] + verts3d[d][1]) / 4; + const fcz = (verts3d[a][2] + verts3d[b][2] + verts3d[c][2] + verts3d[d][2]) / 4; + // 3D face normal via cross product of two edges + const e1x = verts3d[b][0] - verts3d[a][0], e1y = verts3d[b][1] - verts3d[a][1], e1z = verts3d[b][2] - verts3d[a][2]; + const e2x = verts3d[d][0] - verts3d[a][0], e2y = verts3d[d][1] - verts3d[a][1], e2z = verts3d[d][2] - verts3d[a][2]; + let nx = e1y * e2z - e1z * e2y; + let ny = e1z * e2x - e1x * e2z; + let nz = e1x * e2y - e1y * e2x; + // Ensure normal points OUTWARD (away from box center) + const outDot = nx * (fcx - cx3) + ny * (fcy - cy3) + nz * (fcz - cz3); + if (outDot < 0) { nx = -nx; ny = -ny; nz = -nz; } + // Normalize + const nLen = sqrt(nx * nx + ny * ny + nz * nz) || 1; + nx /= nLen; ny /= nLen; nz /= nLen; + // Dot with view direction — negative = faces camera (opposes view dir) + const dot = nx * viewDirX + ny * viewDirY + nz * viewDirZ; + if (dot >= 0) continue; // faces away from camera → skip + frontFaces.add(fi); + const z = (proj[a][2] + proj[b][2] + proj[c][2] + proj[d][2]) / 4; + drawList.push({ z, type: "face", proj, verts: [a, b, c, d], color, tag }); + } + return frontFaces; + }; + + const baseFrontFaces = addFaces(base, projBase, baseColor, "base"); + const lidFrontFaces = addFaces(lid, projLid, lidColor, "lid"); + + // Keyboard keys (on base top face, y = -0.001 just above y=0) + const kbInset = 0.18; + const kbTL = project([-hw + kbInset, -0.001, kbInset]); + const kbTR = project([hw - kbInset, -0.001, kbInset]); + const kbBL = project([-hw + kbInset, -0.001, 2 * hd - kbInset * 3]); + const kbBR = project([hw - kbInset, -0.001, 2 * hd - kbInset * 3]); + // Screen-space winding check (same approach as lid screen content) + const kbE1x = kbTR[0] - kbTL[0], kbE1y = kbTR[1] - kbTL[1]; + const kbE2x = kbBL[0] - kbTL[0], kbE2y = kbBL[1] - kbTL[1]; + const kbCross = kbE1x * kbE2y - kbE1y * kbE2x; + // Smooth alpha from cross product magnitude (same as screen content) + const kbMaxCross = size * size * 0.5; + const kbFacingAmount = kbCross < 0 ? min(1, abs(kbCross) / kbMaxCross) : 0; + // Fade with hinge angle (smooth open/close) and lid occlusion + const hingeFade = min(1, max(0, (hingeAngle - 0.1) / 0.5)); + const lidOccludes = hingeAngle < PI && lidFrontFaces.has(1); + const kbAlpha = floor(kbFacingAmount * hingeFade * (lidOccludes ? 0 : 1) * 255); + const kbFacing = kbAlpha > 2; + const kbKeys = []; + if (kbFacing) { + // 6-row ThinkPad keyboard layout (key counts per row) + // Row 0: Fn keys (14 keys: Esc + F1-F12 + Del) + // Row 1: Number row (14 keys: ` 1-0 - = Bksp) + // Row 2: QWERTY (14 keys: Tab + Q-P + [ ] \) + // Row 3: Home row (13 keys: Caps + A-L + ; ' Enter) + // Row 4: Bottom alpha (12 keys: Shift + Z-M + , . / Shift) + // Row 5: Modifier row (8 keys: Ctrl Fn Win Alt Space Alt PrtSc Ctrl) + const rows = [14, 14, 14, 13, 12, 8]; + const rowIndent = [0, 0, 0.02, 0.04, 0.06, 0]; + // Keyboard occupies top 55% of base, trackpad extends to near edge + const kbFrac = 0.55; + const lerp = (a, b, t) => a + (b - a) * t; + for (let r = 0; r < rows.length; r++) { + const nKeys = rows[r]; + const t0 = (r + 0.1) / rows.length * kbFrac; + const t1 = (r + 0.9) / rows.length * kbFrac; + const indent = rowIndent[r]; + for (let k = 0; k < nKeys; k++) { + // Row 5 has variable-width keys (space bar is wider) + let u0, u1; + if (r === 5) { + // Space bar is key index 4, takes ~40% width + const widths = [1, 1, 1, 1, 4.5, 1, 1, 1]; + const total = widths.reduce((s, w) => s + w, 0); + let start = 0; + for (let j = 0; j < k; j++) start += widths[j]; + u0 = (start + 0.1) / total; + u1 = (start + widths[k] - 0.1) / total; + } else { + u0 = indent + (k + 0.1) / nKeys * (1 - indent * 2); + u1 = indent + (k + 0.9) / nKeys * (1 - indent * 2); + } + kbKeys.push({ + pts: [ + [lerp(lerp(kbTL[0], kbTR[0], u0), lerp(kbBL[0], kbBR[0], u0), t0), + lerp(lerp(kbTL[1], kbTR[1], u0), lerp(kbBL[1], kbBR[1], u0), t0)], + [lerp(lerp(kbTL[0], kbTR[0], u1), lerp(kbBL[0], kbBR[0], u1), t0), + lerp(lerp(kbTL[1], kbTR[1], u1), lerp(kbBL[1], kbBR[1], u1), t0)], + [lerp(lerp(kbTL[0], kbTR[0], u1), lerp(kbBL[0], kbBR[0], u1), t1), + lerp(lerp(kbTL[1], kbTR[1], u1), lerp(kbBL[1], kbBR[1], u1), t1)], + [lerp(lerp(kbTL[0], kbTR[0], u0), lerp(kbBL[0], kbBR[0], u0), t1), + lerp(lerp(kbTL[1], kbTR[1], u0), lerp(kbBL[1], kbBR[1], u0), t1)], + ], + }); + } + } + + // Trackpad (centered, below keyboard, ~35% width of base) + const tpU0 = 0.3, tpU1 = 0.7; + const tpT0 = kbFrac + 0.03, tpT1 = 0.98; + const trackpadColor = isDark ? [32, 32, 36] : [48, 48, 52]; + kbKeys.push({ + pts: [ + [lerp(lerp(kbTL[0], kbTR[0], tpU0), lerp(kbBL[0], kbBR[0], tpU0), tpT0), + lerp(lerp(kbTL[1], kbTR[1], tpU0), lerp(kbBL[1], kbBR[1], tpU0), tpT0)], + [lerp(lerp(kbTL[0], kbTR[0], tpU1), lerp(kbBL[0], kbBR[0], tpU1), tpT0), + lerp(lerp(kbTL[1], kbTR[1], tpU1), lerp(kbBL[1], kbBR[1], tpU1), tpT0)], + [lerp(lerp(kbTL[0], kbTR[0], tpU1), lerp(kbBL[0], kbBR[0], tpU1), tpT1), + lerp(lerp(kbTL[1], kbTR[1], tpU1), lerp(kbBL[1], kbBR[1], tpU1), tpT1)], + [lerp(lerp(kbTL[0], kbTR[0], tpU0), lerp(kbBL[0], kbBR[0], tpU0), tpT1), + lerp(lerp(kbTL[1], kbTR[1], tpU0), lerp(kbBL[1], kbBR[1], tpU0), tpT1)], + ], + color: trackpadColor, + }); + } + + // Wireframe edges — only for edges that touch at least one front-facing face + // Map each edge to which faces it belongs to + const edgeFaceMap = {}; // "a-b" → [faceIdx, ...] + faceQuads.forEach(([a, b, c, d], fi) => { + const edges = [[a,b],[b,c],[c,d],[d,a]]; + // For our quads: edges are [a,b],[b,c=next],[c,d],[d,a] + // But faceQuads use 4 verts, edges are between consecutive + wrap + [[a,b],[b,c],[c,d],[d,a]].forEach(([ea, eb]) => { + const key = min(ea,eb) + "-" + max(ea,eb); + (edgeFaceMap[key] = edgeFaceMap[key] || []).push(fi); + }); + }); + + const addEdges = (proj, frontFaces) => { + halfEdges.forEach(([a, b]) => { + const key = min(a,b) + "-" + max(a,b); + const faces = edgeFaceMap[key] || []; + // Only draw if at least one adjacent face is front-facing + if (!faces.some(fi => frontFaces.has(fi))) return; + const z = (proj[a][2] + proj[b][2]) / 2; + drawList.push({ z, type: "edge", proj, a, b }); + }); + }; + addEdges(projBase, baseFrontFaces); + addEdges(projLid, lidFrontFaces); + + // Sort back-to-front (highest z = farthest = draw first) + drawList.sort((a, b) => b.z - a.z); + + // Draw everything in sorted order + for (const item of drawList) { + if (item.type === "face") { + const { proj, verts: [a, b, c, d], color } = item; + const shade = max(0.6, min(1.2, 1.0 - item.z * 0.12)); + ink(floor(color[0] * shade), floor(color[1] * shade), floor(color[2] * shade)); + tri(proj[a][0], proj[a][1], proj[b][0], proj[b][1], proj[c][0], proj[c][1]); + tri(proj[a][0], proj[a][1], proj[c][0], proj[c][1], proj[d][0], proj[d][1]); + } else if (item.type === "edge") { + const { proj, a, b } = item; + const blink = sin(frame * 0.08 + a * 1.7 + b * 2.3) * 0.5 + 0.5; + const alpha = floor(30 + blink * 50); + ink(255, 255, 255, alpha).line(proj[a][0], proj[a][1], proj[b][0], proj[b][1]); + } + } + + // ⌨️ Keyboard keys — smooth fade based on facing + hinge + occlusion + if (kbFacing) { + for (const key of kbKeys) { + const [[x0, y0], [x1, y1], [x2, y2], [x3, y3]] = key.pts; + const c = key.color || keyColor; + ink(c[0], c[1], c[2], kbAlpha); + tri(x0, y0, x1, y1, x2, y2); + tri(x0, y0, x2, y2, x3, y3); + // White blinky wireframe outline + const blink = sin(frame * 0.08 + x0 * 0.3 + y0 * 0.7) * 0.5 + 0.5; + const wireAlpha = floor((30 + blink * 50) * kbAlpha / 255); + ink(255, 255, 255, wireAlpha); + line(x0, y0, x1, y1); + line(x1, y1, x2, y2); + line(x2, y2, x3, y3); + line(x3, y3, x0, y0); + } + } + + // 🖥️ Screen on the lid (with bezel, solid fill, and text) + const inset = 0.15; + const bezelInset = 0.08; + const hingeInset = 0.35; // larger inset at hinge end (away from base) + // Screen is on the INNER face of the lid (y=0 in lid local). + const screenY = 0.002; + const screenTL = [-hw + inset, screenY, 2 * hd - inset]; + const screenTR = [hw - inset, screenY, 2 * hd - inset]; + const screenBL = [-hw + inset, screenY, hingeInset]; + const screenBR = [hw - inset, screenY, hingeInset]; + + // Bezel corners (slightly larger than screen) + const bezelY = 0.001; + const bezelTL = [-hw + bezelInset, bezelY, 2 * hd - bezelInset]; + const bezelTR = [hw - bezelInset, bezelY, 2 * hd - bezelInset]; + const bezelBL = [-hw + bezelInset, bezelY, hingeInset - 0.07]; + const bezelBR = [hw - bezelInset, bezelY, hingeInset - 0.07]; + + // Same transform as lid vertices (barrel hinge) + const hingeXform = ([lx, ly, lz]) => { + const ry = ly * cosH - lz * sinH; + const rz = ly * sinH + lz * cosH; + return [lx, attachY + ry, attachZ + rz]; + }; + const sTL = hingeXform(screenTL), sTR = hingeXform(screenTR); + const sBL = hingeXform(screenBL), sBR = hingeXform(screenBR); + const bTL = hingeXform(bezelTL), bTR = hingeXform(bezelTR); + const bBL = hingeXform(bezelBL), bBR = hingeXform(bezelBR); + + // Negate right vector — inner face mirrors x when viewed from front + const planeRight = [sTL[0] - sTR[0], sTL[1] - sTR[1], sTL[2] - sTR[2]]; + const planeDown = [sBL[0] - sTL[0], sBL[1] - sTL[1], sBL[2] - sTL[2]]; + + // Screen-space normal check (is it facing the camera?) + const projTL = project(sTL); + const projTR = project(sTR); + const projBL = project(sBL); + const projBR = project(sBR); + const ex1 = projTR[0] - projTL[0], ey1 = projTR[1] - projTL[1]; + const ex2 = projBL[0] - projTL[0], ey2 = projBL[1] - projTL[1]; + const cross = ex1 * ey2 - ey1 * ex2; + + if (cross < 0) { + const maxCross = size * size * 0.5; + const facing = min(1, abs(cross) / maxCross); + const screenAlpha = floor(facing * 220); + + // Bezel (dark border around screen) — two filled triangles + const pBTL = project(bTL), pBTR = project(bTR); + const pBBL = project(bBL), pBBR = project(bBR); + const bezelColor = [20, 20, 22]; + ink(bezelColor[0], bezelColor[1], bezelColor[2], screenAlpha); + tri(pBTL[0], pBTL[1], pBTR[0], pBTR[1], pBBR[0], pBBR[1]); + tri(pBTL[0], pBTL[1], pBBR[0], pBBR[1], pBBL[0], pBBL[1]); + + // Screen fill (dark background) — two filled triangles + const screenColor = [6, 6, 10]; + ink(screenColor[0], screenColor[1], screenColor[2], screenAlpha); + tri(projTL[0], projTL[1], projTR[0], projTR[1], projBR[0], projBR[1]); + tri(projTL[0], projTL[1], projBR[0], projBR[1], projBL[0], projBL[1]); + + // Subtle scanline / noise animation on screen + { + const st = frame * 0.02; + const scanA = floor(facing * 18); + // Horizontal scanline that scrolls down + const scanFrac = (st * 0.5) % 1; + const scanY0 = projTL[1] + (projBL[1] - projTL[1]) * scanFrac; + const scanY1 = scanY0 + (projBL[1] - projTL[1]) * 0.06; + const sxL = projTL[0] + (projBL[0] - projTL[0]) * scanFrac; + const sxR = projTR[0] + (projBR[0] - projTR[0]) * scanFrac; + const sxL1 = projTL[0] + (projBL[0] - projTL[0]) * min(1, scanFrac + 0.06); + const sxR1 = projTR[0] + (projBR[0] - projTR[0]) * min(1, scanFrac + 0.06); + ink(40, 60, 80, scanA); + tri(sxL, scanY0, sxR, scanY0, sxR1, scanY1); + tri(sxL, scanY0, sxR1, scanY1, sxL1, scanY1); + } + + // Screen text + const textAlpha = floor(facing * 255); + const planeW = sqrt(planeRight[0] ** 2 + planeRight[1] ** 2 + planeRight[2] ** 2); + const planeH = sqrt(planeDown[0] ** 2 + planeDown[1] ** 2 + planeDown[2] ** 2); + + const glyphScale = planeW / (4 * 14); + const rn = [planeRight[0] / planeW * glyphScale, + planeRight[1] / planeW * glyphScale, + planeRight[2] / planeW * glyphScale]; + const dn = [planeDown[0] / planeH * glyphScale, + planeDown[1] / planeH * glyphScale, + planeDown[2] / planeH * glyphScale]; + + // Dynamic text: "hi @handle" if logged in, cycle real handles otherwise + let displayHandle = userHandle; + let handleClean = null; + if (!displayHandle && allHandles.length > 0) { + cycleTimer += 1; + if (cycleTimer >= CYCLE_INTERVAL) { + cycleTimer = 0; + cycleIndex = (cycleIndex + 1) % allHandles.length; + } + handleClean = allHandles[cycleIndex]; + displayHandle = `@${handleClean}`; + } else if (displayHandle) { + handleClean = displayHandle.startsWith("@") + ? displayHandle.slice(1) + : displayHandle; + } + // Look up colors every frame (async fetch may have completed) + currentDisplayColors = handleClean + ? handleColors.get(handleClean) || null + : null; + const screenText = displayHandle ? `hi ${displayHandle}` : "hi"; + // "hi " prefix length for color offset — "@" is at index 3 + const handleStart = 3; + // Estimate width: MatrixChunky8 avg ~4px per char + const textW = screenText.length * 4; + const textH = 8; + const offsetR = (planeW / glyphScale - textW) / 2; + const offsetD = (planeH / glyphScale - textH) / 2; + + // Origin from sTR (visual top-left when viewed from front) + const textOrigin = [ + sTR[0] + offsetR * rn[0] + offsetD * dn[0], + sTR[1] + offsetR * rn[1] + offsetD * dn[1], + sTR[2] + offsetR * rn[2] + offsetD * dn[2], + ]; + + // Screen text with handle colors when available + ink(0).write3D(screenText, { + origin: textOrigin, + right: rn, + down: dn, + project, + typeface: "MatrixChunky8", + pixelCallback: (sx, sy, gx, gy, ci) => { + const seed = (gx * 7 + gy * 13 + ci * 31 + frame * 3) & 0xFF; + const flicker = sin(frame * 0.15 + seed * 0.1) * 0.5 + 0.5; + // More varied opacity — some pixels dim, some bright, some blink off + const opVar = sin(seed * 0.37 + frame * 0.08) * 0.5 + 0.5; + const dropout = ((seed * 17 + frame) % 61) < 4 ? 0.15 : 1; + const a = floor(textAlpha * (0.3 + opVar * 0.5 + flicker * 0.2) * dropout); + + // Use handle colors for the @handle portion + const charIdx = ci - handleStart; + if (currentDisplayColors && charIdx >= 0 && charIdx < currentDisplayColors.length) { + const c = currentDisplayColors[charIdx]; + const shimmer = 0.6 + flicker * 0.4; + ink( + floor(c.r * shimmer), + floor(c.g * shimmer), + floor(c.b * shimmer), + a, + ); + } else { + // Default blue/cyan for "hi " prefix or no colors + const flash = (seed + frame) % 47 === 0; + const r = flash ? 220 : floor(20 + flicker * 40); + const g = flash ? 240 : floor(80 + flicker * 100 + seed * 0.2); + const b = flash ? 255 : floor(180 + flicker * 75); + ink(r, g, b, a); + } + }, + }); + } + } + + // Title + product description (painted on top of laptop wireframe) + const titleY = wide ? 10 : 30; + const titleSize = wide ? 1 : 2; + ink(sr, sg, sb, shadowAlpha).write("AC Blank Laptop", { center: "x", y: titleY + shadowOff, size: titleSize, screen }); + ink(fg).write("AC Blank Laptop", { center: "x", y: titleY, size: titleSize, screen }); + // Measure wrapped description to position buy button below it + const descBounds = floor(w * (wide ? 0.95 : 0.85)); + const descY = wide ? titleY + 12 : 54; + const descBox = text.box(DESCRIPTION_PLAIN, { center: "x", y: descY, screen }, descBounds); + const descBottom = descY + (descBox ? descBox.box.height : charH); + + ink(sr, sg, sb, shadowAlpha).write(DESCRIPTION_PLAIN, { center: "x", y: descY + shadowOff, screen }, undefined, descBounds); + ink(fgDim).write(DESCRIPTION, { center: "x", y: descY, screen }, undefined, descBounds); + + // Buy button — custom rendered in Unifont for larger, more active CTA + const $btn = { ink }; + if (buyBtn) { + const buyText = getBuyText(); + // Size box for Unifont (8px wide chars, 16px tall) + padding + const unifontCharW = 8, unifontH = 16, padX = 4, padY = 2; + const boxW = buyText.length * unifontCharW + padX * 2; + const boxH = unifontH + padY * 2; + const boxX = floor((screen.width - boxW) / 2); + const boxY = descBottom + 6; // directly under the prose + buyBtn.btn.box.x = boxX; + buyBtn.btn.box.y = boxY; + buyBtn.btn.box.w = boxW; + buyBtn.btn.box.h = boxH; + const bx = buyBtn.btn.box; + + // Animated background + const t = performance.now() / 1000; + const isOver = buyBtn.btn.over; + const isDown = buyBtn.btn.down; + + { + // Breathing glow animation + const breath = sin(t * 2) * 0.5 + 0.5; + const wave = sin(t * 3.5) * 0.3 + 0.7; + let bgR, bgG, bgB; + + if (isDown) { + bgR = isDark ? 60 : 190; bgG = isDark ? 80 : 210; bgB = isDark ? 60 : 190; + } else if (isOver) { + bgR = isDark ? 40 : 205; bgG = isDark ? 65 : 230; bgB = isDark ? 40 : 205; + } else { + bgR = isDark ? floor(20 + breath * 15) : floor(215 + breath * 15); + bgG = isDark ? floor(30 + breath * 20) : floor(230 + breath * 15); + bgB = isDark ? floor(20 + breath * 10) : floor(215 + breath * 10); + } + ink(bgR, bgG, bgB).box(bx, "fill"); + + // Animated outline — pulses brighter + const oG = isDark ? floor(80 + wave * 120 + breath * 55) : floor(40 + wave * 80 + breath * 35); + ink(isDark ? [40, oG, 40] : [30, oG, 30]).box(bx, "outline"); + + // Shadow text + ink(sr, sg, sb, isDark ? 150 : 80).write(buyText, { x: bx.x + padX + 1, y: bx.y + padY + 1 }, undefined, undefined, false, "unifont"); + // Main text — breathing green + const tG = isDark ? floor(160 + breath * 80 + wave * 15) : floor(20 + breath * 30); + ink(isDark ? [140 + floor(breath * 60), tG, 140 + floor(breath * 40)] : [20, tG, 20]) + .write(buyText, { x: bx.x + padX, y: bx.y + padY }, undefined, undefined, false, "unifont"); + } + $.needsPaint(); + } + + // Color schemes for bottom link buttons + const paperScheme = isDark + ? [[25, 20, 20], [200, 140, 80], [240, 190, 130]] + : [[240, 230, 220], [140, 80, 30], [100, 55, 15]]; + const paperHover = isDark + ? [[35, 28, 28], [240, 180, 100], [255, 210, 150]] + : [[235, 222, 210], [160, 100, 40], [120, 70, 20]]; + const manualScheme = isDark + ? [[20, 20, 30], [100, 140, 200], [160, 190, 240]] + : [[220, 225, 240], [50, 70, 140], [30, 50, 100]]; + const manualHover = isDark + ? [[30, 30, 45], [140, 180, 240], [200, 220, 255]] + : [[210, 215, 235], [40, 60, 160], [20, 40, 120]]; + const osScheme = isDark + ? [[20, 25, 20], [80, 200, 120], [140, 240, 170]] + : [[225, 240, 225], [30, 120, 50], [15, 90, 30]]; + const osHover = isDark + ? [[28, 35, 28], [120, 240, 150], [180, 255, 200]] + : [[215, 235, 215], [20, 140, 60], [10, 110, 40]]; + + const btnGap = 4; + const t = frame * 0.04; + + if (wide) { + // Horizontal bottom layout for widescreen — no labels, just buttons in a row + const colW = floor(w / 3); + const btnBottom = 4; + const charW = 6, btnPad = 8; + const estW = (label) => label.length * charW + btnPad; + + if (paperBtn) { + const label = "PLORK'ing the Planet"; + const bw = estW(label); + paperBtn.reposition({ x: floor(colW * 0.5 - bw / 2), bottom: btnBottom, screen }, label); + paperBtn.paint($btn, paperScheme, paperHover); + } + if (manualBtn) { + const label = "ThinkPad 11e Yoga Manual"; + const bw = estW(label); + manualBtn.reposition({ x: floor(colW * 1.5 - bw / 2), bottom: btnBottom, screen }, label); + manualBtn.paint($btn, manualScheme, manualHover); + } + if (osBtn) { + const label = "AC Native OS"; + const bw = estW(label); + osBtn.reposition({ x: floor(colW * 2.5 - bw / 2), bottom: btnBottom, screen }, label); + osBtn.paint($btn, osScheme, osHover); + } + } else { + // Vertical stack layout for portrait/square + const labelGap = 2; + const labelH = charH + labelGap; + + const paintLabel = (label, bottomY, phase) => { + const lx = 6; + const ly = h - bottomY - charH; + const wobble = floor(sin(t + phase) * 1.5); + ink(sr, sg, sb, shadowAlpha).write(label, { x: lx + shadowOff, y: ly + shadowOff + wobble, screen }); + ink(fg).write(label, { x: lx, y: ly + wobble, screen }); + }; + + let stackY = 20; + + // --- Why!? + Paper --- + if (paperBtn) { + paperBtn.reposition({ x: 6, bottom: stackY, screen }, "PLORK'ing the Planet"); + paperBtn.paint($btn, paperScheme, paperHover); + stackY += paperBtn.height + btnGap; + } + paintLabel("Why!?", stackY, 0); + stackY += labelH; + + // --- What!? + Manual --- + if (manualBtn) { + manualBtn.reposition({ x: 6, bottom: stackY, screen }, "ThinkPad 11e Yoga Manual"); + manualBtn.paint($btn, manualScheme, manualHover); + stackY += manualBtn.height + btnGap; + } + paintLabel("What!?", stackY, PI * 0.66); + stackY += labelH; + + // --- How!? + OS --- + if (osBtn) { + osBtn.reposition({ x: 6, bottom: stackY, screen }, "AC Native OS"); + osBtn.paint($btn, osScheme, osHover); + } + paintLabel("How!?", stackY + (osBtn ? osBtn.height + btnGap : 0), PI * 1.33); + } +} + +function act({ event: e, screen, jump, sound, ui, api }) { + if (thanks) return; + + if (e.is("reframed")) { + setupButtons(ui, screen); + } + + manualBtn?.btn?.act(e, { + push: () => jump(`out:${MANUAL_URL}`), + }); + + paperBtn?.btn?.act(e, { + push: () => jump(`out:${PAPER_URL}`), + }); + + osBtn?.btn?.act(e, { + push: () => jump("os"), + }); + + buyBtn?.btn?.act(e, { + down: () => { + sound?.synth({ type: "sine", tone: 440, duration: 0.05, volume: 0.3 }); + }, + push: () => { + sound?.synth({ type: "sine", tone: 880, duration: 0.1, volume: 0.4 }); + // Jump to the Shopify product (orderable there). + jump(`out:${SHOP_URL}`); + }, + }); +} + +function meta() { + return { + title: "AC Blank Laptop", + desc: "AC Native Laptop — a surplus laptop running AC Native OS.", + }; +} + +export { boot, paint, act, meta }; diff --git a/easel/context/reference/disks/camera.mjs b/easel/context/reference/disks/camera.mjs new file mode 100644 index 000000000..58ed9b22b --- /dev/null +++ b/easel/context/reference/disks/camera.mjs @@ -0,0 +1,186 @@ +// Camera, 2022.6.19.11.16 +// A simple piece for taking stills and pasting them to the system painting. + +/* #region 🏁 todo ++ Later + - [] Add filters... + - [] See `bios.mjs:4247` for GPU style video effects. + - [] Give combined configurations top-level words, like `hellscape`. + (Which would be an environment facing camera with a flame effect.) + - [] Support more weird iOS cameras using parameters. + - [] Add zoom-a-bility. ++ Done + - [x] Fix subtle rotation issues on iOS and potentially Android? + - [x] Add background painting to camera. + - [x] `cam:selfie` with a `selfie` alias + - [x] Pixels fill only the painting frame. + - [x] Alias `camera` to `cam` + - [x] Add [Switch] button. (Cycle through all webcams.) + - [x] Report back the device's camera count after first getting the video. + - [x] If user doesn't accept camera, then we send the back to prompt with + an error message of some kind. + - [x] Switching between Front and Rear cameras / updating constraints. + - [x] Automatically show the canvas border. + - [x] Immediately freeze the frame on leaving. + - [x] Reset / re-frame video on window resize. + - [x] Make sure iOS passes video-data in per each frame. + - [x] Test mobile Safari + - [x] Automatically paint the whole buffer on leave by default. + - [x] Add software shaders. + - [x] Add this camera to the "nopaint" system! + - [x] Camera needs to take up the whole display by default. +#endregion */ + +let vid, snap; +let swap; +// let advance; +let facing = "environment"; +let frame, + under = false, + underBuffer, + capturing = true; + +import * as sfx from "./common/sfx.mjs"; + +// 🥾 Boot +function boot({ ui, params, colon, clonePixels, system }) { + swap = new ui.TextButton("Swap"); + if (params[0] === "me") facing = "user"; + if (colon[0] === "under" || colon[0] === "u") under = true; + if (under) underBuffer = clonePixels(system.painting); +} + +// 🎨 Paint (Runs once per display refresh rate) +function paint({ + api, + wipe, + paste, + video, + cameras, + system, + screen, + num: { randIntRange, clamp, rand }, +}) { + // Initialize or update video feed. + if (!vid) { + wipe(0); + vid = video(!vid ? "camera" : "camera:update", { + width: system.painting.width, // width, + height: system.painting.height, // height, + facing, + }); + } + + // Draw the video on each frame and add an effect. + if (capturing) { + frame = vid(function shader({ x, y }, c) { + // ✨ Sparkles + if (rand() > 0.98) { + c[0] = clamp(c[0] + randIntRange(50, 150), 0, 255); + c[1] = clamp(c[1] + randIntRange(50, 150), 0, 255); + c[2] = clamp(c[2] + randIntRange(50, 150), 0, 255); + } + + // Fade + // if (rand() > 0.1) { + // c[3] = randIntRange(0, 5); + // } + }); + } + + // Paste the video to the main buffer. + paste(frame, system.nopaint.translation.x, system.nopaint.translation.y); + if (under) { + paste( + underBuffer, + system.nopaint.translation.x, + system.nopaint.translation.y, + ); + } + + system.nopaint.needsPresent = true; + + snap = () => { + paste(frame); + if (under) paste(underBuffer); + snap = null; + }; + + // User interface + if (cameras > 1) { + swap?.reposition({ bottom: 6, right: 6, screen }); + swap?.paint(api); + } +} + +function bake() { + snap?.(); +} + +function act({ + event: e, + jump, + send, + video, + hud, + cameras, + sound, + notice, + leaving, +}) { + if (e.is("lift") && !leaving() && swap.btn.down === false) { + sfx.push(sound); + jump("prompt")/*(() => send({ type: "keyboard:open" }));*/ + } + + if (cameras > 1) { + swap?.btn.act(e, { + down: () => sfx.down(sound), + push: () => { + sfx.push(sound); + swap.btn.disabled = true; + const faceTo = facing === "user" ? "environment" : "user"; + vid = video("camera:update", { facing: faceTo }); + }, + }); + } + + if ( + e.is("touch") || + e.is("keyboard:down:enter") || + e.is("keyboard:down:escape") || + e.is("keyboard:down:`") + ) { + if (!swap.btn.down) { + capturing = false; + } + + if (e.is("touch") && !swap?.btn.down && !hud.currentLabel().btn.down) { + sfx.down(sound); + } + } + + if (e.is("camera:mode:user")) { + facing = "user"; + swap.btn.disabled = false; + capturing = true; + } + + if (e.is("camera:mode:environment")) { + facing = "environment"; + swap.btn.disabled = false; + capturing = true; + } + + if (e.is("camera:denied")) { + notice("DENIED", ["yellow", "red"]); + jump("prompt"); + } +} + +export { boot, paint, bake, act }; + +export const system = "nopaint:bake-on-leave"; + +// 📚 Library (Useful functions used throughout the program) +// ... diff --git a/easel/context/reference/disks/notepat.mjs b/easel/context/reference/disks/notepat.mjs new file mode 100644 index 000000000..2f4849724 --- /dev/null +++ b/easel/context/reference/disks/notepat.mjs @@ -0,0 +1,9225 @@ +// Notepat, 2024.6.26.23.17.58.736 +// Tap the pads to play musical notes, or use the keyboard keys. + +import { + getNoteColorWithOctave, + parseNotepatNote, + isBlackKey, +} from "../lib/note-colors.mjs"; +import { drawMiniControllerDiagram } from "../lib/gamepad-diagram.mjs"; +import { detectChord } from "../lib/chord-detection.mjs"; +import { + decodeBitmapToSample, + loadPaintingAsAudio, +} from "../lib/pixel-sample.mjs"; +import { playPercussion } from "../lib/percussion.mjs"; +import * as gm from "../lib/gm.mjs"; +import { + WAVES_BY_BRIGHTNESS, + UNMEASURED_WAVES, +} from "../lib/sound/wave-timbre.mjs"; + +// 🎹 NuPhy Air60 HE — WebHID analog pressure support +// Sends activation commands then reads 0xA0 analog reports with per-key pressure. +const NUPHY_VID = 0x19f5; +const NUPHY_PID = 0xfee0; +const NUPHY_PRESSURE_MAX = 1600; // ~0x0640 at full press + +// HID scancode → DOM key name (lowercase for letter keys) +const HID_TO_KEY = { + 0x04: "a", 0x05: "b", 0x06: "c", 0x07: "d", 0x08: "e", 0x09: "f", + 0x0A: "g", 0x0B: "h", 0x0C: "i", 0x0D: "j", 0x0E: "k", 0x0F: "l", + 0x10: "m", 0x11: "n", 0x12: "o", 0x13: "p", 0x14: "q", 0x15: "r", + 0x16: "s", 0x17: "t", 0x18: "u", 0x19: "v", 0x1A: "w", 0x1B: "x", + 0x1C: "y", 0x1D: "z", + 0x1E: "1", 0x1F: "2", 0x20: "3", 0x21: "4", 0x22: "5", + 0x23: "6", 0x24: "7", 0x25: "8", 0x26: "9", 0x27: "0", + 0x28: "Enter", 0x29: "Escape", 0x2A: "Backspace", 0x2B: "Tab", + 0x2C: " ", 0x2D: "-", 0x2E: "=", + 0x2F: "[", 0x30: "]", 0x31: "\\", + 0x33: ";", 0x34: "'", 0x35: "`", 0x36: ",", 0x37: ".", 0x38: "/", + 0x39: "CapsLock", + 0x3A: "F1", 0x3B: "F2", 0x3C: "F3", 0x3D: "F4", 0x3E: "F5", 0x3F: "F6", + 0x40: "F7", 0x41: "F8", 0x42: "F9", 0x43: "F10", 0x44: "F11", 0x45: "F12", + 0x4F: "ArrowRight", 0x50: "ArrowLeft", 0x51: "ArrowDown", 0x52: "ArrowUp", + 0x4A: "Home", 0x4B: "PageUp", 0x4C: "Delete", 0x4D: "End", 0x4E: "PageDown", +}; +// NuPhy modifier bitmask scancodes +const HID_MOD_TO_KEY = { + 0x100: "Control", 0x200: "Shift", 0x400: "Alt", 0x800: "Meta", + 0x1000: "Control", 0x2000: "Shift", 0x4000: "Alt", 0x8000: "Meta", +}; + +let nuphyConnected = false; +let nuphyActiveKeys = new Map(); // scancode → { key, pressure } + +async function nuphyConnect(showPicker = false) { + if (typeof navigator === "undefined" || !navigator.hid) return false; + try { + let devices; + if (showPicker) { + devices = await navigator.hid.requestDevice({ + filters: [{ vendorId: NUPHY_VID, productId: NUPHY_PID }], + }); + } else { + devices = (await navigator.hid.getDevices()).filter( + (d) => d.vendorId === NUPHY_VID && d.productId === NUPHY_PID, + ); + } + if (!devices.length) return false; + + let rawDev = null, outId = 0; + for (const d of devices) { + if (!d.opened) await d.open(); + d.addEventListener("inputreport", nuphyOnInput); + for (const c of d.collections) { + const outs = (c.outputReports || []).map((r) => r.reportId); + if (outs.length > 0 && !rawDev) { rawDev = d; outId = outs[0]; } + } + } + // Also open any other matching devices not in the picker result + if (showPicker) { + for (const d of await navigator.hid.getDevices()) { + if (d.vendorId !== NUPHY_VID || d.productId !== NUPHY_PID) continue; + if (devices.includes(d)) continue; + if (!d.opened) await d.open(); + d.addEventListener("inputreport", nuphyOnInput); + if (!rawDev) for (const c of d.collections) { + const outs = (c.outputReports || []).map((r) => r.reportId); + if (outs.length > 0) { rawDev = d; outId = outs[0]; } + } + } + } + // Send activation commands + if (rawDev) { + for (const cmd of [0xA8, 0xA0]) { + const p = new Uint8Array(64); + p[0] = 0x55; p[1] = cmd; + try { await rawDev.sendReport(outId, p); } catch (e) { /* ignore */ } + } + } + nuphyConnected = true; + console.log("🎹 NuPhy Air60 HE analog connected!"); + return true; + } catch (e) { + console.warn("NuPhy WebHID:", e); + return false; + } +} + +// Live volume update for active sounds from NuPhy pressure +let nuphySoundContext = null; // Set in boot() to access sound.synth etc. + +function nuphyOnInput(ev) { + const d = new Uint8Array(ev.data.buffer); + if (d[0] !== 0xA0 || d.length < 10) return; + if (d[1] === 0xF0) return; // config report + + const scancode = (d[2] << 8) | d[3]; + const rawPressure = (d[4] << 8) | d[5]; + const direction = d[9]; // 0x01 = pressing, 0xFF = releasing + + const key = HID_TO_KEY[scancode] || HID_MOD_TO_KEY[scancode]; + if (!key) return; + + const pressure = Math.min(rawPressure / NUPHY_PRESSURE_MAX, 1.0); + const velocity = Math.round(pressure * 127) || 1; + const wasActive = nuphyActiveKeys.has(scancode); + + if (rawPressure > 0 && direction === 0x01) { + if (!wasActive) { + nuphyActiveKeys.set(scancode, { key, pressure }); + // Dispatch synthetic keydown with velocity from initial pressure + const evt = new KeyboardEvent("keydown", { + key, code: key, bubbles: true, cancelable: true, + }); + evt.velocity = velocity; + window.dispatchEvent(evt); + } else { + // Update pressure — continuously adjust volume of active sound + nuphyActiveKeys.get(scancode).pressure = pressure; + // Find the active sound for this key and update its volume + const lk = key.toLowerCase(); + const soundEntry = sounds[lk]; + if (soundEntry?.sound?.update) { + const vol = pressure * 0.7; // Linear: full press = 0.7 + soundEntry.sound.update({ volume: vol }); + } + // Update trail brightness + if (trail[lk]) trail[lk].brightness = pressure; + } + } else if (rawPressure === 0 || direction === 0xFF) { + if (wasActive) { + nuphyActiveKeys.delete(scancode); + const evt = new KeyboardEvent("keyup", { + key, code: key, bubbles: true, cancelable: true, + }); + window.dispatchEvent(evt); + } + } +} + +/* 📝 Notes + - [] Make `slide` work with `composite`. + (This may require some refactoring) + - [] Add recordable samples / custom samples... per key? + - [] Stored under handle? + - [🟠] Somehow represent both of these in the graphic layout. + - [x] Add z, x, a# and b below the lower octave. + - [x] Add ;, ', c# and d above the upper octave. + - [] Learn Tattooine theme. + - [x] Shift octave on either half up or down. + - [x] Test it and see if it's fun. + - [] Touch? + - [x] Keyboard shortcuts. + - [x] Add reverb that's only activated for the notepat sounds. + (Implemented via sound.room API and / key toggle) + - [] Add tappable touch bar for toggling the visualizer view. + - [🟠] Only show keyboard shortcuts once any keyboard key is pressed (on Android or iOS). + *** Song *** + - [] Song should loop visually, n times to the right as needed. + - [] Holding space bar should always do the current / next note. + (This shouldn't conflict with keys or mouse.) + - [] Add a visual progress bar <------------------------------> + - [] Make it so you can swipe through next or previous notes if one was + skipped. + ----------------------- + - [-] Encode a few more melodies with lyrics on the command line. + - [] Make sure that transposition will work. + - [-] Fix the single corner cross threshold. + - [] Fix the keyboard + mouse key conflict that keeps sounds sticking + in the upper octaves. + - [] Add volume centroid control to buttons with draggable changes. + - [] Add colon parameter ':lock' for hiding the ui to prevent accidental touches. + - [] Add perc buttons to `beatpat` along with a QR code similar to notepat. + - [] This could use the new button interface. + - [] Make oscilloscope into a button for going fullscreen with wireframe keys. + - [❓] I wonder if there's a way this can also be placed in a local secondary window + using inter-frame communication of some kind, perhaps through the 'oscilloscope' + piece which could pick up all local frame messages for the oscilloscope + and be customizable in ways. + + Later + - [?] Dragging across the buttons in slide mode should slide from one key to another? + - [] Add record button and repeat button with a masking mode and potentially a stepper and "sing" syllable mode for adding words, followed by a way to store tracks. + - [] Add scale overlay selection with visual hiking paths. (Color themes). + - [?] Add longer / customizable slide duration. + - [] Lay out keys better on wider vs vertical screen. + - [] Add multiple tracks so that I can create "systems" that loop + with different lengths. + - [] Add holdable rhythm button with patterns that "cycle". + - [] Add live highlights back in type mode that include note tokenization. + - [] Pressing a new button down should automatically lift the other one. + - [] Add a 'repeat' or 'hold' key which should be 'shift' on the keyboard + - [] Add the tone-sliding that song has during tap mode. + - [] This could be microtonal, or shift / quantize to the next note. + - [] There should be a way to save this modification or 'clear' it + while playing so melodies can deviate somehow, then return to the same + location. + - [] Fix 'tap' mode keypresses... or disable them entirely? + - [] Is it weirdly playable this way? + - [] Also add support for microtonal elements in the notation. + - [] why are some wave type switches changing the frequency / octave? + - [] how to build commas or sections in? + - [] how can i "lightly" trigger the soft keyboard without a whole + text input class... this would be useful for mobile game controls + and such and may require a tap to open? + - [] add restart key? + - [] entering '0' should clear the params in the hud (re-enable editing) + - [] be able to send that track / share it with others (via the first parameter) and the 'share' swipe that could pre-fill it. + - [] use other letters on the keyboard for sfx + - [] show octave in the text + - [] tap the top right corner to switch modes... + - [] add the pitch sliding in + - [] show audio connectedness more visibly + like with a modal or 'press any key' + or 'inactive' pause curtain situation + - [] Bring accents back to notes. + - [] make a memory mode a-la sage, starting with 1 note patterns + - [] parameters are 'number of notes' and experiment with 'max length' + - [] difficulty / note-length should increase automatically or it could + just re-inforce + - [] this would use the read-through mode, but be dynamically + generating text with a 'teaches typing' like modality. + - [] mobile key entry needs to work + - [] represent number keys / octave somehow... + - [] Make it work with the phone keyboard, which means number keys... + - [] Add rhythm... + - [] Add the ability to run through a series of notes / an existing song. + - [] Show some form of display / color coded display to show octave state and also print the last note pressed. + - [] Show a melody so that a typing game can take place. + - [] Abstract this so shift adds the 5. + - [] Leave out all options from synth / make sensible defaults first. + - [] Add 'scale' and 'rotation' to `write`. + + Done + - [x] Make a new "painting" to use as a visualizer. + - [x] Draw symmetrical turtle graphics based on waveform values that + paint continuously and produce consistent and differential form. + - [x] Send udp messages from keys to `tv`. + - [c] Add nice `composite` mode and rudimentary `sample` mode. + - [x] Make a more mobile friendly layout. + - [x] Everything needs to fit for the jelly. + - [x] Buttons should always fit on screen no matter what. + - [x] Add a new playback mode for melodies that include words... with long + command line parameters. + - [x] Add simple corner button for changing wavetype. + - [x] Add simple corner button for changing octave. + - [wip] Color code the notes and semitones, each with a color that has a specific and known name. + - [c] Add an always active QR code. + - [x] Add -3, -2, -1, +1, +2, +3 etc. relative overlays based on press and + have it span the whole octave. + - [x] Fix '+C' notes appearing in the key list during playback mode. + - [x] on keyboard + - [x] on touch / mouse + - [x] Fix subtle 1, 2, 3, 4, then release 1 and press 1 down and watch 4 get unticked touch bug on ios. + - [x] This may require fixing localhost testing first. + - [x] Ghost trails. + - [x] Rename to `notepat`. + - [x] Fix slide mode. + - [x] Fix 5 finger touch bug. + - [x] Add basic percussion with kick, snare and hi-hat on space, shift and control. + - [x] Add keyboard and button preview for second octave. + - [x] Add better mixing when multiple keys are being pressed. + - [x] When lifting, don't cancel other buttons. + - [x] Reflow the mobile button layout so it is responsive (keep a grid) + - [x] slide only? sticky keys error... log all playing keys / sounds in slide mode / print them out to make sure about doubles... + - [x] Implement the full scale with rollover. + - [x] Experiment with a monovoice/slide/replace note mode. + - [x] Get 'slide' working on the software buttons. + - [x] Lift up should slide back. + - [x] Fix ui button issue. + - [x] Tapping buttons on the layout should add notes to the notepad. + - [x] Software buttons and keyboard buttons should not overlap and pushing + keyboard buttons should activate the ui. + - [x] Holding a ui button down should prevent that existing / exact note + from being triggered. + - [x] Holding a keyboard button down should prevent the ui button + from being pressable. + - [x] Implement one or two buttons. + - [x] Match the tones properly 440 in other software should be 440 here and + 2092 should sound the same, etc. + - [x] Solve the 'note' / 'tone' syntax ordering API issue. + - [x] Better 'red' preview highlighting. + - [x] Entering number key more than once should not add extra numbers. + - [x] Show accurate preview of top note. + - [x] Add support for sharps and flats using normal keys somehow... + - [x] And they can be encoded differently. + - [x] How would the interpreter work for these, because it only reads per + character... + - [x] auto advance octaves... + - [x] change the bg color while any note is held. + - [] clean up digit vs note triggering code... + - [x] holding tab should not auto-switch, same with 0! + - [x] press 'enter' + - [x] be able to turn that history into a 'track' that can be followed + - [x] this will enable a type-to-repeat, or... + 'space' to skip mode, where space will automatically hit + the next note after an octave key. + - [x] Show octaves in keys pressed. + - [x] show a history of keys pressed + - [x] Add visualization to the keys being pressed. + - [x] Disable key repeat / don't retrigger sounds on repeat. + - [x] Make it so keys can be held, and add a decay after releasing? +*/ + +// ⚖️ Keyboard Scales +// CvDsEFrGtAwB (First Octave) +// BwAtGrFEsDvC (Second Octave) + +/* 🎶 Sequences + +TODO: 💮 Daisy + +🚫 🪀 Toys R Us Kid + bababg (i don't wanna grow up) + bb (i'm a) + abaf# (toys r us kid) + + f# f# g# f# (there's a million) + g# f# g# f# e (toys at toys r us) + + +✅ 🐦 Lullaby + eeg eeg + eg5c4b aa g + def ddef + dfbag b5c + 4cc5c 4afg + ecf gag + cc5c 4afg + ecfedc + +✅ 🌟 Twinkle Twinkle Little Star + cc gg aa g + ff ee dd c + gg ff ee d + gg ff ee d + cc gg aa g + ff ee dd c + +✅ 🐭 Three Blind Mice + eddc eddc + gffe gffe + g5cc4bab5c4gg + g5cc4bab5c4gg + edc edc + +✅ Row Row Row Ur Boat + cccde edefg + 5ccc 4ggg eee ccc gfedc + +✅ 🧸 Gummy Bear + aaa5c4aa aaa5cee + eee dd dd dd dd + edc4a g5c4a + aa ee aa ee aa e + 5ee ee e dc + 5ee ee e dc + ge dedc + dc4a g5ca +*/ + +// import { qrcode as qr } from "../dep/@akamfoad/qr/qr.mjs"; +// import { Android, iOS } from "../lib/platform.mjs"; + +let STARTING_OCTAVE = "4"; +// 🎨 Tab walks the waves in order of MEASURED brightness, darkest first, so +// the cycle is a ramp you can hear going somewhere instead of the order they +// happened to get written in. The ordering is not hand-typed: it comes from +// `toolchain/timbre/wave-probe.mjs`, which renders every wave through this +// same synth at C4 under one shared amplitude contour, RMS-matches them, and +// takes the Bark centroid of a Zwicker-style excitation pattern — the +// acoustic correlate David Wessel used to interpret the vertical axis of his +// timbre space ("Timbre Space as a Musical Control Structure", CMJ 3(2), +// 1979). Add a wave, re-run the probe, and the cycle re-sorts itself. +// +// The measured ramp is: sine · composite · triangle · square · whistle · +// sawtooth · harp. The old hand-written order had square and sawtooth the +// wrong way round. +// +// `gm`, `stample` and `drum` are modes, not waves — a 128-patch picker, a +// sample loader, and a 12-drum kit keyed by pitch class. They have no single +// timbre to place on the ramp, so they sit together after it and Tab does +// not drop you into one halfway up the brightness walk. +const wavetypes = [...WAVES_BY_BRIGHTNESS, ...UNMEASURED_WAVES]; +// Sine is the instrument notepat opens on — the plainest voice in the list, +// and the one every other wave is heard against. It is also, as measured, the +// darkest, so booting here starts the Tab walk at the bottom of the ramp. +let waveIndex = wavetypes.indexOf("sine"); +const STARTING_WAVE = wavetypes[waveIndex]; +let wave = STARTING_WAVE; +// let hold = false; +let slide = false; +let quickFade = false; +let roomMode = false; // 🏠 Global reverb toggle +let roomAmount = 0.5; // 🎚️ Room/reverb amount (0-1) +let glitchMode = false; // 🧩 Global glitch toggle +let octave = STARTING_OCTAVE; + +// 🎼 GM (General MIDI) state — see lib/gm.mjs +// Number-row digits 0-9 type into a 1-3 digit decimal buffer that picks GM +// program 0-127 live (mirrors menuband). 700ms pause auto-clears the buffer. +// "0" / "00" / "000" map to program 0 (Acoustic Grand Piano) — notepat has +// no MIDI-passthrough slot like menuband does. +let gmReady = false; +let gmProgram = 0; +let gmPatch = null; +let gmPatchLoading = false; +let gmInitStarted = false; +let gmDigitBuffer = ""; +let gmDigitBufferDeadline = 0; +const GM_DIGIT_TIMEOUT_MS = 700; + +// Fire the GM bootstrap exactly once, as soon as window.audioContext is +// available. Browsers gate audio creation behind the first user gesture, +// so this can't run at boot — it has to wait for setSoundContext to see +// a real context. +function maybeInitGM() { + if (gmInitStarted) return; + if (typeof window === "undefined" || !(/** @type {any} */ (window).audioContext)) return; + gmInitStarted = true; + (async () => { + try { + await gm.loadManifest(); + const patch = await gm.loadPatch(gmProgram); + if (patch) { + gmPatch = patch; + gmReady = true; + } + } catch (err) { + console.warn("🎼 GM init failed — staying on oscillator fallback:", err); + } + })(); +} + +let keys = ""; +let tap = false; +let tapIndex = 0; +let tapped; // Store the last tracked key. +let editable = true; +const downs = {}; // Hardware keys that are currently held down. +const sounds = {}; +const tonestack = {}; // Temporary tone-stack that always keeps currently held +// keys and pops them off the stack as keys are lifted. +// (These tones will not necessarily be playing.) + +// 🔒 Sustain modifier state — both modifiers are momentary (no latch). +// SHIFT held during a key press → that voice gets a long decay tail when +// released, so it dissipates rather than cuts off. Re-press of the +// same voiceId interrupts and restarts. +// ENTER held during a key press → that voice is held permanently after +// release. BACKSPACE clears all held voices. +// Drum / percussion presses skip both modes (handled at the press site). +let shiftHeld = false; +let enterHeld = false; +// voiceId = `${wave}:${octave}:${noteName}` — stable identity across +// physical-key reuse and octave/wave changes. Held voices live here +// after their key is released; re-press of the same voiceId kills the +// old one and starts a fresh voice. +const heldVoices = new Map(); +const SHIFT_DECAY_SECONDS = 6.0; // long tail after release in shift-sustain mode + +// 👀 Character eye-tracking state. Two characters watch the piano: +// Piano Man on the left half, Sample Sally on the right half. Their +// pupils smoothly lerp toward the x-center of whichever key in their +// half is currently pressed; when no key is pressed in their half the +// pupils drift back to looking forward (0.5). +let pianoManPupilX = 0.5; +let sampleSallyPupilX = 0.5; +const CHARACTER_EYE_LERP = 0.28; + +// 🔬 Telemetry: Expose state for stability testing +if (typeof window !== 'undefined') { + window.__notepat_sounds = sounds; + window.__notepat_tonestack = tonestack; +} + +// 📊 Performance OSD (On-Screen Display) +let perfOSD = false; // Toggle with ` (backtick) key - starts OFF +let lowLatencyMode = false; +let audioReinitRequested = false; +let pendingAudioReinit = false; +let bootTimestamp = 0; // Set in boot() to detect pre-boot stuck notes +let storedSampleRate = null; // Store sample rate at boot for layout calculations +let miniInputsEnabled = true; // Show mini piano/qwerty when there is space +let paintPerfEnabled = false; // Console timing logs for paint +let paintPerfEvery = 60; // Log every N frames +let layoutCache = { + key: null, + layout: null, + midiMetrics: null, +}; +let waveformsCache = { + source: null, + scopeSamples: 0, + resampled: null, + sanitized: null, +}; +let zeroWaveformsCache = { + length: 0, + buffer: [], +}; +let padsBase = null; +let padsBaseKey = null; + +// 🎨 Color cache for performance - cleared when octave changes +let colorCache = new Map(); +let colorCacheOctave = null; + +function getCachedColor(note, num, forceOctave = null) { + const currentOctave = forceOctave ?? octave; + // Clear cache if octave changed + if (colorCacheOctave !== currentOctave) { + colorCache.clear(); + colorCacheOctave = currentOctave; + } + const key = `${note}:${currentOctave}`; + let color = colorCache.get(key); + if (!color) { + color = colorFromNote(note, num, currentOctave); + colorCache.set(key, color); + } + return color; +} + +const perfStats = { + lastKeyTime: 0, // When the last key was pressed + lastSoundTime: 0, // When the sound started playing (synth call) + latency: 0, // Key-to-synth latency in ms (JS-side only) + latencyHistory: [], // Rolling history of latencies + avgLatency: 0, // Average latency + maxLatency: 0, // Max latency seen + minLatency: Infinity, // Min latency seen + soundCount: 0, // Current number of active sounds + tonesInStack: 0, // Tones in tonestack + keysLength: 0, // Length of keys string + frameTime: 0, // Last frame time + fps: 0, // Estimated FPS + lastFrameTimestamp: 0, // For FPS calculation + memoryUsed: 0, // JS heap used (if available) + sampleRate: 0, // Audio context sample rate (kHz) + baseLatency: 0, // AudioContext base latency (ms) + outputLatency: 0, // AudioContext output latency (ms) +}; + +// 🔬 Expose perfStats for testing +if (typeof window !== 'undefined') { + window.__notepat_perfStats = perfStats; +} + +//let sharps = false, +// flats = false; +const notes = "cdefgab" + "vswrq" + "hijklmn" + "tyuop"; // hold shift on C D F G A for sharps. +const edges = "zx;']"; // below and above the octave +// cdefgab (next ovtave) +// // or alt on D E G A B for flats +// This is a notes -> keys mapping, that uses v for c# + +const TOP_BAR_BOTTOM = 21; +const TOP_BAR_PIANO_HEIGHT = 10; // Mini piano strip in top bar +const TRACK_HEIGHT = 25; +const TRACK_GAP = 6; +// Vertical track view constants (scrolls vertically, positioned beside pads) +const VERTICAL_TRACK_WIDTH = 48; +const VERTICAL_TRACK_MIN_WIDTH = 36; +const VERTICAL_TRACK_ITEM_HEIGHT = 20; // Height per song item in vertical track +const MINI_KEYBOARD_HEIGHT = 16; +const MINI_KEYBOARD_SPACING = 6; +const QWERTY_MINIMAP_SPACING = 6; + +const MIDI_BADGE_TEXT = "USB MIDI"; +const MIDI_RATE_LABEL_TEXT = "SR"; +const MIDI_BADGE_PADDING_X = 2; +const MIDI_BADGE_PADDING_RIGHT = 6; // Extra padding to prevent FPS from overlapping next segment +const MIDI_BADGE_PADDING_Y = 2; +const MIDI_BADGE_MARGIN = 2; +const MIDI_RATE_LABEL_GAP = 2; + +const QWERTY_MINIMAP_KEY_HEIGHT = 8; +const QWERTY_MINIMAP_KEY_SPACING = 1; + +// Secondary top bar for mode toggle buttons (using MatrixChunky8 font) +const SECONDARY_BAR_TOP = TOP_BAR_BOTTOM; +const SECONDARY_BAR_HEIGHT = 12; +const SECONDARY_BAR_BOTTOM = SECONDARY_BAR_TOP + SECONDARY_BAR_HEIGHT; + +// OS bar — thin strip below the secondary bar for the "x86 os" button +const OS_BAR_TOP = SECONDARY_BAR_BOTTOM; +const OS_BAR_HEIGHT = 12; +const OS_BAR_BOTTOM = OS_BAR_TOP + OS_BAR_HEIGHT; + +const TOGGLE_BTN_PADDING_X = 2; +const TOGGLE_BTN_PADDING_Y = 2; +const TOGGLE_BTN_GAP = 3; // At least 1px visible gap between buttons + +// Responsive label shortening for tight layouts +const LABEL_VARIANTS = { + slide: ["slide", "sld", "s"], + room: ["room", "rm", "r"], + glitch: ["glitch", "glt", "g"], + quick: ["quick", "qk", "q"], +}; + +// Current chosen labels (set by buildSecondaryBarLayout) +let secondaryBarLabels = { + slide: "slide", + room: "room", + glitch: "glitch", + quick: "quick", +}; + +const MELODY_ALIAS_BASE_SIDE = 72; +const MELODY_ALIAS_MIN_SIDE = 56; +const MELODY_ALIAS_MARGIN = 6; + +const NOTE_TO_KEYBOARD_KEY = { + "-a": "control", + "-a#": "z", + "-b": "x", + c: "c", + "c#": "v", + d: "d", + "d#": "s", + e: "e", + f: "f", + "f#": "w", + g: "g", + "g#": "r", + a: "a", + "a#": "q", + b: "b", + "+c": "h", + "+c#": "t", + "+d": "i", + "+d#": "y", + "+e": "j", + "+f": "k", + "+f#": "u", + "+g": "l", + "+g#": "o", + "+a": "m", + "+a#": "p", + "+b": "n", + "++c": ";", + "++c#": "'", + "++d": "]", +}; + +const KEYBOARD_TO_NOTE = Object.fromEntries( + Object.entries(NOTE_TO_KEYBOARD_KEY).map(([note, key]) => [key, note]), +); + +const QWERTY_LAYOUT_ROWS = [ + ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "]"], + ["a", "s", "d", "f", "g", "h", "j", "k", "l", ";", "'"], + ["control", "z", "x", "c", "v", "b", "n", "m", "alt"], + ["left", "down", "up", "right", "space"], +]; + +const QWERTY_MINIMAP_HEIGHT = + QWERTY_LAYOUT_ROWS.length * QWERTY_MINIMAP_KEY_HEIGHT + + (QWERTY_LAYOUT_ROWS.length - 1) * QWERTY_MINIMAP_KEY_SPACING; + +const QWERTY_ROW_OFFSETS = [0, 0.5, 1]; +const QWERTY_MAX_SPAN = Math.max( + ...QWERTY_LAYOUT_ROWS.map((row, i) => row.length + QWERTY_ROW_OFFSETS[i]), +); +const PAN_RANGE = 0.9; // Keep a safe center mix; never hard L/R. + +const MINI_PIANO_WHITE_KEYS = [ + "-A", + "-B", + "C", + "D", + "E", + "F", + "G", + "A", + "B", + "+C", + "+D", + "+E", + "+F", + "+G", + "+A", + "+B", + "++C", + "++D", +]; + +const MINI_PIANO_BLACK_KEYS = [ + { note: "-A#", afterWhite: 0 }, + { note: "C#", afterWhite: 2 }, + { note: "D#", afterWhite: 3 }, + { note: "F#", afterWhite: 5 }, + { note: "G#", afterWhite: 6 }, + { note: "A#", afterWhite: 7 }, + { note: "+C#", afterWhite: 9 }, + { note: "+D#", afterWhite: 10 }, + { note: "+F#", afterWhite: 12 }, + { note: "+G#", afterWhite: 13 }, + { note: "+A#", afterWhite: 14 }, + { note: "++C#", afterWhite: 16 }, +]; + +const TOP_BAR_COMPACT_WHITE_KEYS = ["C", "D", "E", "F", "G", "A", "B"]; +const TOP_BAR_COMPACT_BLACK_KEYS = [ + { note: "C#", afterWhite: 0 }, + { note: "D#", afterWhite: 1 }, + { note: "F#", afterWhite: 3 }, + { note: "G#", afterWhite: 4 }, + { note: "A#", afterWhite: 5 }, +]; +const TOP_BAR_PIANO_MIN_KEY_W = 5; +const TOP_BAR_PIANO_MIN_COMPACT_KEY_W = 5; + +const MINI_PIANO_WHITE_KEY_WIDTH = 6; +const MINI_PIANO_WHITE_KEY_WIDTH_COMPACT = 4; +const MINI_PIANO_BLACK_KEY_WIDTH = 4; +const MINI_PIANO_BLACK_KEY_WIDTH_COMPACT = 3; +const MINI_PIANO_BLACK_KEY_HEIGHT = 9; +const MINI_PIANO_BLACK_KEY_HEIGHT_COMPACT = 7; + +function getMiniPianoWhiteKeyWidth(isCompact) { + return isCompact ? MINI_PIANO_WHITE_KEY_WIDTH_COMPACT : MINI_PIANO_WHITE_KEY_WIDTH; +} + +function getMiniPianoBlackKeyWidth(isCompact) { + return isCompact ? MINI_PIANO_BLACK_KEY_WIDTH_COMPACT : MINI_PIANO_BLACK_KEY_WIDTH; +} + +function getMiniPianoBlackKeyHeight(isCompact) { + return isCompact ? MINI_PIANO_BLACK_KEY_HEIGHT_COMPACT : MINI_PIANO_BLACK_KEY_HEIGHT; +} + +function getTopBarPianoMetrics(screen) { + const topPianoY = 3; + const topPianoHeight = 15; + // Push piano right when .com superscript is shown to avoid overlap with HUD label + const topPianoStartX = dotComMode ? 75 : 54; + // Cap the piano's right edge at the leftmost top-bar button (os/m4l/wave/oct) + // so keys never slide under the button strip on narrow screens. + const leftmostButtonX = Math.min( + osBtn?.box?.x ?? Infinity, + abletonBtn?.box?.x ?? Infinity, + waveBtn?.box?.x ?? Infinity, + octBtn?.box?.x ?? Infinity, + ); + const rightEdge = Number.isFinite(leftmostButtonX) + ? leftmostButtonX - 3 + : screen.width; + const availableWidth = Math.max(0, rightEdge - topPianoStartX); + + const fullWidth = Math.min(140, Math.floor(availableWidth * 0.5)); + const fullWhiteKeyWidth = Math.floor(fullWidth / MINI_PIANO_WHITE_KEYS.length); + if (fullWhiteKeyWidth >= TOP_BAR_PIANO_MIN_KEY_W) { + return { + mode: "full", + x: topPianoStartX, + y: topPianoY, + height: topPianoHeight, + whiteKeys: MINI_PIANO_WHITE_KEYS, + blackKeys: MINI_PIANO_BLACK_KEYS, + whiteKeyWidth: fullWhiteKeyWidth, + blackKeyWidth: Math.max(2, Math.floor(fullWhiteKeyWidth * 0.6)), + blackKeyHeight: Math.floor(topPianoHeight * 0.55), + }; + } + + const compactWidth = Math.min(84, Math.floor(availableWidth * 0.35)); + const compactWhiteKeyWidth = Math.floor(compactWidth / TOP_BAR_COMPACT_WHITE_KEYS.length); + if (compactWhiteKeyWidth >= TOP_BAR_PIANO_MIN_COMPACT_KEY_W) { + return { + mode: "compact", + x: topPianoStartX, + y: topPianoY, + height: topPianoHeight, + whiteKeys: TOP_BAR_COMPACT_WHITE_KEYS, + blackKeys: TOP_BAR_COMPACT_BLACK_KEYS, + whiteKeyWidth: compactWhiteKeyWidth, + blackKeyWidth: Math.max(2, Math.floor(compactWhiteKeyWidth * 0.6)), + blackKeyHeight: Math.floor(topPianoHeight * 0.55), + }; + } + + return { + hidden: true, + x: topPianoStartX, + y: topPianoY, + height: topPianoHeight, + }; +} + +// Get note from top bar piano click (returns note like 'c', 'c#', '+d', etc.) +function getTopBarPianoNoteAt(x, y, screen) { + const metrics = getTopBarPianoMetrics(screen); + if (!metrics || metrics.hidden) return null; + const { + x: topPianoStartX, + y: topPianoY, + height: topPianoHeight, + whiteKeys, + blackKeys, + whiteKeyWidth: topPianoWhiteKeyWidth, + blackKeyWidth: topPianoBlackKeyWidth, + blackKeyHeight: topPianoBlackKeyHeight, + } = metrics; + + const relX = x - topPianoStartX; + const relY = y - topPianoY; + + // Check if within piano bounds + if (relX < 0 || relY < 0 || relY > topPianoHeight) return null; + if (relX >= whiteKeys.length * topPianoWhiteKeyWidth) return null; + + // Check black keys first (they're on top) + for (const { note, afterWhite } of blackKeys) { + const bx = afterWhite * topPianoWhiteKeyWidth + topPianoWhiteKeyWidth - topPianoBlackKeyWidth / 2; + if (relX >= bx && relX <= bx + topPianoBlackKeyWidth && relY <= topPianoBlackKeyHeight) { + return note.toLowerCase(); + } + } + + // Check white keys + const index = Math.floor(relX / topPianoWhiteKeyWidth); + if (index >= 0 && index < whiteKeys.length) { + return whiteKeys[index].toLowerCase(); + } + + return null; +} + +function clampPan(value) { + return Math.max(-PAN_RANGE, Math.min(PAN_RANGE, value)); +} + +function getSampleRateText(sampleRate) { + const fallbackRate = + typeof window !== "undefined" ? window.audioContext?.sampleRate : null; + const rate = Number.isFinite(sampleRate) ? sampleRate : fallbackRate; + if (!Number.isFinite(rate) || rate <= 0) return null; + if (rate >= 1000) return `${Math.round(rate / 1000)}k`; + return `${Math.round(rate)}Hz`; +} + +function getPanForQwertyKey(key) { + if (typeof key !== "string" || key.length === 0) return 0; + const lower = key.toLowerCase(); + + for (let row = 0; row < QWERTY_LAYOUT_ROWS.length; row += 1) { + const rowKeys = QWERTY_LAYOUT_ROWS[row]; + const col = rowKeys.indexOf(lower); + if (col >= 0) { + const x = col + QWERTY_ROW_OFFSETS[row]; + const normalized = QWERTY_MAX_SPAN > 1 ? x / (QWERTY_MAX_SPAN - 1) : 0.5; + return clampPan(normalized * 2 - 1); + } + } + + return 0; +} + +function getPanForButtonNote(note) { + const key = noteToKeyboardKey(note); + return key ? getPanForQwertyKey(key) : 0; +} + +function noteToKeyboardKey(note) { + if (typeof note !== "string" || note.length === 0) return null; + const lower = note.toLowerCase(); + return NOTE_TO_KEYBOARD_KEY[lower] ?? null; +} + +function keyboardKeyToNote(key) { + if (typeof key !== "string" || key.length === 0) return null; + const lower = key.toLowerCase(); + return KEYBOARD_TO_NOTE[lower] ?? null; +} + +function formatKeyLabel(key) { + if (!key) return ""; + switch (key) { + case "control": + return "CTL"; + case "space": + return "SP"; + case "alt": + return "ALT"; + case "left": + return "L"; + case "right": + return "R"; + case "up": + return "U"; + case "down": + return "D"; + default: + return key.toUpperCase(); + } +} + +let upperOctaveShift = 0, // Set by <(,) or >(.) keys. + lowerOctaveShift = 0; + +const attack = 0.0005; // Faster onset for responsive play. +// const attack = 0.005; // 0.025; +// const decay = 0.9999; // 0.9; +let toneVolume = 0.95; // 0.9; +// const killFade = 0.01; // TODO: Make this dynamic according to press time. 24.11.04.06.05 +const fade = 0.005; +const fastFade = 0.005; +const killFade = 0.15; //0.05; + +let perc; // A one frame percussion flash color. +let metronomeFlash = 0; // Peripheral screen flash intensity for metronome beats (0-1) + +// | +// CVDSEFWGRAQB|QARGWFESDVC +// ^ ^ ^^ ^ ^ ^| ^ ^ ^^ ^ ^ +// HTIYJKULOMPN|PMOLUKJYITH +// ^ ^ ^^ ^ ^ ^| ^ ^ ^^ ^ ^ +// | + +// TODO: How an I add another octave to the layout... + +// first octave +// c# v +// d# s +// f# w +// g# r +// a# q +// second octave +// c# t +// d# y +// f# u +// g# o +// a# p + +const octaves = "123456789"; +const accents = "#f"; +const buttons = {}; // Software keys. 🎹 +// TODO: Add better octave theme and sharp buttons, etc... 24.07.16.20.01 + +const buttonNotes = [ + "c", + "c#", + "d", + "d#", + "e", + "f", + "f#", + "g", + "g#", + "a", + "a#", + "b", + "+c", + "+c#", + "+d", + "+d#", + "+e", + "+f", + "+f#", + "+g", + "+g#", + "+a", + "+a#", + "+b", +]; + +const buttonNoteLookup = new Set(buttonNotes); +const MIDI_NOTE_NAMES = ["c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"]; + +const midiActiveNotes = new Map(); + +const MIDI_PITCH_BEND_RANGE = 2; // Semitones up/down for pitch wheel. +let midiPitchBendValue = 0; // Normalized -1..1 position of the wheel. +let midiConnected = false; +let lastOrphanCleanup = 0; // Timestamp of last orphan cleanup + +// 🧹 Clean up sounds that are playing but have no corresponding input held +function cleanupOrphanedSounds(pens, fastKill = false) { + const activePens = pens?.() || []; + const anyKeyDown = Object.keys(downs).length > 0; + const anyButtonDown = buttonNotes.some(note => buttons[note]?.down); + const anyPenDown = activePens.length > 0; + + // If nothing is held but sounds exist, clean them up + if (!anyKeyDown && !anyButtonDown && !anyPenDown) { + const soundKeys = Object.keys(sounds); + if (soundKeys.length > 0) { + const now = performance.now(); + // Debounce: only cleanup if 100ms since last cleanup + if (now - lastOrphanCleanup > 100) { + console.log("🧹 Cleaning up", soundKeys.length, "orphaned sounds:", soundKeys.join(", ")); + soundKeys.forEach(note => { + sounds[note]?.sound?.kill(fastKill ? 0.02 : 0.1); + delete sounds[note]; + delete tonestack[note]; + trail[note] = 1; + }); + lastOrphanCleanup = now; + } + } + } +} + +// red, orange, yellow, green, blue, purple, brown +// C, D, E, F, G, A, B + +// pink, white, black, gray, tan +const notesToColors = { + c: [255, 0, 0], // red + "c#": [255, 192, 203], // pink + d: [255, 165, 0], // orange + "d#": [255, 255, 255], // white + e: [255, 255, 0], // yellow + f: [0, 128, 0], // green + "f#": [0, 0, 0], // black + g: [0, 0, 255], // blue + "g#": [128, 128, 128], // gray + a: [128, 0, 128], // purple + "a#": [210, 180, 140], // tan + b: [165, 42, 42], // brown +}; + +const noteOrder = [ + "c", + "c#", + "d", + "d#", + "e", + "f", + "f#", + "g", + "g#", + "a", + "a#", + "b", +]; + +const notesToColorsFinal = { + "4c": [255, 0, 0], // red + "4c#": [255, 192, 203], // pink + "4d": [255, 165, 0], // orange + "4d#": [255, 255, 255], // white + "4e": [255, 255, 0], // yellow + "4f": [0, 128, 0], // green + "4f#": [0, 0, 0], // black + "4g": [0, 0, 255], // blue + "4g#": [128, 128, 128], // gray + "4a": [128, 0, 128], // purple + "4a#": [210, 180, 140], // tan + "4b": [165, 42, 42], // brown +}; + +function colorFromNote(note, num, octaveOverride = null) { + const baseOctave = Number.isFinite(octaveOverride) + ? octaveOverride + : parseInt(octave); + const { noteName, octave: noteOctave } = parseNotepatNote(note, baseOctave); + return getNoteColorWithOctave(noteName, noteOctave, { + baseOctave: 4, + accentStep: 25, + }); +} + +function brightenColor(color, amount = 40) { + if (!Array.isArray(color) || color.length < 3) return color; + return [ + Math.min(255, color[0] + amount), + Math.min(255, color[1] + amount), + Math.min(255, color[2] + amount), + ]; +} + +function darkenColor(color, factor = 0.65) { + if (!Array.isArray(color) || color.length < 3) return color; + return [ + Math.max(0, Math.round(color[0] * factor)), + Math.max(0, Math.round(color[1] * factor)), + Math.max(0, Math.round(color[2] * factor)), + ]; +} + +function getContrastingTextColor(color) { + if (!Array.isArray(color) || color.length < 3) return [255, 255, 255]; + const [r, g, b] = color; + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luminance > 150 ? [20, 20, 20] : [245, 245, 245]; +} + +const buttonOctaves = ["3", "4", "5", "6", "7", "8"]; // ❤️‍🔥 Add octaves... + +const octaveTheme = [ + "black", // 0 (never available) + "black", // 1 + "darkblue", // 2 + "red", // 3 + "blue", // 4 + "yellowgreen", // 6 + "yellow", // 5 + "green", // 7 + "purple", // 8 +]; + +const { abs, round, floor, ceil, min, max } = Math; + +let scope = 16; // Reduced for better visualizer performance +// let scopeTrim = 0; + +let projector = false; +let visualizerFullscreen = false; // Toggle visualizer as full background behind buttons +let recitalMode = false; // 🎭 Recital mode - wireframe single-color minimal UI +let recitalBlinkPhase = 0; // For blinking back button in recital mode +const noteShake = {}; // Per-note shake amounts (note -> shake value) + +// 🥁 Metronome state - UTC-synced like clock.mjs +let metronomeEnabled = false; +let metronomeBPM = 180; // Default 180 BPM +let metronomeLastBeatTime = 0; // Last time a beat was triggered (UTC ms) +let metronomeVisualPhase = 0; // 0-1 visual pulse phase +let metronomeBeatCount = 0; // Count beats for visual display +let metronomeClockRef = null; // Reference to clock API for UTC sync +let metronomeBallPos = 0; // 0-1 bouncing ball position (bounces between beats) + +// 🎹 DAW sync state (Ableton Live via M4L) +let dawMode = false; // Set from query param OR auto-detected from DAW messages +let overlayMode = false; // ?overlay=true — transparent bg for electron overlay window +let dawAutoDetected = false; // True if we auto-detected DAW mode from messages +let dawSynced = false; // True when receiving valid DAW data +let dawBpm = null; // BPM from DAW +let dawPlaying = false; // Transport playing state from DAW + +// 🌐 Branded domain mode (notepat.com — pushes top bar piano right for .com superscript) +let dotComMode = false; + +// 🎨 KidLisp background visual (activated via `notepat $roz` etc.) +let kidlispBackground = null; // e.g. "$roz" — the $code to render behind the UI +let kidlispBgEnabled = false; + +const trail = {}; + +// 🎹 Piano roll history - pixel timeline of held notes +const PIANO_ROLL_WIDTH = 400; // pixels of history (larger buffer for more context) +const PIANO_ROLL_SCROLL_DIVISOR = 2; // Only scroll every N frames (lower = faster scrolling) +let pianoRollFrameCounter = 0; // Frame counter for scroll timing +let pianoRollScrollPosition = 0; // Total pixels scrolled (for beat marker sync) +const pianoRollHistory = buttonNotes.map(() => new Uint8Array(PIANO_ROLL_WIDTH)); // 1 row per note, 0=off, 1=on +// 🥁 Beat marker history - 0=no beat, 1=regular beat, 2=downbeat (every 4) +const pianoRollBeatHistory = new Uint8Array(PIANO_ROLL_WIDTH); + +// 🔬 Telemetry: Expose trail for stability testing +if (typeof window !== 'undefined') { + window.__notepat_trail = trail; +} + +let lastActiveNote; +let transposeOverlay = false; +let transposeOverlayFade = 0; +let paintTransposeOverlay = false; +let paintPictureOverlay = false; +// let paintPictureOverlay = true; + +// let qrcells; + +let waveBtn, octBtn, osBtn, abletonBtn, recBtn; +// 🥁 Drum kit lives as a wave type ("drum") in `wavetypes` — when selected, +// every note fires from lib/percussion.mjs instead of the pitched synth. +let slideBtn, roomBtn, glitchBtn, quickBtn; // Toggle buttons for slide/room/glitch/quick modes +let metroBtn, bpmMinusBtn, bpmPlusBtn; // Metronome controls +let melodyAliasBtn; + +// 🔴 Recording for clock jump-and-play +// `recBtn` is rendered on the OS bar. First press starts capturing every +// note-on as { tone, downAt } and finalizes it on note-off; second press +// serializes the buffer to clock's melody syntax and jumps to clock so the +// recording plays back. Sticky duration modifiers in clock's parser are why +// v1 emits all notes at the median duration with no per-note modifier. +let recording = false; +let recordBuffer = []; // [{ tone: "4c", downAt: ms, upAt: ms }] +let recordingActive = {}; // buttonNote -> { tone, downAt } +let melodyAliasDown = false; +let melodyAliasActiveNote = null; +let melodyAliasStartedNote = false; + +let rawSong = ` + C:Twin- C:-kle G:twin- G:-kle A:lit- A:-tle G:star, + F:how F:I E:won- E:-der D:what D:you C:are. + G:Up G:a- F:-bove F:the E:world E:so D:high, + G:like G:a F:dia- F:-mond E:in E:the D:sky. + C:Twin- C:-kle G:twin- G:-kle A:lit- A:-tle G:star, + F:how F:I E:won- E:-der D:what D:you C:are. +`; + +let song, + songNoteDown = false, + songStops = [], + songIndex = 0, + songShift = 0, + songShifting = false, + songProgress = 0; // Track progress through current note (0 to 1) + +let autopatConfig = { + enabled: false, + rawSong: null, + events: null, + bpm: 88, + beatsPerNote: 1, + restBeats: 0.1, + startDelay: 0.35, + ignoreInput: false, + hudLabel: "autopat", + paused: false, + showTrack: false, + sidePanelWidth: 0, +}; + +let autopatState = { + activeNote: null, + activeNoteEnd: 0, + nextTime: 0, + started: false, + eventIndex: 0, +}; + +let autopatApi = null; + +function rebuildAutopatSong() { + if (!autopatConfig.enabled) return; + if (autopatConfig.events?.length) { + song = null; + songStops = []; + songIndex = 0; + songShift = 0; + songShifting = false; + songProgress = 0; + if (autopatConfig.hudLabel && autopatHud?.label) { + autopatHud.label(autopatConfig.hudLabel); + } + return; + } + const autopatSong = autopatConfig.rawSong || rawSong; + song = parseSong(autopatSong); + songStops = []; + songIndex = 0; + songShift = 0; + songShifting = false; + songProgress = 0; + + if (song) { + let x = 0; + const glyphWidth = resolveMatrixGlyphMetrics(autopatTypeface).width; + song.forEach((part) => { + let word = part[1], + space = 0; + word.endsWith("-") ? (word = word.slice(0, -1)) : (space = glyphWidth); + if (word.startsWith("-")) word = word.slice(1); + songStops.push([word, x]); + x += word.length * glyphWidth + space; + }); + } + + if (autopatConfig.hudLabel && autopatHud?.label) { + autopatHud.label(autopatConfig.hudLabel); + } +} + +function configureAutopat(options = {}) { + autopatConfig = { + ...autopatConfig, + ...options, + enabled: options.enabled ?? true, + }; + + autopatState = { + activeNote: null, + activeNoteEnd: 0, + nextTime: 0, + started: false, + eventIndex: 0, + }; + + rebuildAutopatSong(); +} + +// Convert various note notations to notepat keyboard notation +function convertNoteToKeyboardKey(note) { + if (!note || typeof note !== "string") return note; + + const normalizedNote = note.toLowerCase().trim(); + + // Check if it's already a keyboard key - if so, convert to note name + if (KEYBOARD_TO_NOTE[normalizedNote]) { + return KEYBOARD_TO_NOTE[normalizedNote]; // e.g., "h" -> "+c" + } + + // Already in note notation (c, c#, +c, etc.) - return as-is + if (NOTE_TO_KEYBOARD_KEY[normalizedNote]) { + return normalizedNote; // e.g., "c" -> "c", "+c" -> "+c" + } + + // Handle alternative notations + // Map flats to sharps (Db -> C#, Eb -> D#, etc.) + const flatToSharp = { + 'db': 'c#', + 'eb': 'd#', + 'gb': 'f#', + 'ab': 'g#', + 'bb': 'a#', + '+db': '+c#', + '+eb': '+d#', + '+gb': '+f#', + '+ab': '+g#', + '+bb': '+a#', + }; + + if (flatToSharp[normalizedNote]) { + return flatToSharp[normalizedNote]; // e.g., "db" -> "c#" + } + + // Return original if no conversion found + return normalizedNote; +} + +function parseSong(raw) { + return raw + .trim() + .split(/\s+/) + .map((noteword) => { + const [note, word] = noteword.split(":"); + // Convert the note to keyboard notation + const keyboardNote = convertNoteToKeyboardKey(note); + // Store in uppercase to match button comparison logic (note.toUpperCase() === song[i][0]) + return [keyboardNote.toUpperCase(), word]; + }); +} + +// song = parseSong(rawSong); + +let startupSfx; +let udpServer; +let relaySocket = null; +let relaySourceHandle = ""; +let relaySourceMachineId = ""; +let relaySubscribeAll = false; +let relaySources = []; +let relayStatusText = "relay off"; +let relayLastEventText = ""; +let relayLastEventAt = 0; +let relayNeedsPanic = false; +let relayMessageHandler = null; +const relayMidiQueue = []; +const relayActiveNotes = new Map(); + +// 🎹 notepat.com → AC-Notepat M4L device (notepat.com/amxd) MIDI bridge. +// Native bare-metal notepat ships these events as raw UDP packets to +// session-server:10010 (see fedac/native/src/udp-client.c udp_send_midi). +// The browser can't open raw UDP, so we publish through both the geckos +// channel (sub-frame latency, may drop) and the session-server WS +// (reliable fallback). Session-server fans either path out via +// broadcastNotepatMidiEvent to subscribed notepat-remote M4L devices. +// Enabled with ?amxd=1 (or ?amxd=publish), or always when running inside +// jweb~ (acFORCE_DAW). Tracks a per-tab machineId so the M4L device's +// source list can disambiguate multiple browser tabs. +let amxdPublishEnabled = false; +let amxdMachineId = ""; +let amxdHandle = ""; +let amxdHeartbeatAt = 0; +const AMXD_HEARTBEAT_MS = 5000; +let amxdSentCount = 0; +let amxdLastEvent = null; +let amxdActiveNotes = new Map(); // tone string → midi number (to send note_off) +let amxdSuppressPublish = false; // set true while replaying inbound relay events + +let stampleSampleId = null; +let stampleSampleData = null; +let stampleSampleRate = null; +let stampleNeedleProgress = 0; +let stampleNeedleNote = null; +let stampleProgressTick = 0; +let kidlispSampleRefreshTick = 0; + +let autopatHud = null; +let autopatTypeface = null; + +let picture; +let matrixFont; // MatrixChunky8 font for note letters + +function normalizeRelayHandle(handle) { + return `${handle || ""}`.trim().replace(/^@+/, "").toLowerCase(); +} + +function relayTargetLabel() { + if (relaySubscribeAll) return "all"; + if (relaySourceHandle && relaySourceMachineId) return `@${relaySourceHandle} ${relaySourceMachineId}`; + if (relaySourceHandle) return `@${relaySourceHandle}`; + if (relaySourceMachineId) return relaySourceMachineId; + return "off"; +} + +function relaySubscriptionPayload() { + if (relaySubscribeAll) return { all: true }; + if (relaySourceHandle || relaySourceMachineId) { + return { + handle: relaySourceHandle || undefined, + machineId: relaySourceMachineId || undefined, + }; + } + return null; +} + +function updateRelayBridgeState() { + if (typeof window === "undefined") return; + window.acNotepatRelay = { + status: relayStatusText, + target: relayTargetLabel(), + sources: relaySources, + lastEvent: relayLastEventText, + setSource(next = {}) { + setRelaySource(next); + }, + }; +} + +function requestRelaySources() { + relaySocket?.send?.("notepat:midi:sources"); +} + +function sendRelaySubscription() { + const payload = relaySubscriptionPayload(); + if (!relaySocket?.send) return; + if (!payload) { + relaySocket.send("notepat:midi:unsubscribe", true); + relayStatusText = "relay off"; + updateRelayBridgeState(); + return; + } + relaySocket.send("notepat:midi:subscribe", payload); + relayStatusText = `relay ${relayTargetLabel()}`; + updateRelayBridgeState(); +} + +function setRelaySource(next = {}) { + const hasHandle = Object.prototype.hasOwnProperty.call(next, "handle"); + const hasMachineId = Object.prototype.hasOwnProperty.call(next, "machineId"); + const hasAll = Object.prototype.hasOwnProperty.call(next, "all"); + + if (hasHandle) { + relaySourceHandle = normalizeRelayHandle(next.handle); + } + if (hasMachineId) { + relaySourceMachineId = `${next.machineId || ""}`.trim(); + } else if (hasHandle) { + relaySourceMachineId = ""; + } + if (hasAll) { + relaySubscribeAll = next.all === true; + } + if (relaySubscribeAll) { + relaySourceHandle = ""; + relaySourceMachineId = ""; + } + relayNeedsPanic = true; + requestRelaySources(); + sendRelaySubscription(); +} + +function handleRelaySocketMessage(id, type, content) { + if (type === "connected" || type === "connected:already") { + requestRelaySources(); + sendRelaySubscription(); + return; + } + + if (type === "notepat:midi:sources") { + relaySources = Array.isArray(content?.sources) ? content.sources : []; + updateRelayBridgeState(); + return; + } + + if (type === "notepat:midi:subscribed") { + relayStatusText = `relay ${relayTargetLabel()}`; + updateRelayBridgeState(); + return; + } + + if (type === "notepat:midi:unsubscribed") { + relayStatusText = "relay off"; + relayNeedsPanic = true; + updateRelayBridgeState(); + return; + } + + if (type === "notepat:midi") { + relayMidiQueue.push(content); + relayLastEventText = `${content?.handle ? "@" + content.handle + " " : ""}${content?.event || "event"} ${content?.note ?? ""}`; + relayLastEventAt = Date.now(); + updateRelayBridgeState(); + } +} + +// Convert a notepat tone string (e.g. "4C", "3F#") to a MIDI note number. +// MIDI: C-1 = 0, C0 = 12, C4 = 60. Returns null if it can't parse. +function toneToMidi(tone) { + if (typeof tone !== "string") return null; + const match = tone.match(/^(-?\d+)([A-Ga-g])([#b]?)$/); + if (!match) return null; + const oct = parseInt(match[1], 10); + const letter = match[2].toUpperCase(); + const accidental = match[3]; + const semis = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }[letter]; + if (semis === undefined) return null; + let midi = (oct + 1) * 12 + semis; + if (accidental === "#") midi += 1; + else if (accidental === "b") midi -= 1; + if (midi < 0 || midi > 127) return null; + return midi; +} + +function publishNotepatMidi(event, midiNote, velocity, channel = 0) { + if (!amxdPublishEnabled || amxdSuppressPublish) return; + if (!Number.isFinite(midiNote)) return; + const payload = { + event, + note: midiNote, + velocity, + channel, + handle: amxdHandle, + machineId: amxdMachineId, + piece: "notepat", + ts: Date.now(), + }; + // UDP path (lowest latency); silently no-ops if not connected. + try { udpServer?.send?.("notepat:midi", payload); } catch (_) {} + // WS reliable fallback — session-server dedups by (note + ts) implicitly + // because each press only produces one note_on/note_off pair anyway. + try { relaySocket?.send?.("notepat:midi:publish", payload); } catch (_) {} + amxdSentCount += 1; + amxdLastEvent = { event, note: midiNote, velocity, ts: payload.ts }; +} + +function publishNotepatMidiHeartbeat() { + if (!amxdPublishEnabled) return; + const now = Date.now(); + if (now - amxdHeartbeatAt < AMXD_HEARTBEAT_MS) return; + amxdHeartbeatAt = now; + const payload = { + handle: amxdHandle, + machineId: amxdMachineId, + piece: "notepat", + ts: now, + }; + try { udpServer?.send?.("notepat:midi:heartbeat", payload); } catch (_) {} + try { relaySocket?.send?.("notepat:midi:heartbeat", payload); } catch (_) {} +} + +async function boot({ + params, + api, + colon, + ui, + screen, + fps, + typeface, + hud, + net, + store, + painting, + sound, + clock, + query, + handle, +}) { + autopatApi = api; + autopatHud = hud; + autopatTypeface = typeface; + setSoundContext({ + synth: sound?.synth, + play: sound?.play, + freq: sound?.freq, + }); + + // 🌐 Complete the notepat.com wordmark on the same baseline. + hud.suffix(".com"); + dotComMode = true; + + // 🎹 Check if we're in DAW mode (loaded from Ableton M4L) + dawMode = query?.daw === "1" || query?.daw === 1 || query?.daw === true; + console.log("🎹 Notepat: dawMode =", dawMode, "query.daw =", query?.daw, typeof query?.daw); + + // ?overlay=true → transparent-bg mode for the ac-electron Notepat Overlay + // window. Skip background wipes in paint() so the desktop shows through. + overlayMode = query?.overlay === "true" || query?.overlay === true || query?.overlay === "1"; + if (overlayMode) { + console.log("🪟 Notepat: overlay mode — skipping background wipes"); + // Hide the corner label + ".com" suffix so nothing floats over + // the desktop except the piano itself. The default slug is set by + // the disk loader, so we have to explicitly clear it. + hud.suffix(null); + hud.label(undefined); + dotComMode = false; + } + + // Also check if we already have DAW data (survives hot reload) + if (!dawMode && sound.daw?.bpm) { + dawMode = true; + dawAutoDetected = true; + console.log("🎹 Notepat: DAW mode AUTO-DETECTED from existing sound.daw:", sound.daw); + } + + if (dawMode) { + console.log("🎹 Notepat: DAW mode enabled", dawAutoDetected ? "(auto-detected)" : "(from query)"); + } + + // 🎹 NuPhy Air60 HE — auto-connect if previously paired (no user gesture needed) + nuphyConnect(false).then((ok) => { + if (ok) console.log("🎹 NuPhy auto-connected from saved pairing"); + }); + + // 🕒 Store clock reference for UTC-synced metronome + metronomeClockRef = clock; + if (clock?.resync) { + clock.resync(); // Sync to UTC on boot + } + + // Reset state on boot to avoid stuck notes after reloads + bootTimestamp = performance.now() / 1000; // Current time in seconds + Object.keys(sounds).forEach((note) => { + sounds[note]?.sound?.kill?.(0.01); + delete sounds[note]; + }); + Object.keys(tonestack).forEach((note) => delete tonestack[note]); + Object.keys(trail).forEach((note) => delete trail[note]); + Object.keys(downs).forEach((note) => delete downs[note]); + anyDown = false; + audioReinitRequested = false; + + // 🕒 Request minimal audio latency for this piece (must be set before AudioContext creation) + if (typeof window !== "undefined") { + window.__acLatencyHint = 0.003; // 3ms target - very aggressive for low latency + window.__acSampleRate = 48000; // Lower CPU load for tighter real-time response + window.__acSpeakerPerformanceMode = "disabled"; // Skip heavy analysis work in the worklet + } + lowLatencyMode = true; + + // Store sample rate for layout calculations + storedSampleRate = sound?.sampleRate || (typeof window !== "undefined" ? window.audioContext?.sampleRate : null); + + if (Array.isArray(colon)) { + if (colon.includes("perf")) { + paintPerfEnabled = true; + } + } + + // Disabled: dynamic audio reinit was breaking audio - now using 48kHz globally + pendingAudioReinit = false; + + // fps(4); + udpServer = net.udp(); // For sending messages to `tv`. + relaySourceHandle = normalizeRelayHandle(query?.relayHandle); + relaySourceMachineId = `${query?.relayMachine || ""}`.trim(); + relaySubscribeAll = query?.relayAll === "1" || query?.relay === "all"; + relayStatusText = relaySubscriptionPayload() ? `relay ${relayTargetLabel()}` : "relay off"; + relaySocket = net.socket(handleRelaySocketMessage); + requestRelaySources(); + sendRelaySubscription(); + + // 🎹 notepat.com → /amxd MIDI bridge. Opt-in via `?amxd=1`; auto-on when + // running inside an M4L jweb host (acFORCE_DAW). Matches what AC Native + // OS does at runtime (always-on UDP send to session-server:10010, see + // fedac/native/pieces/notepat.mjs sendUdpMidiEvent). + const amxdFlag = query?.amxd; + amxdPublishEnabled = + amxdFlag === "1" || amxdFlag === 1 || amxdFlag === true || + amxdFlag === "publish" || amxdFlag === "on" || + (typeof window !== "undefined" && !!window.acFORCE_DAW); + if (amxdPublishEnabled) { + amxdHandle = normalizeRelayHandle(handle?.()) || "notepat-web"; + // Per-tab machineId so a single user with notepat.com open in two tabs + // shows as two sources. Falls back to a fresh random id when crypto.randomUUID + // is unavailable (rare; covers very old browser shells). + try { + amxdMachineId = + (typeof crypto !== "undefined" && crypto.randomUUID?.()) || + `web-${Math.random().toString(36).slice(2, 10)}`; + } catch (_) { + amxdMachineId = `web-${Math.random().toString(36).slice(2, 10)}`; + } + console.log( + `🎹 notepat → amxd bridge ON handle=@${amxdHandle} machineId=${amxdMachineId}`, + ); + // Initial heartbeat so the M4L source list lights up before any note. + publishNotepatMidiHeartbeat(); + } + if (typeof window !== "undefined") { + if (relayMessageHandler) window.removeEventListener("message", relayMessageHandler); + relayMessageHandler = (event) => { + const data = event?.data; + if (!data || typeof data !== "object") return; + if (data.type === "notepat:midi:set-source" || data.type === "notepat:midi:subscribe") { + setRelaySource({ + handle: data.handle, + machineId: data.machineId, + all: data.all === true, + }); + } else if (data.type === "notepat:midi:unsubscribe") { + setRelaySource({ handle: "", machineId: "", all: false }); + } else if (data.type === "notepat:midi:sources") { + requestRelaySources(); + } + }; + window.addEventListener("message", relayMessageHandler); + updateRelayBridgeState(); + } + + // Create picture buffer at quarter resolution (quarter width, quarter height) + const pictureWidth = Math.max(1, Math.floor(screen.width / 4)); + const pictureHeight = Math.max(1, Math.floor(screen.height / 4)); + picture = painting(pictureWidth, pictureHeight, ({ wipe }) => { + wipe("gray"); + }); + + net + .preload("startup") // TODO: Switch this default sample. 24.11.19.22.20 + .then((sfx) => (startupSfx = sfx)) + .catch((err) => console.warn(err)); // Load startup + + stampleSampleId = null; + stampleSampleData = null; + stampleSampleRate = null; + stampleNeedleProgress = 0; + stampleNeedleNote = null; + + if (store?.retrieve) { + const storedSample = + store["stample:sample"] || + (await store.retrieve("stample:sample", "local:db")); + if (storedSample?.data?.length) { + const storedId = storedSample.id || "stample"; + stampleSampleId = storedId; + stampleSampleData = storedSample.data; + stampleSampleRate = storedSample.sampleRate; + sound?.registerSample?.(storedId, storedSample.data, storedSample.sampleRate); + } + } + + try { + sound?.midi?.connect?.(); + } catch (err) { + console.warn("🎹 Unable to request MIDI connection", err); + } + + // Load MatrixChunky8 font for note letters + if (api.Typeface) { + matrixFont = new api.Typeface("MatrixChunky8"); + matrixFont.load(net.preload); + } + + // qrcells = qr("https://prompt.ac/notepat", { errorCorrectLevel: 2 }).modules; + + // 🎨 KidLisp background: parse $code param (e.g. `notepat $roz`) + const dollarParam = params.find((p) => p.startsWith("$")); + if (dollarParam) { + kidlispBackground = dollarParam; // e.g. "$roz" + kidlispBgEnabled = true; + hud.label(`notepat ${dollarParam}`); + } + + if (params[0] === "piano") { + toneVolume = params[1] || 0.5; + hud.label("notepat"); // Clear the label. + } + + if (params[0] === "twinkle") { + song = parseSong(rawSong); + hud.label("notepat"); // Strip "twinkle" from the label + } + + // Check if any parameter contains a custom melody using rawSong syntax (note:word) + const customMelodyParams = params.filter(param => param.includes(":")); + if (customMelodyParams.length > 0) { + // Join all custom melody params into a single string + const customRawSong = customMelodyParams.join(" "); + song = parseSong(customRawSong); + hud.label("notepat"); // Clear the label + } + + if (autopatConfig.enabled) { + rebuildAutopatSong(); + } + + if (song) { + let x = 0; + const glyphWidth = resolveMatrixGlyphMetrics(typeface).width; + song.forEach((part, index) => { + let word = part[1], + space = 0; + word.endsWith("-") ? (word = word.slice(0, -1)) : (space = glyphWidth); + if (word.startsWith("-")) word = word.slice(1); + songStops.push([word, x]); + x += word.length * glyphWidth + space; + }); + } + + // keys = params.join(" ") || ""; + // keys = keys.replace(/\s/g, ""); + // if (keys.length > 0) { + // tap = true; + // editable = false; + // } + + const wavetypes = [ + "gm", + "square", + "sine", + "triangle", + "sawtooth", + "noise-white", + "noise", + "harp", + "whistle", + "composite", + "stample", + "sample", + "drum", + ]; + const requestedWave = wavetypes.indexOf(colon[0]) > -1 ? colon[0] : wave; + wave = requestedWave === "sample" ? "stample" : requestedWave; + // Map 'noise' shorthand to 'noise-white' for the synth + if (wave === "noise") wave = "noise-white"; + const stampleSourceToken = colon?.[1]; + const stampleSource = stampleSourceToken + ? (stampleSourceToken.startsWith("%23") + ? `#${stampleSourceToken.slice(3)}` + : decodeURIComponent(stampleSourceToken)) + : null; + if ((wave === "stample" || wave === "sample") && stampleSource) { + const loaded = await loadPaintingAsAudio(stampleSource, { + sampleId: "stample:bitmap", + sound: { + registerSample: sound?.registerSample, + sampleRate: sound?.sampleRate, + }, + preload: net?.preload, + store, + system: api?.system, + get: api?.get, + painting, + kidlispSize: 128, + }); + if (loaded?.sampleData?.length) { + stampleSampleId = loaded.sampleId; + stampleSampleData = loaded.sampleData; + stampleSampleRate = loaded.meta?.sampleRate || sound?.sampleRate || 48000; + } + } + // slide = true; // colon[0] === "slide" || colon[1] === "slide"; + + const colonTokens = Array.isArray(colon) ? colon.filter(Boolean) : []; + const hideLabelTokens = new Set(["stample", "sample"]); + const visibleColonTokens = colonTokens.filter( + (token) => !hideLabelTokens.has(token), + ); + const hasHiddenLabelToken = colonTokens.some((token) => hideLabelTokens.has(token)); + if (colonTokens.length > 0) { + if (hasHiddenLabelToken && visibleColonTokens.length === 0) { + hud.label("notepat"); + } else { + const coloredSuffix = visibleColonTokens + .map((token) => `\\gray\\:\\silver\\${token}`) + .join(" "); + const labelText = `\\silver\\notepat${coloredSuffix ? " " + coloredSuffix : ""}`; + const plainText = `notepat${visibleColonTokens.length ? " " + visibleColonTokens.map((token) => `:${token}`).join(" ") : ""}`; + hud.label(labelText, undefined, 0, plainText); + } + } + + buildOctButton(api); + buildWaveButton(api); + buildAbletonButton(api); + buildOsButton(api); + buildRecButton(api); + buildToggleButtons(api); + buildMetronomeButtons(api); + + const newOctave = + parseInt(colon[0]) || parseInt(colon[1]) || parseInt(colon[2]); + + if (newOctave) { + STARTING_OCTAVE = newOctave.toString(); + octave = STARTING_OCTAVE; + } + + setupButtons(api); +} + +function sim({ sound, simCount, num, clock, painting }) { + const simTick = typeof simCount === "bigint" ? Number(simCount) : simCount; + setSoundContext({ + synth: sound?.synth, + play: sound?.play, + freq: sound?.freq, + num, + }); + flushRelayMidiQueue(); + publishNotepatMidiHeartbeat(); + recordSync(); + + if (lowLatencyMode) { + if (simTick % 3 === 0) sound.speaker?.poll(); + } else { + sound.speaker?.poll(); + } + + // In stample mode, keep a live KidLisp-backed sample updated for playback. + if ( + kidlispBgEnabled && + kidlispBackground && + (wave === "stample" || wave === "sample") && + typeof painting === "function" + ) { + kidlispSampleRefreshTick = (kidlispSampleRefreshTick + 1) % 4; + if (kidlispSampleRefreshTick === 0) { + const bufferSize = 128; + try { + const lispPainting = painting(bufferSize, bufferSize, (paintApi) => { + paintApi.kidlisp(0, 0, bufferSize, bufferSize, kidlispBackground); + }); + if (lispPainting?.pixels?.length) { + const totalPixels = bufferSize * bufferSize; + const meta = { + sampleLength: totalPixels * 3, + sampleRate: sound?.sampleRate || 48000, + }; + const decoded = decodeBitmapToSample(lispPainting, meta); + if (decoded?.length) { + if (sound?.updateSample) { + sound.updateSample("stample:bitmap", decoded, meta.sampleRate); + } else { + sound?.registerSample?.("stample:bitmap", decoded, meta.sampleRate); + } + stampleSampleId = "stample:bitmap"; + stampleSampleData = decoded; + stampleSampleRate = meta.sampleRate; + } + } + } catch (err) { + // Non-fatal: keep notepat interactive if KidLisp render fails. + console.warn("🎨 Notepat KidLisp sample refresh failed", err); + } + } + } + + // 🎹 Update DAW state from sound.daw (updated continuously by bios.mjs) + if (sound.daw) { + // Auto-enable DAW mode if we receive DAW data + if (!dawMode && sound.daw.bpm !== undefined && sound.daw.bpm !== null) { + dawMode = true; + dawAutoDetected = true; + console.log("🎹 Notepat sim() AUTO-DETECTED DAW mode from sound.daw:", JSON.stringify(sound.daw)); + } + + if (sound.daw.bpm !== undefined && sound.daw.bpm !== null) { + dawSynced = true; + dawBpm = Math.round(sound.daw.bpm); + // Sync notepat's metronome BPM to DAW BPM when in DAW mode + if (dawMode) { + metronomeBPM = dawBpm; + } + } + if (sound.daw.playing !== undefined && sound.daw.playing !== null) { + dawPlaying = sound.daw.playing; + } + } + + // 🥁 UTC-synced metronome tick (like clock.mjs) + // In DAW mode: metronome auto-enables when DAW is playing, disabled when stopped + // 🥁 UTC-synced metronome tick (like clock.mjs) + // In DAW mode: Tick when metronome is enabled AND DAW is playing (sync to Ableton transport) + // In standalone mode: Tick when metronome is manually enabled + const shouldTickMetronome = metronomeEnabled && metronomeBPM > 0 && + (!dawMode || dawPlaying); // In DAW mode, also require dawPlaying + + if (shouldTickMetronome) { + const clockRef = clock || metronomeClockRef; + const syncedTime = clockRef?.time?.(); + const currentTimeMs = syncedTime ? syncedTime.getTime() : Date.now(); + + // Calculate beat interval in milliseconds + const msPerBeat = 60000 / metronomeBPM; + + // Calculate which beat we should be on based on UTC time + // This ensures all instances sync to the same beat boundaries + const beatNumber = Math.floor(currentTimeMs / msPerBeat); + + // Check if we've moved to a new beat + if (beatNumber !== metronomeBeatCount) { + metronomeBeatCount = beatNumber; + metronomeLastBeatTime = currentTimeMs; + metronomeVisualPhase = 1.0; // Flash on beat + metronomeFlash = 1.0; // Trigger peripheral screen flash + + // 🥁 Record beat in piano roll history (downbeat=2, regular=1) + const isDownbeat = (beatNumber % 4) === 0; + pianoRollBeatHistory[PIANO_ROLL_WIDTH - 1] = isDownbeat ? 2 : 1; + + // Play metronome click sound + // Accent on beat 1 of each measure (every 4 beats) + const clickFreq = isDownbeat ? 1200 : 800; // Higher pitch for downbeat + const clickVol = isDownbeat ? 0.4 : 0.25; + + sound.synth({ + type: "sine", + tone: clickFreq, + attack: 0.001, + decay: 0.05, + sustain: 0, + release: 0.02, + volume: clickVol, + duration: 0.03, + }); + } + + // Update bouncing ball position - oscillates between 0 and 1 within each beat + // Use a smooth sine-based bounce that peaks in the middle of the beat interval + const msPerBeatForBall = 60000 / metronomeBPM; + const beatProgress = (currentTimeMs % msPerBeatForBall) / msPerBeatForBall; + // Sine wave creates smooth back-and-forth motion + metronomeBallPos = Math.sin(beatProgress * Math.PI); + + // Decay visual phase smoothly + if (metronomeVisualPhase > 0) { + metronomeVisualPhase = Math.max(0, metronomeVisualPhase - 0.08); + } + // Decay peripheral flash faster for snappier effect + if (metronomeFlash > 0) { + metronomeFlash = Math.max(0, metronomeFlash - 0.15); + } + } else if (metronomeEnabled && dawMode && !dawPlaying) { + // Reset visual state when metronome is enabled but DAW is stopped + metronomeVisualPhase = 0; + metronomeBallPos = 0; + metronomeFlash = 0; + } + + // 🛡️ Stuck note protection: Kill notes held longer than MAX_NOTE_LIFETIME + // Note: startedAt is in audio-time (soundTime), so we must compare against soundTime, not wall-clock + const MAX_NOTE_LIFETIME = 30; // seconds - notes shouldn't be held this long + const audioNow = sound.time; // Use audio time, not performance.now() + if (audioNow !== undefined) { + Object.keys(sounds).forEach((noteKey) => { + const entry = sounds[noteKey]; + if (entry?.sound?.startedAt) { + const age = audioNow - entry.sound.startedAt; + if (age > MAX_NOTE_LIFETIME) { + console.warn(`🛡️ Killing stuck note: ${noteKey} (held ${age.toFixed(1)}s)`); + entry.sound?.kill?.(0.1); + delete sounds[noteKey]; + delete tonestack[noteKey]; + if (buttons[noteKey]) buttons[noteKey].down = false; + } + } + }); + } + + // 📊 Update performance stats + if (perfOSD) { + const perfNow = performance.now(); + if (perfStats.lastFrameTimestamp > 0) { + perfStats.frameTime = perfNow - perfStats.lastFrameTimestamp; + perfStats.fps = Math.round(1000 / perfStats.frameTime); + } + perfStats.lastFrameTimestamp = perfNow; + perfStats.soundCount = Object.keys(sounds).filter(k => sounds[k]?.sound).length; + perfStats.tonesInStack = Object.keys(tonestack).length; + perfStats.keysLength = keys.length; + + // Get audio context info + if (sound.sampleRate) { + perfStats.sampleRate = sound.sampleRate / 1000; // Convert to kHz + } + // Get AudioContext latency info from window if available + if (typeof window !== 'undefined' && window.audioContext) { + perfStats.baseLatency = (window.audioContext.baseLatency || 0) * 1000; + perfStats.outputLatency = (window.audioContext.outputLatency || 0) * 1000; + } + + // Get memory usage if available + if (typeof performance !== 'undefined' && performance.memory) { + perfStats.memoryUsed = Math.round(performance.memory.usedJSHeapSize / 1024 / 1024); + } + } + + if (songShifting) { + songShift += !songNoteDown ? 0.5 : 1; + if (songShift >= songStops[songIndex][1]) { + songShift = songStops[songIndex][1]; + songShifting = false; + } + + // Calculate progress (0 to 1) through current note + if (songIndex > 0) { + const currentX = songStops[songIndex][1]; + const prevX = songStops[songIndex - 1]?.[1] || 0; + const distance = currentX - prevX; + const traveled = songShift - prevX; + songProgress = distance > 0 ? Math.min(1, traveled / distance) : 0; + } else { + songProgress = songShift / (songStops[0]?.[1] || 1); + } + } + + if (autopatConfig.enabled && autopatConfig.events?.length && !autopatConfig.paused) { + const now = typeof sound?.time === "number" ? sound.time : performance.now() / 1000; + const beatSeconds = 60 / (autopatConfig.bpm || 88); + + if (!autopatState.started) { + autopatState.started = true; + autopatState.nextTime = now + (autopatConfig.startDelay || 0); + autopatState.eventIndex = 0; + } + + if (autopatState.activeNote && now >= autopatState.activeNoteEnd) { + stopButtonNote(autopatState.activeNote, { force: true }); + autopatState.activeNote = null; + autopatState.activeNoteEnd = 0; + autopatState.nextTime = now; + } + + if (!autopatState.activeNote && now >= autopatState.nextTime) { + const event = autopatConfig.events[autopatState.eventIndex]; + if (!event) { + autopatState.eventIndex = 0; + } else { + const beats = event.beats ?? autopatConfig.beatsPerNote ?? 1; + const durationSeconds = beatSeconds * beats; + const key = event.key; + if (key) { + const note = key.toLowerCase(); + const started = startButtonNote(note, 127, autopatApi); + if (started) { + autopatState.activeNote = note; + autopatState.activeNoteEnd = now + durationSeconds; + } else { + autopatState.activeNote = null; + autopatState.activeNoteEnd = now; + } + } else { + autopatState.activeNote = null; + autopatState.activeNoteEnd = now + durationSeconds; + } + autopatState.eventIndex = + (autopatState.eventIndex + 1) % autopatConfig.events.length; + if (!key) { + autopatState.nextTime = now + durationSeconds; + } + } + } + } else if (autopatConfig.enabled && song?.length && !autopatConfig.paused) { + const now = typeof sound?.time === "number" ? sound.time : performance.now() / 1000; + const beatSeconds = 60 / (autopatConfig.bpm || 88); + const noteSeconds = beatSeconds * (autopatConfig.beatsPerNote || 1); + const restSeconds = beatSeconds * (autopatConfig.restBeats || 0); + + if (!autopatState.started) { + autopatState.started = true; + autopatState.nextTime = now + (autopatConfig.startDelay || 0); + } + + if (autopatState.activeNote && now >= autopatState.activeNoteEnd) { + stopButtonNote(autopatState.activeNote, { force: true }); + autopatState.activeNote = null; + autopatState.activeNoteEnd = 0; + autopatState.nextTime = now + restSeconds; + } + + if (!autopatState.activeNote && now >= autopatState.nextTime) { + const current = song?.[songIndex]?.[0]; + if (current) { + const note = current.toLowerCase(); + const started = startButtonNote(note, 127, autopatApi); + if (started) { + autopatState.activeNote = note; + autopatState.activeNoteEnd = now + noteSeconds; + } else if (song?.length) { + songIndex = (songIndex + 1) % song.length; + } + } + } + } else if (autopatConfig.enabled && autopatConfig.paused) { + if (autopatState.activeNote) { + stopButtonNote(autopatState.activeNote, { force: true }); + autopatState.activeNote = null; + autopatState.activeNoteEnd = 0; + } + } + + Object.keys(trail).forEach((note) => { + trail[note] -= 0.0065; + if (trail[note] <= 0) delete trail[note]; + }); + + // Keep shake active while note is playing, decay only after release + Object.keys(noteShake).forEach((note) => { + // Check if note is still playing (with or without + prefix) + const isPlaying = sounds[note] !== undefined || sounds[note.replace('+', '')] !== undefined || sounds[`+${note}`] !== undefined; + if (isPlaying) { + // Keep shake at a constant level while playing + noteShake[note] = Math.max(noteShake[note], 2); + } else { + // Decay shake after note release + noteShake[note] -= 0.15; + if (noteShake[note] <= 0) delete noteShake[note]; + } + }); + + const active = orderedByCount(sounds); + + if (wave === "stample" && active.length > 0) { + stampleProgressTick = (stampleProgressTick + 1) % 12; + if (stampleProgressTick === 0) { + const note = active[active.length - 1]; + const entry = sounds[note]; + entry?.sound?.progress?.().then((progressData) => { + if (progressData && typeof progressData.progress === "number") { + stampleNeedleProgress = progressData.progress; + stampleNeedleNote = note; + } + }); + } + } else if (active.length === 0) { + stampleNeedleProgress = 0; + stampleNeedleNote = null; + } + + if (active.length > 0 && transposeOverlayFade < 1) { + // transposeOverlayFade += 0.05; + transposeOverlayFade = 1; + transposeOverlayFade = min(1, transposeOverlayFade); + } else if (transposeOverlayFade > 0) { + transposeOverlayFade -= 0.0065; + transposeOverlayFade = max(0, transposeOverlayFade); + } + + // Color shifting for the oscilloscope. + const val = activeStr ? 0.075 : 0.02; + let d1 = lastAverage, + d2 = currentAverage; + if (!activeStr) d1 = d2 = [0, 0, 0]; + if ( + !activeStr && + secondaryColor[0] < 50 && + secondaryColor[1] < 50 && + secondaryColor[2] < 50 + ) { + lastAverage = [0, 0, 0]; + d1 = lastAverage; + } + secondaryColor = num.shiftRGB(secondaryColor, d1, val, "lerp"); + primaryColor = num.shiftRGB(primaryColor, d2, val, "lerp"); +} + +function resolveMatrixGlyphMetrics(fallbackTypeface) { + const glyph = + matrixFont?.glyphs?.["0"]?.resolution ?? + fallbackTypeface?.glyphs?.["0"]?.resolution; + + if (Array.isArray(glyph) && glyph.length >= 2) { + return { width: glyph[0], height: glyph[1] }; + } + + return { width: 6, height: 8 }; +} + +function measureMatrixTextWidth(text, fallbackTypeface) { + if (!text || !text.length) return 0; + const advances = matrixFont?.data?.advances || fallbackTypeface?.data?.advances; + const defaultAdvance = resolveMatrixGlyphMetrics(fallbackTypeface).width; + let width = 0; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + width += advances && advances[ch] !== undefined ? advances[ch] : defaultAdvance; + } + return width; +} + +function measureMatrixTextBoxWidth(text, api, screenWidth) { + if (!text || !text.length || !api?.text?.box) return 0; + const bounds = Math.max(screenWidth || 0, 200); + const tb = api.text.box( + text, + { x: 0, y: 0 }, + bounds, + 1, + false, + "MatrixChunky8", + ); + return tb?.box?.width || 0; +} + +function getMiniPianoGeometry({ screen, layout, song, trackY, trackHeight }) { + const isCompact = layout?.compactMode && layout?.splitLayout; + const isHorizontalLayout = layout?.miniInputsHorizontal; + const isVerticalLayout = layout?.miniInputsVertical; + const whiteKeyWidth = getMiniPianoWhiteKeyWidth(isCompact); + const whiteKeyHeight = MINI_KEYBOARD_HEIGHT; + const blackKeyWidth = getMiniPianoBlackKeyWidth(isCompact); + const blackKeyHeight = getMiniPianoBlackKeyHeight(isCompact); + const pianoWidth = MINI_PIANO_WHITE_KEYS.length * whiteKeyWidth; + const sidePanelWidth = layout?.sidePanelWidth || 0; + const effectiveWidth = screen.width - sidePanelWidth; + const qKeyWidth = 9; + const qKeySpacing = QWERTY_MINIMAP_KEY_SPACING; + const qwertyWidth = + Math.max(...QWERTY_LAYOUT_ROWS.map((row) => row.length)) * + (qKeyWidth + qKeySpacing); + const qwertyHeight = QWERTY_MINIMAP_HEIGHT; + + let pianoY = OS_BAR_BOTTOM; + let pianoStartX = 58; + const centerX = layout?.centerX ?? (effectiveWidth - pianoWidth) / 2; + const centerWidth = layout?.centerAreaWidth ?? pianoWidth; + const centerRight = centerX + centerWidth; + const clamp = (value, minValue, maxValue) => + Math.min(Math.max(value, minValue), maxValue); + + // Vertical/rotated layout: piano keys run vertically on the right side + if (isVerticalLayout) { + const gridWidth = (layout?.buttonsPerRow || 4) * (layout?.buttonWidth || 20) + (layout?.margin || 2) * 2; + const gridLeft = layout?.margin || 2; + // In vertical mode, the "width" of the piano becomes its height + // whiteKeyWidth becomes the height of each key, whiteKeyHeight becomes the width + const rotatedPianoWidth = whiteKeyHeight + 2; // Keys drawn horizontally but stacked vertically + const rotatedPianoHeight = MINI_PIANO_WHITE_KEYS.length * whiteKeyWidth; // Full piano height when rotated + pianoStartX = gridLeft + gridWidth + 4; // Gap from grid + pianoY = layout?.topButtonY || OS_BAR_BOTTOM; + + // Check if rotated piano fits horizontally (x + width within screen) + const pianoRight = pianoStartX + rotatedPianoWidth; + // Check if rotated piano fits vertically (y + height within screen) + const availableHeight = screen.height - pianoY - 4; + + // If piano doesn't fit, hide it + if (pianoRight > screen.width - 2 || rotatedPianoHeight > availableHeight) { + return { + x: 0, + y: 0, + whiteKeyWidth, + whiteKeyHeight, + blackKeyWidth, + blackKeyHeight, + pianoWidth, + hidden: true, // Flag to skip painting piano + qwertyStartX: null, + qwertyStartY: null, + }; + } + + return { + x: pianoStartX, + y: pianoY, + whiteKeyWidth, + whiteKeyHeight, + blackKeyWidth, + blackKeyHeight, + pianoWidth, + rotated: true, // Flag to indicate 90-degree rotation + rotatedWidth: rotatedPianoWidth, + rotatedHeight: rotatedPianoHeight, + qwertyStartX: null, // No qwerty in narrow mode + qwertyStartY: null, + }; + } + + // Horizontal layout: place piano/qwerty to the right of the grid, stacked vertically + if (isHorizontalLayout) { + const gridWidth = (layout?.buttonsPerRow || 4) * (layout?.buttonWidth || 20) + (layout?.margin || 2) * 2; + const gridLeft = layout?.margin || 2; + pianoStartX = gridLeft + gridWidth + 8; // 8px gap from grid + pianoY = layout?.topButtonY || OS_BAR_BOTTOM; + + // Check if piano fits horizontally + const pianoRight = pianoStartX + pianoWidth; + if (pianoRight > screen.width - 2) { + // Piano doesn't fit - hide it + return { + x: 0, + y: 0, + whiteKeyWidth, + whiteKeyHeight, + blackKeyWidth, + blackKeyHeight, + pianoWidth, + hidden: true, // Flag to skip painting piano + qwertyStartX: null, + qwertyStartY: null, + }; + } + + // Stack qwerty below piano + const qwertyStartX = pianoStartX; + const qwertyStartY = pianoY + whiteKeyHeight + QWERTY_MINIMAP_SPACING; + + return { + x: pianoStartX, + y: pianoY, + whiteKeyWidth, + whiteKeyHeight, + blackKeyWidth, + blackKeyHeight, + pianoWidth, + qwertyStartX, + qwertyStartY, + }; + } + + if (isCompact) { + pianoY = OS_BAR_BOTTOM + 2; + // Check if piano fits in center area - if not, skip it (return hidden flag) + if (centerWidth < pianoWidth + 4) { + // Piano doesn't fit - hide it but still compute QWERTY position for center + // Center the QWERTY in the available center area + const idealQwertyX = centerX + (centerWidth - qwertyWidth) / 2; + const minQwertyX = centerX; + const maxQwertyX = Math.max(minQwertyX, centerRight - qwertyWidth); + const clampedQwertyX = clamp(idealQwertyX, minQwertyX, maxQwertyX); + return { + x: 0, + y: 0, + whiteKeyWidth, + whiteKeyHeight, + blackKeyWidth, + blackKeyHeight, + pianoWidth, + hidden: true, // Flag to skip painting piano + qwertyStartX: clampedQwertyX, + qwertyStartY: pianoY, + }; + } + const idealX = centerX + (centerWidth - pianoWidth) / 2; + const minX = centerX; + const maxX = Math.max(minX, centerRight - pianoWidth); + pianoStartX = clamp(idealX, minX, maxX); + } else if (song) { + const effectiveTrackY = trackY ?? OS_BAR_BOTTOM; + pianoY = effectiveTrackY + (trackHeight || 0) + 2; + pianoStartX = effectiveWidth - pianoWidth - 2; + } else { + const rightAlignedX = effectiveWidth - pianoWidth - 2; + if (rightAlignedX > pianoStartX + 12) { + pianoStartX = rightAlignedX; + } + } + + let qwertyStartX = pianoStartX; + if (layout?.splitLayout) { + const idealQwertyX = centerX + (centerWidth - qwertyWidth) / 2; + const minQwertyX = centerX; + const maxQwertyX = Math.max(minQwertyX, centerRight - qwertyWidth); + qwertyStartX = clamp(idealQwertyX, minQwertyX, maxQwertyX); + } + let qwertyStartY = pianoY + whiteKeyHeight + QWERTY_MINIMAP_SPACING; + const maxY = layout?.topButtonY ? layout.topButtonY - 2 : screen.height; + if (qwertyStartY + qwertyHeight > maxY) { + const gap = 4; + const rightX = pianoStartX + pianoWidth + gap; + const leftX = pianoStartX - gap - qwertyWidth; + if (layout?.splitLayout) { + const leftFits = leftX >= centerX + 2; + const rightFits = rightX + qwertyWidth <= centerRight - 2; + if (leftFits) { + qwertyStartX = leftX; + qwertyStartY = pianoY; + } else if (rightFits) { + qwertyStartX = rightX; + qwertyStartY = pianoY; + } + } else if (leftX >= 2) { + qwertyStartX = leftX; + qwertyStartY = pianoY; + } else if (rightX + qwertyWidth <= effectiveWidth - 2) { + qwertyStartX = rightX; + qwertyStartY = pianoY; + } + } + + return { + x: pianoStartX, + y: pianoY, + whiteKeyWidth, + whiteKeyHeight, + blackKeyWidth, + blackKeyHeight, + pianoWidth, + qwertyStartX, + qwertyStartY, + }; +} + +function getMiniPianoNoteAt(x, y, geometry) { + if (!geometry || geometry.hidden) return null; + const { + x: startX, + y: startY, + whiteKeyWidth, + whiteKeyHeight, + blackKeyWidth, + blackKeyHeight, + } = geometry; + const relX = x - startX; + const relY = y - startY; + if (relX < 0 || relY < 0 || relY > whiteKeyHeight) return null; + + for (const { note, afterWhite } of MINI_PIANO_BLACK_KEYS) { + const bx = afterWhite * whiteKeyWidth + whiteKeyWidth - blackKeyWidth / 2; + if ( + relX >= bx && + relX <= bx + blackKeyWidth && + relY <= blackKeyHeight + ) { + return note.toLowerCase(); + } + } + + const index = Math.floor(relX / whiteKeyWidth); + if (index < 0 || index >= MINI_PIANO_WHITE_KEYS.length) return null; + return MINI_PIANO_WHITE_KEYS[index].toLowerCase(); +} + +function getQwertyKeyAt(x, y, pianoGeometry) { + if (!pianoGeometry || pianoGeometry.hidden) return null; + const qKeyWidth = 9; + const qKeyHeight = QWERTY_MINIMAP_KEY_HEIGHT; + const qKeySpacing = QWERTY_MINIMAP_KEY_SPACING; + const qwertyStartX = pianoGeometry.qwertyStartX ?? pianoGeometry.x; + const qwertyStartY = + pianoGeometry.qwertyStartY ?? + pianoGeometry.y + pianoGeometry.whiteKeyHeight + QWERTY_MINIMAP_SPACING; + + for (let rowIndex = 0; rowIndex < QWERTY_LAYOUT_ROWS.length; rowIndex += 1) { + const row = QWERTY_LAYOUT_ROWS[rowIndex]; + const rowOffset = + rowIndex === 0 + ? 0 + : rowIndex === 1 + ? (qKeyWidth + qKeySpacing) / 2 + : rowIndex === 2 + ? qKeyWidth + : Math.floor( + (pianoGeometry.pianoWidth - + row.length * (qKeyWidth + qKeySpacing)) / + 2, + ); + const rowY = qwertyStartY + rowIndex * (qKeyHeight + qKeySpacing); + const relY = y - rowY; + if (relY < 0 || relY > qKeyHeight) continue; + + for (let keyIndex = 0; keyIndex < row.length; keyIndex += 1) { + const keyX = + qwertyStartX + rowOffset + keyIndex * (qKeyWidth + qKeySpacing); + if (x >= keyX && x <= keyX + qKeyWidth) { + return row[keyIndex]; + } + } + } + + return null; +} + +function computeMidiBadgeMetrics( + screen, + glyphMetrics = resolveMatrixGlyphMetrics(), + compactMode = false, + rateLabel = null, + rateText = null, +) { + // Compact layout: "M|48k|60fps" - use proper text measurement + const midiWidth = measureMatrixTextWidth("M"); + const divWidth = 5; // divider + spacing + const shortRate = rateText ? rateText.replace("Hz", "") : ""; + const rateWidth = measureMatrixTextWidth(shortRate); + const fpsWidth = measureMatrixTextWidth("120fps"); // Max FPS width + + const totalTextWidth = midiWidth + divWidth + rateWidth + divWidth + fpsWidth; + const width = totalTextWidth + MIDI_BADGE_PADDING_X + MIDI_BADGE_PADDING_RIGHT; + const height = glyphMetrics.height + MIDI_BADGE_PADDING_Y * 2; + + // In compact mode, center the badge at bottom + const x = compactMode + ? (screen.width - width) / 2 + : screen.width - width - MIDI_BADGE_MARGIN; + const y = screen.height - height - MIDI_BADGE_MARGIN; + + return { x, y, width, height }; +} + +function computeMidiBadgeTopMetrics( + screen, + glyphMetrics = resolveMatrixGlyphMetrics(), + rateLabel = null, + rateText = null, +) { + const base = computeMidiBadgeMetrics( + screen, + glyphMetrics, + false, + rateLabel, + rateText, + ); + const x = MIDI_BADGE_MARGIN; + const y = SECONDARY_BAR_TOP + Math.max(0, Math.floor((SECONDARY_BAR_HEIGHT - base.height) / 2)); + return { ...base, x, y }; +} + +function computeMelodyButtonRect(screen, midiMetrics) { + if (!midiMetrics) return null; + + const maxWidth = midiMetrics.x - MELODY_ALIAS_MARGIN; + if (maxWidth < MELODY_ALIAS_MIN_SIDE) return null; + + const side = max( + MELODY_ALIAS_MIN_SIDE, + min(MELODY_ALIAS_BASE_SIDE, maxWidth), + ); + + const height = side; + const width = side; + const y = midiMetrics.y - MELODY_ALIAS_MARGIN - height; + if (y < 0) return null; + + const x = screen.width - width - MIDI_BADGE_MARGIN; + + return { x, y, width, height }; +} + +function getButtonLayoutMetrics( + screen, + { + songMode = false, + pictureOverlay = false, + midiMetrics, + rateLabel, + rateText, + sidePanelWidth = 0, + } = {}, +) { + const reservedSide = sidePanelWidth || autopatConfig.sidePanelWidth || 0; + const badgeMetrics = + midiMetrics ?? + computeMidiBadgeMetrics( + screen, + resolveMatrixGlyphMetrics(), + false, + rateLabel, + rateText, + ); + const melodyButtonRect = + songMode && !pictureOverlay + ? computeMelodyButtonRect(screen, badgeMetrics) + : null; + + const aliasPadding = melodyButtonRect + ? melodyButtonRect.height + MELODY_ALIAS_MARGIN + : 0; + + const baseBottomPadding = 2; + const bottomPadding = pictureOverlay + ? baseBottomPadding + : songMode + ? baseBottomPadding + badgeMetrics.height + MIDI_BADGE_MARGIN + aliasPadding + : baseBottomPadding; + + const totalButtons = buttonNotes.length; + const margin = 2; + + // Compact/DAW mode: split layout - octaves on sides, piano/qwerty in center + // Use split layout for landscape screens that are short enough (DAW embeds, wide windows) + const isLandscape = screen.width > screen.height; + const aspectRatio = screen.width / screen.height; + // Trigger split layout when: landscape AND (short OR very wide aspect ratio) + const compactMode = screen.height < 280 || (isLandscape && aspectRatio > 1.8); + + if (compactMode && isLandscape) { + const usableWidth = max(0, screen.width - reservedSide); + // Recompute badge metrics for compact mode (centered) + const compactBadgeMetrics = computeMidiBadgeMetrics( + screen, + resolveMatrixGlyphMetrics(), + true, + rateLabel, + rateText, + ); + + // Split layout: 4x3 grid per octave (12 notes each) + // Left side: first octave (c to b), Right side: second octave (+c to +b) + const notesPerSide = 12; + const buttonsPerRow = 4; // 4 notes per row on each side + const totalRows = Math.ceil(notesPerSide / buttonsPerRow); // 3 rows + const hudReserved = OS_BAR_BOTTOM; + + // Piano dimensions (extended mini layout) + const whiteKeyWidth = getMiniPianoWhiteKeyWidth(true); + const pianoWidth = MINI_PIANO_WHITE_KEYS.length * whiteKeyWidth; + const rotatedPianoWidth = MINI_KEYBOARD_HEIGHT + 4; // Width when piano is rotated + + // QWERTY minimap dimensions (10 keys per row roughly) + const qKeyWidth = 9; + const qKeySpacing = 1; + const qwertyWidth = + Math.max(...QWERTY_LAYOUT_ROWS.map((row) => row.length)) * + (qKeyWidth + qKeySpacing); + + // Center area: use smaller of piano or rotated piano, depending on available space + // Minimum center width is the rotated piano width (for narrow screens) + const minCenterWidth = rotatedPianoWidth + 4; + const idealCenterWidth = Math.max(pianoWidth, qwertyWidth) + 4; + // Check how much space we'd have for buttons with each center width + const spaceWithIdeal = (usableWidth - idealCenterWidth) / 2; + const spaceWithMin = (usableWidth - minCenterWidth) / 2; + // In split mode, if buttons would be too small even with minimum center, hide piano entirely + // This means center width = 0, and buttons get the full width + const pianoHidden = (spaceWithMin / buttonsPerRow < 16); + const centerWidth = pianoHidden ? 0 : + (spaceWithIdeal / buttonsPerRow >= 16) ? idealCenterWidth : minCenterWidth; + + // Available width for buttons on each side + const sideMargin = margin; + const availableSideWidth = (usableWidth - centerWidth) / 2 - sideMargin * 2; + + // Calculate button size - allow non-square to fill space better on narrow screens + const maxButtonWidth = floor(availableSideWidth / buttonsPerRow); + const availableHeight = screen.height - hudReserved - bottomPadding - margin; + const maxButtonHeight = floor(availableHeight / totalRows); + + // Use available width and height, but keep some proportionality + let buttonWidth = max(12, maxButtonWidth); + let buttonHeight = max(12, maxButtonHeight); + // Keep aspect ratio reasonable (between 0.5 and 2.0) + const aspect = buttonWidth / buttonHeight; + if (aspect > 2.0) buttonWidth = floor(buttonHeight * 2.0); + else if (aspect < 0.5) buttonHeight = floor(buttonWidth * 2.0); + // Avoid super-tall pads in split layout (prefer square or shorter) + buttonHeight = min(buttonHeight, buttonWidth); + + const topButtonY = hudReserved + margin; + + // Calculate actual button block width and height + const buttonBlockWidth = buttonsPerRow * buttonWidth; + const buttonBlockHeight = totalRows * buttonHeight; + + // Left octave starts at left margin + const leftOctaveX = sideMargin; + + // Right octave ends at right edge, position accordingly + const rightOctaveX = usableWidth - sideMargin - buttonBlockWidth; + + // Center area position - recalculate based on actual button sizes + const centerX = leftOctaveX + buttonBlockWidth + sideMargin; + // When piano is hidden, center area width should be minimal (just gap between button groups) + const centerAreaWidth = pianoHidden ? + Math.max(0, rightOctaveX - centerX - sideMargin) : + max(minCenterWidth, rightOctaveX - centerX - sideMargin); + + // Bottom center area for unified active note display - position above MIDI badge + const bottomCenterY = screen.height - compactBadgeMetrics.height - MIDI_BADGE_MARGIN - 14; + + // Vertical track in split mode: position in center area between the two octave grids + const verticalTrackWidth = songMode && centerAreaWidth >= VERTICAL_TRACK_MIN_WIDTH + ? min(VERTICAL_TRACK_WIDTH, centerAreaWidth - 4) + : 0; + const verticalTrackX = verticalTrackWidth > 0 + ? centerX + floor((centerAreaWidth - verticalTrackWidth) / 2) + : 0; + const verticalTrackY = hudReserved + 2; + const verticalTrackHeight = screen.height - hudReserved - bottomPadding - 4; + + return { + buttonWidth, + buttonHeight, + topButtonY, + totalRows, + buttonsPerRow, + margin, + bottomPadding, + hudReserved, + trackHeight: 0, + trackSpacing: 0, + reservedTop: hudReserved, + melodyButtonRect: null, + midiBadge: compactBadgeMetrics, // Use centered badge metrics + compactMode: true, + sidePanelWidth: reservedSide, + usableWidth, + miniInputsEnabled: miniInputsEnabled, + // Split layout info + splitLayout: true, + leftOctaveX, + rightOctaveX, + centerX, + centerAreaWidth, + notesPerSide, + bottomCenterY, + buttonBlockHeight, + // Vertical track (for split layout) + verticalTrack: verticalTrackWidth > 0, + verticalTrackX, + verticalTrackY, + verticalTrackWidth, + verticalTrackHeight, + }; + } + + const buttonsPerRow = 4; + const totalRows = ceil(totalButtons / buttonsPerRow); + + if (pictureOverlay) { + const buttonSize = 24; + const buttonsAreaHeight = totalRows * buttonSize; + const topButtonY = screen.height - bottomPadding - buttonsAreaHeight; + + return { + buttonWidth: buttonSize, + buttonHeight: buttonSize, + topButtonY, + totalRows, + buttonsPerRow, + margin, + bottomPadding, + hudReserved: 0, + trackHeight: 0, + trackSpacing: 0, + melodyButtonRect: null, + midiBadge: badgeMetrics, + compactMode: false, + sidePanelWidth: reservedSide, + }; + } + + const hudReserved = OS_BAR_BOTTOM; + const trackHeight = songMode ? TRACK_HEIGHT : 0; + const trackSpacing = songMode ? TRACK_GAP : 0; + const baseReservedTop = hudReserved + trackHeight + trackSpacing; + // Always calculate space for mini inputs (piano + qwerty stacked) + const qwertyReservedBase = QWERTY_MINIMAP_HEIGHT + QWERTY_MINIMAP_SPACING; + const keyboardReservedBase = + MINI_KEYBOARD_HEIGHT + MINI_KEYBOARD_SPACING + qwertyReservedBase; + const minButtonSize = 20; + + const usableWidth = max(0, screen.width - reservedSide); + + // Check if there's horizontal space to put mini inputs to the right of the grid + const gridWidthEstimate = buttonsPerRow * minButtonSize + margin * 2; + const pianoWidth = MINI_PIANO_WHITE_KEYS.length * getMiniPianoWhiteKeyWidth(false); + const horizontalSpaceForMini = usableWidth - gridWidthEstimate - pianoWidth > 10; + + // Check for narrow screens where we can fit a vertical/rotated piano on the right + // Rotated piano: width = MINI_KEYBOARD_HEIGHT, height = pianoWidth (all keys stacked) + const rotatedPianoWidth = MINI_KEYBOARD_HEIGHT + 4; // Piano keys become vertical + const rotatedPianoHeight = pianoWidth; // Height needed to fit ALL white keys + const availableHeightForRotated = screen.height - OS_BAR_BOTTOM - 4; // Leave some margin + // Only show rotated piano if ALL keys fit vertically + const narrowVerticalSpace = !horizontalSpaceForMini && + usableWidth - gridWidthEstimate - rotatedPianoWidth > 4 && + availableHeightForRotated >= rotatedPianoHeight; + + // Show mini inputs if there's either vertical or horizontal space + const availableWithoutMini = screen.height - bottomPadding - baseReservedTop; + const verticalSpaceForMini = + availableWithoutMini - keyboardReservedBase >= totalRows * minButtonSize; + const canFitMini = horizontalSpaceForMini || verticalSpaceForMini || narrowVerticalSpace; + const showMiniInputs = miniInputsEnabled && canFitMini; + // Only reserve top space if using vertical layout (not horizontal or narrow vertical) + const reservedTop = baseReservedTop + (showMiniInputs && verticalSpaceForMini && !horizontalSpaceForMini && !narrowVerticalSpace ? keyboardReservedBase : 0); + + // Calculate maximum button dimensions to fill available space + const widthLimit = floor((usableWidth - margin * 2) / buttonsPerRow); + const available = max(0, screen.height - bottomPadding - reservedTop); + const heightLimit = floor(available / totalRows); + + // In single-column mode, maximize pad size - allow non-square pads to fill space + // Use minimum of width/height to keep some proportionality, but don't cap artificially + const maxDimension = min(widthLimit, heightLimit); + const maxButtonSize = 64; // Allow larger pads on bigger screens + + // Check if vertical track can fit to the right of the button grid + // In landscape mode, always prefer vertical piano roll on the right + const isLandscapeSingle = screen.width > screen.height; + const gridWidth = buttonsPerRow * min(maxButtonSize, widthLimit); + const spaceForVerticalTrack = usableWidth - gridWidth - margin * 3; + // In landscape, use vertical track even with minimal space; in portrait, need more room + const minTrackWidth = isLandscapeSingle ? 24 : VERTICAL_TRACK_MIN_WIDTH; + const canFitVerticalTrack = spaceForVerticalTrack >= minTrackWidth; + const verticalTrackWidth = canFitVerticalTrack + ? min(VERTICAL_TRACK_WIDTH, max(24, spaceForVerticalTrack)) + : 0; + + // Adjust width limit if we're reserving space for vertical track + const adjustedWidthLimit = canFitVerticalTrack + ? floor((usableWidth - verticalTrackWidth - margin * 3) / buttonsPerRow) + : widthLimit; + + // Width fills horizontal space (up to max) + let buttonWidth = min(maxButtonSize, adjustedWidthLimit); + buttonWidth = max(minButtonSize, buttonWidth); + + // Height fills vertical space independently (up to max, min of width for proportionality) + let buttonHeight = min(maxButtonSize, heightLimit); + buttonHeight = max(minButtonSize, buttonHeight); + + // Keep some proportionality - don't let aspect ratio get too extreme + const buttonAspectRatio = buttonWidth / buttonHeight; + if (buttonAspectRatio > 1.5) { + buttonWidth = floor(buttonHeight * 1.5); + } else if (buttonAspectRatio < 0.67) { + buttonHeight = floor(buttonWidth * 1.5); + } + // Avoid super-tall pads (prefer square or shorter) + buttonHeight = min(buttonHeight, buttonWidth); + + const buttonsAreaHeight = totalRows * buttonHeight; + let topButtonY = screen.height - bottomPadding - buttonsAreaHeight; + topButtonY = max(topButtonY, reservedTop); + + // Calculate vertical track position (to the right of button grid) + const actualGridWidth = buttonsPerRow * buttonWidth; + const verticalTrackX = canFitVerticalTrack ? margin + actualGridWidth + margin : 0; + const verticalTrackY = reservedTop + 2; + const verticalTrackHeight = screen.height - reservedTop - bottomPadding - 4; + + return { + buttonWidth, + buttonHeight, + topButtonY, + totalRows, + buttonsPerRow, + margin, + bottomPadding, + hudReserved, + trackHeight: canFitVerticalTrack ? 0 : trackHeight, // Hide horizontal track if using vertical + trackSpacing: canFitVerticalTrack ? 0 : trackSpacing, + reservedTop, + melodyButtonRect, + midiBadge: badgeMetrics, + compactMode: false, + sidePanelWidth: reservedSide, + usableWidth, + miniInputsEnabled: showMiniInputs, + miniInputsHorizontal: horizontalSpaceForMini && showMiniInputs, + miniInputsVertical: narrowVerticalSpace && showMiniInputs && !horizontalSpaceForMini, + // Vertical track (for single-column with space) + verticalTrack: canFitVerticalTrack, + verticalTrackX, + verticalTrackY, + verticalTrackWidth, + verticalTrackHeight, + }; +} + +function getCachedLayout( + screen, + { + songMode, + pictureOverlay, + rateLabel, + rateText, + sidePanelWidth, + }, +) { + const key = [ + screen.width, + screen.height, + songMode ? 1 : 0, + pictureOverlay ? 1 : 0, + rateLabel || "", + rateText || "", + sidePanelWidth || 0, + miniInputsEnabled ? 1 : 0, + ].join("|"); + + if (layoutCache.key === key && layoutCache.layout && layoutCache.midiMetrics) { + return layoutCache; + } + + const midiMetrics = computeMidiBadgeMetrics( + screen, + resolveMatrixGlyphMetrics(), + false, + rateLabel, + rateText, + ); + const layout = getButtonLayoutMetrics(screen, { + songMode, + pictureOverlay, + midiMetrics, + rateLabel, + rateText, + sidePanelWidth, + }); + + layoutCache = { + key, + layout, + midiMetrics, + }; + + return layoutCache; +} + +function buildPadsBase({ api, screen, layout, matrixGlyphMetrics, num }) { + if (!api?.painting) return null; + + const gridTop = Math.round(layout.topButtonY); + const gridHeight = Math.round(layout.buttonHeight * layout.totalRows); + + // Create bitmap only for the button grid area - boxes + grid only, no text + const painting = api.painting(screen.width, gridHeight, (p) => { + // Start fully transparent - main bg shows through + p.wipe(0, 0, 0, 0); + + const gridAlpha = 40; + const topEdge = 0; + const bottomEdge = gridHeight; + + // Grid lines + if (!layout.splitLayout) { + const gridWidth = layout.buttonsPerRow * layout.buttonWidth; + const availableWidth = layout.usableWidth ?? screen.width; + // Left-align grid when mini inputs are to the right, otherwise center + const baseX = layout.miniInputsHorizontal + ? layout.margin + : Math.ceil( + layout.margin + + Math.max(0, Math.floor((availableWidth - layout.margin * 2 - gridWidth) / 2)), + ); + const leftEdge = baseX; + const rightEdge = Math.round(baseX + gridWidth); + + const gridColor = globalThis.__theme?.dark === false ? [0, 0, 0] : [255, 255, 255]; + for (let col = 0; col <= layout.buttonsPerRow; col += 1) { + const x = Math.round(baseX + col * layout.buttonWidth); + p.ink(...gridColor, gridAlpha).line(x, topEdge, x, bottomEdge); + } + + for (let row = 0; row <= layout.totalRows; row += 1) { + const y = Math.round(row * layout.buttonHeight); + p.ink(...gridColor, gridAlpha).line(leftEdge, y, rightEdge, y); + } + } + + // Colored pad boxes (no text - text drawn live) + buttonNotes.forEach((note) => { + const btn = buttons[note]; + if (!btn) return; + + const outOctave = + parseInt(octave) + + (note.startsWith("+") ? upperOctaveShift : lowerOctaveShift); + const baseColor = colorFromNote(note, num, outOctave); + const isSemitone = note.includes("#"); + // Semitones get darker for clear visual distinction from white notes + const tinted = isSemitone ? darkenColor(baseColor, 0.45) : baseColor; + + // Offset box Y by gridTop since painting starts at gridTop + const relBox = { ...btn.box, y: btn.box.y - gridTop }; + p.ink(tinted, 196).box(relBox); + const olColor = globalThis.__theme?.dark === false ? [140, 150, 160] : [90, 110, 120]; + p.ink(...olColor).box(relBox, "outline"); + }); + }); + + return { painting, gridTop }; +} + +// 🎩👩 Piano Man + Sample Sally — two pixel-art characters that sit +// in a thin band above the piano keys and watch the notes pressed +// under them. Each character's pupils lerp toward the x-center of +// whichever active key sits in their half; pupils recenter when +// nothing is pressed in that half. Drawn after the piano key loop so +// the characters render on top of the piano if vertical clearance is +// tight, with semi-translucent fills so keys stay visible underneath. +function paintCharacters({ ink }) { + // Compute the piano's actual bounding box from the visible buttons. + let pianoLeft = Infinity; + let pianoRight = -Infinity; + let pianoTop = Infinity; + for (const note in buttons) { + const btn = buttons[note]; + if (!btn?.box || btn.hidden) continue; + if (btn.box.x < pianoLeft) pianoLeft = btn.box.x; + if (btn.box.x + btn.box.w > pianoRight) pianoRight = btn.box.x + btn.box.w; + if (btn.box.y < pianoTop) pianoTop = btn.box.y; + } + if (!isFinite(pianoLeft) || !isFinite(pianoTop)) return; + + const pianoWidth = pianoRight - pianoLeft; + if (pianoWidth < 80) return; // too narrow to host two characters + const pianoMidX = pianoLeft + pianoWidth / 2; + + // 14px tall character heads, centered ~9px above the topmost key + // when there's clearance, otherwise overlapping the top of the keys. + const HEAD_W = 18; + const HEAD_H = 14; + const charY = Math.max(2, pianoTop - HEAD_H - 2); + const pianoManCX = Math.round(pianoLeft + pianoWidth * 0.25); + const sampleSallyCX = Math.round(pianoLeft + pianoWidth * 0.75); + + // Find the most-recently-pressed active note in each half, so the + // characters track the leading edge of a chord rather than averaging. + let pianoManTargetX = null; + let sampleSallyTargetX = null; + let pianoManMostRecent = -Infinity; + let sampleSallyMostRecent = -Infinity; + for (const note in sounds) { + const entry = sounds[note]; + const btn = buttons[note]; + if (!btn?.box) continue; + const noteCenterX = btn.box.x + btn.box.w / 2; + const startedAt = entry?.sound?.startedAt ?? 0; + if (noteCenterX < pianoMidX) { + if (startedAt > pianoManMostRecent) { + pianoManMostRecent = startedAt; + pianoManTargetX = noteCenterX; + } + } else { + if (startedAt > sampleSallyMostRecent) { + sampleSallyMostRecent = startedAt; + sampleSallyTargetX = noteCenterX; + } + } + } + + // Map an absolute target x into a normalized 0..1 pupil position + // within the character's eye-track range. Range = the character's + // half of the piano so pupils always have headroom to track edges. + const halfWidth = pianoWidth / 2; + const targetForChar = (charCX, abs) => { + if (abs == null) return 0.5; + const minX = charCX - halfWidth / 2; + const t = (abs - minX) / halfWidth; + return Math.max(0, Math.min(1, t)); + }; + + const pmTarget = targetForChar(pianoManCX, pianoManTargetX); + const ssTarget = targetForChar(sampleSallyCX, sampleSallyTargetX); + + pianoManPupilX += (pmTarget - pianoManPupilX) * CHARACTER_EYE_LERP; + sampleSallyPupilX += (ssTarget - sampleSallyPupilX) * CHARACTER_EYE_LERP; + + // Draw both characters. Each has a head, two eye whites, two + // pupils that move within the eye whites, and a mouth. + drawPianoMan(ink, pianoManCX - HEAD_W / 2, charY, HEAD_W, HEAD_H, pianoManPupilX); + drawSampleSally(ink, sampleSallyCX - HEAD_W / 2, charY, HEAD_W, HEAD_H, sampleSallyPupilX); +} + +function drawPianoMan(ink, x, y, w, h, pupilX) { + // Head: warm tan with a black bowler hat band on top. + ink(60, 50, 70, 200).box(x, y, w, h); // hatband shadow + ink(220, 195, 165, 230).box(x + 1, y + 4, w - 2, h - 4); // face + ink(40, 30, 45, 240).box(x, y, w, 4); // hat top + ink(40, 30, 45, 240).box(x - 1, y + 3, w + 2, 1); // hat brim + // Eye whites (3x3 each) + const eyeY = y + 6; + const leftEyeX = x + 3; + const rightEyeX = x + w - 6; + ink(255, 255, 245, 240).box(leftEyeX, eyeY, 3, 3); + ink(255, 255, 245, 240).box(rightEyeX, eyeY, 3, 3); + // Pupils (1x1, move within the 3x3 eye white) + const pupilOffset = Math.round(pupilX * 2); + ink(20, 15, 25, 255).box(leftEyeX + pupilOffset, eyeY + 1, 1, 1); + ink(20, 15, 25, 255).box(rightEyeX + pupilOffset, eyeY + 1, 1, 1); + // Mustache (a horizontal line under the eyes) + ink(40, 30, 45, 220).box(x + 4, y + 11, w - 8, 1); +} + +function drawSampleSally(ink, x, y, w, h, pupilX) { + // Head: pinker tone with brown wavy hair on top. + ink(110, 70, 50, 240).box(x, y, w, 3); // hair top row + ink(110, 70, 50, 240).box(x - 1, y + 1, 1, 3); // hair left side + ink(110, 70, 50, 240).box(x + w, y + 1, 1, 3); // hair right side + ink(240, 200, 195, 230).box(x + 1, y + 3, w - 2, h - 3); // face + // Hair fringe (couple of dark pixels between hairline and forehead) + ink(110, 70, 50, 200).box(x + 3, y + 3, 1, 1); + ink(110, 70, 50, 200).box(x + w - 4, y + 3, 1, 1); + // Eye whites + const eyeY = y + 6; + const leftEyeX = x + 3; + const rightEyeX = x + w - 6; + ink(255, 255, 245, 240).box(leftEyeX, eyeY, 3, 3); + ink(255, 255, 245, 240).box(rightEyeX, eyeY, 3, 3); + // Pupils (1x1, move within the 3x3 eye white) + const pupilOffset = Math.round(pupilX * 2); + ink(30, 20, 60, 255).box(leftEyeX + pupilOffset, eyeY + 1, 1, 1); + ink(30, 20, 60, 255).box(rightEyeX + pupilOffset, eyeY + 1, 1, 1); + // Smile (small mouth) + ink(170, 60, 90, 230).box(x + w / 2 - 1, y + 11, 2, 1); +} + +function paint({ + wipe, + ink, + write, + screen, + box, + line, + plot, + sound, + typeface, + help, + num, + layer, + paste, + page, + api, + paintCount, + zoom, + blur, + scroll, + sharpen +}) { + const paintStart = performance.now(); + + // Hover state (for newbie-friendly overlays) + let hoveredNote = null; + let hoveredKeyLabel = null; + let hoveredNoteColor = null; + + // Track FPS - optimized to reduce calculations + const lastTimestamp = perfStats.lastFrameTimestamp; + if (lastTimestamp > 0) { + const delta = paintStart - lastTimestamp; + // Only update FPS if at least 1ms has passed (prevents absurd values from rapid calls) + if (delta >= 1) { + perfStats.frameTime = delta; + const instantFps = Math.min(1000 / delta, 240); // Cap at 240 FPS for display + const frameCount = perfStats.fpsFrameCount || 0; + // Quick ramp up for first 10 frames, then smooth + const smoothing = frameCount < 10 ? 0.5 : 0.8; + perfStats.fps = perfStats.fps === 0 ? instantFps : perfStats.fps * smoothing + instantFps * (1 - smoothing); + perfStats.fpsFrameCount = frameCount + 1; + } + } + perfStats.lastFrameTimestamp = paintStart; + + const active = orderedByCount(sounds); + const scopeSamples = Math.max(1, Math.floor(scope || 1)); + const rawWaveformsLeft = sound.speaker?.waveforms?.left; + const waveformsAvailable = + rawWaveformsLeft && + typeof rawWaveformsLeft.length === "number" && + rawWaveformsLeft.length > 0; + const amplitudeRaw = sound.speaker?.amplitudes?.left; + const amplitude = + typeof amplitudeRaw === "number" && Number.isFinite(amplitudeRaw) + ? amplitudeRaw + : 0; + const needsWaveforms = + visualizerFullscreen || projector || paintPictureOverlay || active.length > 0 || amplitude > 0.0005; + let sanitizedWaveforms = []; + if (waveformsAvailable && needsWaveforms) { + if ( + waveformsCache.source !== rawWaveformsLeft || + waveformsCache.scopeSamples !== scopeSamples + ) { + const resampled = help.resampleArray(rawWaveformsLeft, scopeSamples); + sanitizedWaveforms = resampled.map((value) => + typeof value === "number" && Number.isFinite(value) ? value : 0, + ); + waveformsCache = { + source: rawWaveformsLeft, + scopeSamples, + resampled, + sanitized: sanitizedWaveforms, + }; + } else { + sanitizedWaveforms = waveformsCache.sanitized || []; + } + } + if (zeroWaveformsCache.length !== scopeSamples) { + zeroWaveformsCache = { + length: scopeSamples, + buffer: new Array(scopeSamples).fill(0), + }; + } + const waveformsForBars = + sanitizedWaveforms.length > 0 + ? sanitizedWaveforms + : zeroWaveformsCache.buffer; + const waveformsForOverlay = sanitizedWaveforms.length > 0 ? sanitizedWaveforms : []; + const audioReady = + waveformsAvailable && + typeof amplitudeRaw === "number" && + Number.isFinite(amplitudeRaw); + const matrixGlyphMetrics = resolveMatrixGlyphMetrics(typeface); + // Use global theme for background when no note is active + const theme = globalThis.__theme; + if (theme) theme.update(); + const isDark = theme ? theme.dark : true; + + let bg; + const latestNote = active.length > 0 ? active[active.length - 1] : null; + const activeBg = latestNote + ? darkenColor(colorFromNote(latestNote, num), tap ? 0.55 : 0.45) + : null; + + const defaultBg = isDark ? [0, 0, 180] : [220, 225, 235]; + + if (!tap) { + bg = activeBg ?? (isDark ? [50, 50, 255] : [200, 210, 230]); + + if (perc && !activeBg) { + bg = perc; + perc = null; + } + } else { + bg = activeBg ?? defaultBg; + } + + if (paintPictureOverlay && !overlayMode) { + wipe(isDark ? 0 : 255); + + if (active.length === 0) { + page(picture); + wipe(0, 0); + page(screen); + } + + pictureLines( + { + page, + ink, + wipe, + screen, + box, + blur, + line, + plot, + num, + paintCount, + sound, + scroll, + }, + { + amplitude, + waveforms: waveformsForOverlay, + audioReady, + }, + ); + + page(picture); + page(screen); + + paste(picture, 0, 0, { width: screen.width, height: screen.height }); + + return; + } + + // 🎭 Recital mode color scheme - dynamically set by active note + // Get the first active note to determine the wireframe color + const activeNoteKeys = Object.keys(sounds); + let recitalColor = [0, 255, 220]; // Default cyan wireframe color + let recitalDim = [0, 120, 100]; // Dimmer version for inactive + + if (recitalMode && activeNoteKeys.length > 0) { + // Use the most recent/first active note's color + const activeNote = activeNoteKeys[0]; + const parsed = parseNotepatNote(activeNote); + if (parsed) { + const noteColor = getNoteColorWithOctave(parsed.baseNote, parsed.octave); + // Brighten the color for wireframe visibility + recitalColor = [ + Math.min(255, noteColor[0] + 80), + Math.min(255, noteColor[1] + 80), + Math.min(255, noteColor[2] + 80), + ]; + // Create dim version + recitalDim = [ + Math.floor(recitalColor[0] * 0.5), + Math.floor(recitalColor[1] * 0.5), + Math.floor(recitalColor[2] * 0.5), + ]; + } + } + + // 🎨 KidLisp background: forward amplitude + render + if (kidlispBgEnabled) { + const freqs = sound.speaker?.frequencies?.left; + const freqBand = (name) => { + const b = freqs?.find?.((f) => f.name === name); + return b ? b.amplitude : 0; + }; + const ampRight = sound.speaker?.amplitudes?.right; + const klAmp = amplitude * 10; + api.updateKidLispAudio({ + amp: klAmp, + leftAmp: klAmp, + rightAmp: (typeof ampRight === "number" && Number.isFinite(ampRight) ? ampRight : 0) * 10, + kick: sound.speaker?.beat?.detected ? 1 : 0, + beat: sound.speaker?.beat?.detected ? 1 : 0, + bass: freqBand("subBass") + freqBand("lowMid"), + mid: freqBand("mid"), + treble: freqBand("treble"), + highMid: freqBand("highMid"), + presence: freqBand("presence"), + }); + if (paintCount % 60 === 0) console.log("🎵 KL audio:", { amp: klAmp, raw: amplitude, speakerLeft: sound.speaker?.amplitudes?.left }); + } + + // Compute layout early for kidlisp bounds (cached, no extra cost on second call) + const earlySampleRateText = getSampleRateText(sound?.sampleRate); + const earlyLayout = getCachedLayout(screen, { + songMode: Boolean(song), + pictureOverlay: paintPictureOverlay, + rateText: earlySampleRateText, + rateLabel: earlySampleRateText ? MIDI_RATE_LABEL_TEXT : null, + sidePanelWidth: autopatConfig.sidePanelWidth, + }).layout; + + // 🪟 Overlay mode: wipe to fully transparent so the electron window's + // transparency lets the desktop show through between keys. Skip the + // other branches (kidlisp viz, fullscreen visualizer, recital) — those + // paint a fullscreen base that would obscure whatever is underneath. + if (overlayMode) { + wipe(0, 0, 0, 0); + } else if (kidlispBgEnabled && kidlispBackground && !paintPictureOverlay) { + wipe(bg); // Base background first + const klY = OS_BAR_BOTTOM; + const klBottom = earlyLayout.topButtonY; + const klH = klBottom - klY; + if (klH > 10) { + let klX, klW; + if (earlyLayout.splitLayout && earlyLayout.centerAreaWidth >= 20) { + klX = earlyLayout.centerX; + klW = earlyLayout.centerAreaWidth; + } else { + klX = 0; + klW = screen.width; + } + api.kidlisp(klX, klY, klW, klH, kidlispBackground); + } + } else if (visualizerFullscreen && !recitalMode) { + wipe(isDark ? 0 : 255); + sound.paint.bars( + api, + amplitude, + waveformsForBars, + 0, + 0, + screen.width, + screen.height, + [255, 0, 0, 255], + { primaryColor, secondaryColor }, + ); + } else if (recitalMode) { + wipe(isDark ? 0 : 255); + // Draw fullscreen visualizer behind wireframe UI + sound.paint.bars( + api, + amplitude, + waveformsForBars, + 0, + 0, + screen.width, + screen.height, + [255, 0, 0, 255], + { primaryColor: recitalColor, secondaryColor: recitalDim }, + ); + // Update blink phase for back button + recitalBlinkPhase = (recitalBlinkPhase + 0.05) % (Math.PI * 2); + } else { + wipe(bg); + } + + // 🥁 Draw peripheral screen flash for metronome beats + if (metronomeFlash > 0 && metronomeEnabled) { + const flashAlpha = Math.floor(metronomeFlash * 120); // Max alpha 120 for subtle effect + const flashWidth = Math.max(3, Math.floor(6 * metronomeFlash)); // Shrinking border width + // Choose color based on beat (downbeat = brighter) + const isDownbeat = (metronomeBeatCount % 4) === 0; + const flashColor = isDownbeat ? [255, 255, 255, flashAlpha] : [180, 200, 255, flashAlpha]; + ink(...flashColor); + // Draw border boxes on all four edges + box(0, 0, screen.width, flashWidth); // Top + box(0, screen.height - flashWidth, screen.width, flashWidth); // Bottom + box(0, 0, flashWidth, screen.height); // Left + box(screen.width - flashWidth, 0, flashWidth, screen.height); // Right + } + + // 🎹 Draw mini piano strip in top bar (not in recital mode or fullscreen modes) + // Store piano end position for visualizer to use + const topBarDefaultX = dotComMode ? 75 : 54; + let topBarPianoEndX = topBarDefaultX; // Default if piano not shown + if (!recitalMode && !visualizerFullscreen && !paintPictureOverlay && !projector) { + const metrics = getTopBarPianoMetrics(screen); + if (metrics && !metrics.hidden) { + const { + mode, + x: topPianoStartX, + y: topPianoY, + height: topPianoHeight, + whiteKeys, + blackKeys, + whiteKeyWidth: topPianoWhiteKeyWidth, + blackKeyWidth: topPianoBlackKeyWidth, + blackKeyHeight: topPianoBlackKeyHeight, + } = metrics; + const topPianoStripHeight = 2; + topBarPianoEndX = topPianoStartX + whiteKeys.length * topPianoWhiteKeyWidth; + const isCompact = mode === "compact"; + + // Pre-calculate common values + const whiteKeyBottom = topPianoY + topPianoHeight - topPianoStripHeight; + const blackKeyBottom = topPianoY + topPianoBlackKeyHeight - topPianoStripHeight; + const whiteKeyW = topPianoWhiteKeyWidth - 1; + + // Draw white keys - use for loop instead of forEach for performance + const whiteKeyCount = whiteKeys.length; + for (let i = 0; i < whiteKeyCount; i++) { + const note = whiteKeys[i]; + const x = topPianoStartX + i * topPianoWhiteKeyWidth; + const noteKey = note.toLowerCase(); + const hasLower = sounds[noteKey] !== undefined; + const hasUpper = sounds[`+${noteKey}`] !== undefined; + const isActivePlaying = hasLower || hasUpper; + + // Use cached color lookup + const baseFill = isActivePlaying ? [215, 225, 230] : [195, 205, 210]; + ink(baseFill[0], baseFill[1], baseFill[2]).box(x, topPianoY, whiteKeyW, topPianoHeight); + + const stripBase = getCachedColor(noteKey, num); + if (isActivePlaying) { + ink(Math.min(255, stripBase[0] + 40), Math.min(255, stripBase[1] + 40), Math.min(255, stripBase[2] + 40)).box(x, whiteKeyBottom, whiteKeyW, topPianoStripHeight); + } else { + ink(stripBase[0], stripBase[1], stripBase[2]).box(x, whiteKeyBottom, whiteKeyW, topPianoStripHeight); + } + if (isCompact && hasUpper) { + ink(255, 255, 255, 160).box(x, topPianoY + 1, whiteKeyW, 1); + } + + ink(90, 110, 120, 80).box(x, topPianoY, whiteKeyW, topPianoHeight, "outline"); + } + + // Draw black keys - use for loop + const blackKeyCount = blackKeys.length; + for (let i = 0; i < blackKeyCount; i++) { + const { note, afterWhite } = blackKeys[i]; + const x = topPianoStartX + afterWhite * topPianoWhiteKeyWidth + topPianoWhiteKeyWidth - topPianoBlackKeyWidth / 2; + const noteKey = note.toLowerCase(); + const hasLower = sounds[noteKey] !== undefined; + const hasUpper = sounds[`+${noteKey}`] !== undefined; + const isActivePlaying = hasLower || hasUpper; + + const baseFill = isActivePlaying ? [28, 32, 36] : [0, 0, 0]; + ink(baseFill[0], baseFill[1], baseFill[2]).box(x, topPianoY, topPianoBlackKeyWidth, topPianoBlackKeyHeight); + + const stripBase = getCachedColor(noteKey, num); + if (isActivePlaying) { + ink(Math.min(255, stripBase[0] + 40), Math.min(255, stripBase[1] + 40), Math.min(255, stripBase[2] + 40)).box(x, blackKeyBottom, topPianoBlackKeyWidth, topPianoStripHeight); + } else { + ink(stripBase[0], stripBase[1], stripBase[2]).box(x, blackKeyBottom, topPianoBlackKeyWidth, topPianoStripHeight); + } + if (isCompact && hasUpper) { + ink(255, 255, 255, 160).box(x, topPianoY + 1, topPianoBlackKeyWidth, 1); + } + } + } + } + + const sampleRateText = getSampleRateText(sound?.sampleRate); + const sampleRateLabel = sampleRateText ? MIDI_RATE_LABEL_TEXT : null; + const showTrack = Boolean(song) && autopatConfig.showTrack !== false; + const hideMidiBadge = screen.width < 240; + + // 🎭 Recital mode: Draw wireframe UI on top of visualizer + if (recitalMode && !paintPictureOverlay && !projector) { + // Draw wireframe top bar (outline only, transparent inside) + ink(...recitalColor, 80).box(0, 0, screen.width, TOP_BAR_BOTTOM, "outline"); + // Horizontal line under top bar + ink(...recitalColor, 60).line(0, TOP_BAR_BOTTOM - 1, screen.width, TOP_BAR_BOTTOM - 1); + + // Draw wireframe secondary bar + ink(...recitalColor, 60).box(0, SECONDARY_BAR_TOP, screen.width, SECONDARY_BAR_HEIGHT, "outline"); + + // Wireframe toggle buttons in recital mode + slideBtn?.paint((btn) => { + const active = slide; + ink(...(active ? recitalColor : recitalDim), active ? 180 : 80).box(btn.box, "outline"); + ink(...(active ? recitalColor : recitalDim), active ? 200 : 100).write(secondaryBarLabels.slide, { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + roomBtn?.paint((btn) => { + const active = roomMode; + ink(...(active ? recitalColor : recitalDim), active ? 180 : 80).box(btn.box, "outline"); + ink(...(active ? recitalColor : recitalDim), active ? 200 : 100).write(secondaryBarLabels.room, { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + glitchBtn?.paint((btn) => { + const active = glitchMode; + ink(...(active ? recitalColor : recitalDim), active ? 180 : 80).box(btn.box, "outline"); + ink(...(active ? recitalColor : recitalDim), active ? 200 : 100).write(secondaryBarLabels.glitch, { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + quickBtn?.paint((btn) => { + const active = quickFade; + ink(...(active ? recitalColor : recitalDim), active ? 180 : 80).box(btn.box, "outline"); + ink(...(active ? recitalColor : recitalDim), active ? 200 : 100).write(secondaryBarLabels.quick, { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + // 🥁 Wireframe metronome controls in recital mode + bpmMinusBtn?.paint((btn) => { + ink(...recitalDim, 80).box(btn.box, "outline"); + ink(...recitalDim, 100).write("-", { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + // BPM display IS the toggle button now + metroBtn?.paint((btn) => { + const bpmText = metronomeBPM.toString().padStart(3, " "); + const active = metronomeEnabled; + // Flash on beat when enabled + const flashAlpha = active && metronomeVisualPhase > 0.5 ? 255 : (active ? 180 : 80); + ink(...(active ? recitalColor : recitalDim), flashAlpha).box(btn.box, "outline"); + ink(...(active ? recitalColor : recitalDim), active ? 200 : 100).write(bpmText, { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + bpmPlusBtn?.paint((btn) => { + ink(...recitalDim, 80).box(btn.box, "outline"); + ink(...recitalDim, 100).write("+", { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + // 🔙 Blinking "BACK" text - drawn LAST so it's on top of everything + const blinkAlpha = Math.floor(180 + Math.sin(recitalBlinkPhase * 3) * 75); + ink(...recitalColor, blinkAlpha).write("< BACK", { x: 6, y: 6 }, undefined, undefined, false, "MatrixChunky8"); + } + + // 🎛️ Draw secondary top bar with toggle buttons (normal mode) + if (!paintPictureOverlay && !projector && !visualizerFullscreen && !recitalMode) { + // Draw segmented secondary bar backgrounds with distinct colors + // Calculate segment boundaries + const hideMidiBadge = screen.width < 240; + const midiBadgeEnd = hideMidiBadge + ? 0 + : MIDI_BADGE_MARGIN + (computeMidiBadgeTopMetrics(screen, matrixGlyphMetrics, sampleRateLabel, sampleRateText)?.width || 60) + 2; + const metroStart = bpmMinusBtn?.box?.x ?? midiBadgeEnd + 40; + const toggleStart = slideBtn?.box?.x ?? screen.width - 80; + + // Segment 1: MIDI badge area (dark blue tint) + if (!hideMidiBadge) { + ink(15, 20, 35, 180).box(0, SECONDARY_BAR_TOP, midiBadgeEnd, SECONDARY_BAR_HEIGHT); + } + + // Segment 2: Content area - stream & notes (dark neutral) + ink(20, 20, 25, 160).box(midiBadgeEnd, SECONDARY_BAR_TOP, metroStart - midiBadgeEnd, SECONDARY_BAR_HEIGHT); + + // Segment 3: Metronome controls (dark warm/red tint) + ink(30, 18, 18, 180).box(metroStart, SECONDARY_BAR_TOP, toggleStart - metroStart, SECONDARY_BAR_HEIGHT); + + // Segment 4: Toggle buttons (dark purple tint) + ink(25, 18, 30, 180).box(toggleStart, SECONDARY_BAR_TOP, screen.width - toggleStart, SECONDARY_BAR_HEIGHT); + + // Paint slide button + slideBtn?.paint((btn) => { + const base = [0, 220, 140]; + let bgR, bgG, bgB, bgA, olR, olG, olB, olA; + if (slide) { + bgR = base[0]; bgG = base[1]; bgB = base[2]; bgA = 230; + olR = 0; olG = 255; olB = 170; olA = 255; + } else { + bgR = Math.round(base[0] * 0.3); bgG = Math.round(base[1] * 0.3); bgB = Math.round(base[2] * 0.3); bgA = 170; + olR = 70; olG = 90; olB = 80; olA = 180; + } + const textColor = slide ? "white" : [180, 190, 200]; + ink(bgR, bgG, bgB, bgA).box(btn.box); + ink(olR, olG, olB, olA).box(btn.box, "outline"); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(btn.box); + ink(0, 255, 200, 140).box(btn.box, "outline"); + } + ink(textColor).write(secondaryBarLabels.slide, { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + // Paint room button + roomBtn?.paint((btn) => { + const base = [150, 110, 255]; + let bgR, bgG, bgB, bgA, olR, olG, olB, olA; + if (roomMode) { + bgR = base[0]; bgG = base[1]; bgB = base[2]; bgA = 230; + olR = 190; olG = 160; olB = 255; olA = 255; + } else { + bgR = Math.round(base[0] * 0.3); bgG = Math.round(base[1] * 0.3); bgB = Math.round(base[2] * 0.3); bgA = 170; + olR = 90; olG = 80; olB = 120; olA = 180; + } + const textColor = roomMode ? "white" : [180, 190, 200]; + ink(bgR, bgG, bgB, bgA).box(btn.box); + ink(olR, olG, olB, olA).box(btn.box, "outline"); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(btn.box); + ink(190, 160, 255, 140).box(btn.box, "outline"); + } + ink(textColor).write(secondaryBarLabels.room, { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + // 🎚️ Room parameter bar - appears when room mode is enabled + if (roomMode && roomBtn?.box) { + const barHeight = 6; + const barY = OS_BAR_BOTTOM + 2; + const barX = roomBtn.box.x; + const barWidth = Math.max(40, roomBtn.box.w * 2); + const fillWidth = Math.floor(barWidth * roomAmount); + + // Background + ink(30, 25, 45, 200).box(barX, barY, barWidth, barHeight); + // Fill based on room amount + ink(150, 110, 255, 220).box(barX, barY, fillWidth, barHeight); + // Border + ink(100, 80, 160, 180).box(barX, barY, barWidth, barHeight, "outline"); + // Label + ink(180, 160, 220).write(Math.round(roomAmount * 100) + "%", { x: barX + barWidth + 3, y: barY - 1 }, undefined, undefined, false, "MatrixChunky8"); + } + + // Paint glitch button + glitchBtn?.paint((btn) => { + const base = [255, 80, 160]; + let bgR, bgG, bgB, bgA, olR, olG, olB, olA; + if (glitchMode) { + bgR = base[0]; bgG = base[1]; bgB = base[2]; bgA = 230; + olR = 255; olG = 140; olB = 210; olA = 255; + } else { + bgR = Math.round(base[0] * 0.3); bgG = Math.round(base[1] * 0.3); bgB = Math.round(base[2] * 0.3); bgA = 170; + olR = 110; olG = 70; olB = 100; olA = 180; + } + const textColor = glitchMode ? "white" : [180, 190, 200]; + ink(bgR, bgG, bgB, bgA).box(btn.box); + ink(olR, olG, olB, olA).box(btn.box, "outline"); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(btn.box); + ink(255, 140, 210, 140).box(btn.box, "outline"); + } + ink(textColor).write(secondaryBarLabels.glitch, { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + // Paint quick button + quickBtn?.paint((btn) => { + const base = [255, 170, 0]; + let bgR, bgG, bgB, bgA, olR, olG, olB, olA; + if (quickFade) { + bgR = base[0]; bgG = base[1]; bgB = base[2]; bgA = 230; + olR = 255; olG = 200; olB = 80; olA = 255; + } else { + bgR = Math.round(base[0] * 0.3); bgG = Math.round(base[1] * 0.3); bgB = Math.round(base[2] * 0.3); bgA = 170; + olR = 120; olG = 100; olB = 60; olA = 180; + } + const textColor = quickFade ? "white" : [180, 190, 200]; + ink(bgR, bgG, bgB, bgA).box(btn.box); + ink(olR, olG, olB, olA).box(btn.box, "outline"); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(btn.box); + ink(255, 200, 80, 140).box(btn.box, "outline"); + } + ink(textColor).write(secondaryBarLabels.quick, { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + // 🥁 Paint metronome controls: [-] [BPM] [+] [metro] + const metroBase = [255, 100, 100]; // Red-ish for metronome + + // Paint BPM minus button (RED color coded) + bpmMinusBtn?.paint((btn) => { + let bgR, bgG, bgB, bgA, olR, olG, olB, olA; + if (btn.down) { + bgR = 220; bgG = 60; bgB = 60; bgA = 230; + olR = 255; olG = 100; olB = 100; olA = 255; + } else { + bgR = 140; bgG = 40; bgB = 40; bgA = 200; + olR = 180; olG = 60; olB = 60; olA = 200; + } + const textColor = btn.down ? "white" : [255, 120, 120]; + ink(bgR, bgG, bgB, bgA).box(btn.box); + ink(olR, olG, olB, olA).box(btn.box, "outline"); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(btn.box); + ink(255, 120, 120, 140).box(btn.box, "outline"); + } + ink(textColor).write("-", { x: btn.box.x + TOGGLE_BTN_PADDING_X + 1, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + // BPM display IS the toggle button now - clicking it toggles metronome on/off (BLACK/WHITE themed) + metroBtn?.paint((btn) => { + const bpmText = metronomeBPM.toString().padStart(3, " "); + const bounceY = metronomeEnabled ? Math.round(Math.sin(metronomeVisualPhase * Math.PI * 2) * 2) : 0; + const textY = btn.box.y + TOGGLE_BTN_PADDING_Y + (bounceY < 0 ? bounceY : 0); + const bx = btn.box.x; + const by = btn.box.y + bounceY; + const bw = btn.box.w; + const bh = btn.box.h; + // Flash background on beat when metronome is enabled + const flashAlpha = metronomeEnabled ? Math.floor(40 + metronomeVisualPhase * 100) : 0; + // Black/white theme for BPM value + let bgR, bgG, bgB, bgA, olR, olG, olB, olA; + if (metronomeEnabled) { + bgR = 40; bgG = 40; bgB = 40; bgA = 230; + olR = 255; olG = 255; olB = 255; olA = 255; + } else { + bgR = 20; bgG = 20; bgB = 20; bgA = 200; + olR = 80; olG = 80; olB = 80; olA = 180; + } + ink(bgR, bgG, bgB, bgA).box(bx, by, bw, bh); + if (metronomeEnabled && metronomeVisualPhase > 0) { + ink(255, 255, 255, flashAlpha).box(bx, by, bw, bh); + } + ink(olR, olG, olB, olA).box(bx, by, bw, bh, "outline"); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(bx, by, bw, bh); + ink(200, 220, 255, 140).box(bx, by, bw, bh, "outline"); + } + // Flash the button on beat + if (metronomeEnabled && metronomeVisualPhase > 0.5) { + ink(255, 255, 255, Math.floor(metronomeVisualPhase * 60)).box(bx, by, bw, bh); + } + ink(metronomeEnabled ? "white" : [160, 160, 160]).write( + bpmText, + { x: bx + TOGGLE_BTN_PADDING_X, y: textY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + }); + + // Paint BPM plus button (GREEN color coded) + bpmPlusBtn?.paint((btn) => { + let bgR, bgG, bgB, bgA, olR, olG, olB, olA; + if (btn.down) { + bgR = 60; bgG = 200; bgB = 80; bgA = 230; + olR = 100; olG = 255; olB = 120; olA = 255; + } else { + bgR = 40; bgG = 120; bgB = 50; bgA = 200; + olR = 60; olG = 160; olB = 70; olA = 200; + } + const textColor = btn.down ? "white" : [120, 255, 140]; + ink(bgR, bgG, bgB, bgA).box(btn.box); + ink(olR, olG, olB, olA).box(btn.box, "outline"); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(btn.box); + ink(120, 255, 140, 140).box(btn.box, "outline"); + } + ink(textColor).write("+", { x: btn.box.x + TOGGLE_BTN_PADDING_X + 2, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, undefined, undefined, false, "MatrixChunky8"); + }); + + // 🏀 Draw bouncing ball animation when metronome is running + if (metronomeEnabled && bpmPlusBtn?.box && slideBtn?.box) { + const ballTrackStartX = bpmPlusBtn.box.x + bpmPlusBtn.box.w + 4; + const ballTrackEndX = slideBtn.box.x - 4; + const ballTrackWidth = ballTrackEndX - ballTrackStartX; + + if (ballTrackWidth > 16) { + const ballY = SECONDARY_BAR_TOP + Math.floor(SECONDARY_BAR_HEIGHT / 2); + const ballRadius = 3; + // Ball bounces from left to right based on metronomeBallPos (0-1) + const ballX = ballTrackStartX + metronomeBallPos * ballTrackWidth; + + // Draw distinct background for metronome bounce area + ink(35, 25, 45, 150).box(ballTrackStartX - 2, SECONDARY_BAR_TOP + 1, ballTrackWidth + 4, SECONDARY_BAR_HEIGHT - 2); + + // Draw faint track line + ink(80, 80, 80, 100).line(ballTrackStartX, ballY, ballTrackEndX, ballY); + + // Draw the bouncing ball with glow effect + const flashIntensity = metronomeVisualPhase; + const ballFloorX = Math.floor(ballX); + let ballR, ballG, ballB, ballA, glowR, glowG, glowB, glowA; + if (flashIntensity > 0.5) { + ballR = 255; ballG = 255; ballB = 255; ballA = 255; + glowR = 255; glowG = 255; glowB = 255; glowA = Math.floor(80 + flashIntensity * 100); + } else { + ballR = 200; ballG = 200; ballB = 255; ballA = 220; + glowR = 150; glowG = 150; glowB = 255; glowA = 60; + } + + // Glow + ink(glowR, glowG, glowB, glowA).box(ballFloorX - ballRadius - 1, ballY - ballRadius - 1, ballRadius * 2 + 3, ballRadius * 2 + 3); + // Ball + ink(ballR, ballG, ballB, ballA).box(ballFloorX - ballRadius, ballY - ballRadius, ballRadius * 2 + 1, ballRadius * 2 + 1); + } + } + + // 🔌🎹 Draw USB/MIDI/sample-rate badge on the left side of the mini bar + const topMidiMetrics = hideMidiBadge + ? null + : computeMidiBadgeTopMetrics( + screen, + matrixGlyphMetrics, + sampleRateLabel, + sampleRateText, + ); + // Pass metronome button start as maxX to prevent FPS from overlapping + // Ensure maxX is reasonable (at least 80px from left) to avoid cutting off FPS + const bpmBtnX = bpmMinusBtn?.box?.x; + const midiMaxX = (bpmBtnX && bpmBtnX > 80) ? bpmBtnX - 4 : screen.width; + if (!hideMidiBadge) { + drawMidiBadge( + topMidiMetrics, + midiConnected, + sampleRateLabel, + sampleRateText, + midiMaxX, + { api, screenWidth: screen.width }, + ); + } + + const streamLeft = hideMidiBadge + ? MIDI_BADGE_MARGIN + : topMidiMetrics.x + topMidiMetrics.width + 3; + // Stream should stop before metronome buttons (bpmMinusBtn), not the toggle buttons + const streamRight = bpmMinusBtn?.box?.x ? bpmMinusBtn.box.x - 2 : (slideBtn?.box?.x ? slideBtn.box.x - 4 : screen.width - 4); + // Stream (waveform preview) removed from the mini bar + const streamWidth = 0; + + // Hover ghost-note preview for newbies + let hoverPillWidth = 0; + if (hoveredNote) { + const hoverNoteText = isBlackKey(hoveredNote) ? hoveredNote.toLowerCase() : hoveredNote.toUpperCase(); + const keySuffix = hoveredKeyLabel ? ` / ${hoveredKeyLabel}` : ""; + const hoverText = `${hoverNoteText}${keySuffix}`; + const hoverTextW = hoverText.length * matrixGlyphMetrics.width; + const hoverPad = 6; + const hoverMaxW = Math.max(0, streamRight - streamLeft); + const hoverW = Math.min(hoverTextW + hoverPad, hoverMaxW); + if (hoverW > 10) { + const hoverY = SECONDARY_BAR_TOP + Math.floor((SECONDARY_BAR_HEIGHT - 8) / 2); + const hoverColor = hoveredNoteColor || getCachedColor(hoveredNote, num); + const hoverTextColor = getContrastingTextColor(hoverColor); + ink(hoverColor[0], hoverColor[1], hoverColor[2], 200).box(streamLeft, hoverY, hoverW, 8); + ink(hoverColor[0], hoverColor[1], hoverColor[2], 255).box(streamLeft, hoverY, hoverW, 8, "outline"); + ink(hoverTextColor).write( + hoverText, + { x: streamLeft + 3, y: hoverY + 1 }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + hoverPillWidth = hoverW; + } + } + + // 🎵 Chord Detection Display - shown in stream area (between MIDI badge and metronome) + { + const chordStartX = streamLeft + (streamWidth > 0 ? streamWidth + 4 : 0) + (hoverPillWidth > 0 ? hoverPillWidth + 4 : 0); + const chordRight = bpmMinusBtn?.box?.x ? bpmMinusBtn.box.x - 2 : (slideBtn?.box?.x ? slideBtn.box.x - 4 : screen.width - 4); + const chordHeight = 8; + const chordY = SECONDARY_BAR_TOP + Math.floor((SECONDARY_BAR_HEIGHT - chordHeight) / 2); + const activeNotes = orderedByCount(sounds); + if (activeNotes.length > 0 && chordRight - chordStartX > 20) { + const chord = detectChord(activeNotes); + if (chord) { + const chordText = chord.name; + const chordTextW = chordText.length * matrixGlyphMetrics.width; + const chordBoxW = chordTextW + 6; + if (chordStartX + chordBoxW < chordRight) { + const rootNote = chord.root.toLowerCase(); + const chordColor = getCachedColor(rootNote, num); + const chordTextColor = getContrastingTextColor(chordColor); + const chordOutline = darkenColor(chordColor, 0.6); + ink(chordColor[0], chordColor[1], chordColor[2], 240).box(chordStartX, chordY, chordBoxW, chordHeight); + ink(chordOutline[0], chordOutline[1], chordOutline[2], 255).box(chordStartX, chordY, chordBoxW, chordHeight, "outline"); + ink(chordTextColor[0], chordTextColor[1], chordTextColor[2]).write( + chordText, + { x: chordStartX + 3, y: chordY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + } + } + } + } + } + + // wipe(!projector ? bg : 64); + + // QR Code + // if (qrcells) { + // const ox = screen.width - qrcells.length - 34; + // const oy = 0; + // const scale = 1; + // for (let y = 0; y < qrcells.length; y += 1) { + // for (let x = 0; x < qrcells.length; x += 1) { + // const black = qrcells[y][x]; + // ink(!black).box(ox + x * scale, oy + y * scale, scale); + // } + // } + // } + + // Get layout info early for positioning track and piano/qwerty + const cachedLayout = getCachedLayout(screen, { + songMode: Boolean(showTrack), + pictureOverlay: paintPictureOverlay, + rateText: sampleRateText, + rateLabel: sampleRateLabel, + sidePanelWidth: autopatConfig.sidePanelWidth, + }); + const layout = cachedLayout.layout; + + // Song Track View - can be horizontal (top) or vertical (side) + + // TODO: Precompute the full song length with x stops next to indices. + + const useVerticalTrack = showTrack && layout.verticalTrack; + // In single-column mode (non-split), hide horizontal track entirely - only show vertical track if available + const showHorizontalTrack = showTrack && !useVerticalTrack && layout.splitLayout; + const trackHeight = showHorizontalTrack ? TRACK_HEIGHT : 0; + const trackY = showHorizontalTrack ? OS_BAR_BOTTOM : null; + + + if (showHorizontalTrack) { + const glyphWidth = matrixGlyphMetrics.width; + const startX = 6 - songShift; + let i = 0; + + ink(20, 30, 50).box(0, trackY, screen.width, trackHeight); + + while (i < song.length) { + let word = songStops[i][0]; + let x = startX + songStops[i][1]; + + if (x > screen.width) break; + + const current = i === songIndex; + + // Word color (lyrics) - match the cyan palette from the board + if (current) { + const wordColor = songNoteDown ? [180, 200, 210] : "white"; // Soften while holding + ink(wordColor).write(word, x, trackY + 3); + } else { + ink(120, 140, 150).write(word, x, trackY + 3); // Lighter gray-blue + } + + // Note color (musical note) - match the cyan boxes + if (current) { + const noteColor = songNoteDown ? [0, 140, 160] : [0, 180, 200]; // Keep visible while acknowledging hold + ink(noteColor).write(song[i][0], x, trackY + 13); + } else { + ink(80, 100, 120).write(song[i][0], x, trackY + 13); // Darker gray-blue + } + + i += 1; + } + } + + // Vertical track view (scrolls vertically, positioned beside pads) + if (useVerticalTrack && song && layout.verticalTrackWidth > 0) { + const vTrackX = layout.verticalTrackX; + const vTrackY = layout.verticalTrackY; + const vTrackW = layout.verticalTrackWidth; + const vTrackH = layout.verticalTrackHeight; + + // Background + ink(20, 30, 50).box(vTrackX, vTrackY, vTrackW, vTrackH); + + // Calculate vertical scroll offset to keep current item centered + const itemH = VERTICAL_TRACK_ITEM_HEIGHT; + const visibleItems = floor(vTrackH / itemH); + const centerOffset = floor(visibleItems / 2); + const scrollOffset = max(0, songIndex - centerOffset) * itemH; + + let i = 0; + let yPos = vTrackY + 2 - (scrollOffset % itemH); + const startIdx = max(0, songIndex - centerOffset - 1); + i = startIdx; + + while (i < song.length && yPos < vTrackY + vTrackH) { + if (yPos + itemH > vTrackY) { + const word = songStops[i]?.[0] || ""; + const note = song[i]?.[0] || ""; + const current = i === songIndex; + + // Highlight current item + if (current) { + ink(40, 60, 80).box(vTrackX, yPos, vTrackW, itemH - 1); + } + + // Note on left side + if (current) { + const noteColor = songNoteDown ? [0, 140, 160] : [0, 180, 200]; + ink(noteColor).write(note, vTrackX + 2, yPos + 2); + } else { + ink(80, 100, 120).write(note, vTrackX + 2, yPos + 2); + } + + // Word/lyric (truncate if needed) + const maxWordLen = floor((vTrackW - 18) / 6); // Approx char width + const displayWord = word.length > maxWordLen ? word.slice(0, maxWordLen) : word; + if (current) { + const wordColor = songNoteDown ? [180, 200, 210] : "white"; + ink(wordColor).write(displayWord, vTrackX + 16, yPos + 2); + } else { + ink(120, 140, 150).write(displayWord, vTrackX + 16, yPos + 2); + } + } + + yPos += itemH; + i += 1; + } + + // Draw scroll indicator if there are items above or below + if (startIdx > 0) { + ink(100, 120, 140).write("▲", vTrackX + vTrackW - 10, vTrackY + 2); + } + if (i < song.length) { + ink(100, 120, 140).write("▼", vTrackX + vTrackW - 10, vTrackY + vTrackH - 10); + } + } + + const usePadsBase = !song && !projector && !paintPictureOverlay && !recitalMode; + const padsBaseCacheKey = [ + cachedLayout.key, + octave, + upperOctaveShift, + lowerOctaveShift, + recitalMode ? 1 : 0, + ].join("|"); + + if (layout.miniInputsEnabled) { + const pianoGeometry = getMiniPianoGeometry({ + screen, + layout, + song: showTrack ? song : null, + trackY, + trackHeight, + }); + let pianoY = pianoGeometry.y; + let pianoStartX = pianoGeometry.x; + + // Skip main piano drawing - now using top bar piano instead + // Keep pianoGeometry calculations for QWERTY positioning + if (false && !pianoGeometry.hidden) { // Piano disabled - use top bar piano + const whiteKeyWidth = getMiniPianoWhiteKeyWidth(layout.compactMode); + const whiteKeyHeight = MINI_KEYBOARD_HEIGHT; + const blackKeyWidth = getMiniPianoBlackKeyWidth(layout.compactMode); + const blackKeyHeight = getMiniPianoBlackKeyHeight(layout.compactMode); + + const whiteKeys = MINI_PIANO_WHITE_KEYS; + const blackKeys = MINI_PIANO_BLACK_KEYS; + const totalWhiteKeys = whiteKeys.length; + const pianoWidth = totalWhiteKeys * whiteKeyWidth; + + // � Rotated vertical piano mode (for narrow screens) + if (pianoGeometry.rotated && !recitalMode) { + // In rotated mode: keys run vertically, white keys are stacked from top to bottom + // Each key is drawn as a horizontal bar + const keyHeight = whiteKeyWidth; // Key "width" becomes vertical height + const keyWidth = whiteKeyHeight; // Key "height" becomes horizontal width + const blackKeyH = blackKeyWidth; // Black key vertical size + const blackKeyW = blackKeyHeight; // Black key horizontal size + + // Draw white keys (stacked vertically, lowest note at bottom) + const wkLen = whiteKeys.length; + for (let index = 0; index < wkLen; index++) { + const note = whiteKeys[index]; + // Reverse index so low notes are at bottom + const reversedIndex = totalWhiteKeys - 1 - index; + const y = pianoY + reversedIndex * keyHeight; + const x = pianoStartX; + const noteKey = note.toLowerCase(); + const isActivePlaying = sounds[noteKey] !== undefined; + + if (isActivePlaying) { + ink(215, 225, 230).box(x, y, keyWidth, keyHeight - 1); + } else { + ink(195, 205, 210).box(x, y, keyWidth, keyHeight - 1); + } + + // Color strip on right edge + const stripBase = getCachedColor(noteKey, num); + const stripColor = isActivePlaying ? brightenColor(stripBase, 40) : stripBase; + const stripWidth = 3; + ink(stripColor[0], stripColor[1], stripColor[2]).box(x + keyWidth - stripWidth, y, stripWidth, keyHeight - 1); + ink(90, 110, 120).box(x, y, keyWidth, keyHeight - 1, "outline"); + } + + // Draw black keys (overlaid, positioned between white keys) + const bkLen = blackKeys.length; + for (let bi = 0; bi < bkLen; bi++) { + const { note, afterWhite } = blackKeys[bi]; + // afterWhite indicates which white key this black key is after + // In vertical mode, we position it between the reversed indices + const reversedAfterWhite = totalWhiteKeys - 1 - afterWhite; + const y = pianoY + reversedAfterWhite * keyHeight + keyHeight - blackKeyH / 2; + const x = pianoStartX; + const noteKey = note.toLowerCase(); + const isActivePlaying = sounds[noteKey] !== undefined; + + // Black keys are pure black now + if (isActivePlaying) { + ink(28, 32, 36).box(x, y, blackKeyW, blackKeyH); + } else { + ink(0, 0, 0).box(x, y, blackKeyW, blackKeyH); + } + + // White text label for black key + if (blackKeyH >= 6) { + const label = noteKey.replace("#", "").toUpperCase(); + ink(255, 255, 255, isActivePlaying ? 255 : 180).write( + label, + { x: x + 1, y: y + 1 }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + } + } + } else if (recitalMode) { + // 🎭 Recital mode: Wireframe piano + const rR = recitalColor[0], rG = recitalColor[1], rB = recitalColor[2]; + // Draw piano outline + ink(rR, rG, rB, 80).box(pianoStartX, pianoY, pianoWidth, whiteKeyHeight, "outline"); + // Draw vertical separators between white keys + const rwkLen = whiteKeys.length; + for (let index = 0; index < rwkLen; index++) { + const note = whiteKeys[index]; + const x = pianoStartX + index * whiteKeyWidth; + const noteKey = note.toLowerCase(); + const isActivePlaying = sounds[noteKey] !== undefined; + if (isActivePlaying) { + ink(rR, rG, rB, 200).box(x, pianoY, whiteKeyWidth - 1, whiteKeyHeight); + } + ink(rR, rG, rB, 50).line(x, pianoY, x, pianoY + whiteKeyHeight); + } + // Draw black key outlines + const rbkLen = blackKeys.length; + for (let bi = 0; bi < rbkLen; bi++) { + const { note, afterWhite } = blackKeys[bi]; + const x = pianoStartX + afterWhite * whiteKeyWidth + whiteKeyWidth - blackKeyWidth / 2; + const noteKey = note.toLowerCase(); + const isActivePlaying = sounds[noteKey] !== undefined; + if (isActivePlaying) { + ink(rR, rG, rB, 200).box(x, pianoY, blackKeyWidth, blackKeyHeight); + } else { + ink(rR, rG, rB, 100).box(x, pianoY, blackKeyWidth, blackKeyHeight, "outline"); + } + } + } else if (!pianoGeometry.rotated) { + // Normal mode: Draw white keys + const normalStripHeight = layout.compactMode ? 2 : 3; + const songCurrentNoteForPiano = song?.[songIndex]?.[0]; + const nwkLen = whiteKeys.length; + for (let index = 0; index < nwkLen; index++) { + const note = whiteKeys[index]; + const x = pianoStartX + index * whiteKeyWidth; + const noteKey = note.toLowerCase(); + const isCurrentNote = song && note === songCurrentNoteForPiano; + const isActivePlaying = sounds[noteKey] !== undefined; + const isCurrentAndPlaying = isCurrentNote && isActivePlaying; + const isCurrentAwaiting = isCurrentNote && !isActivePlaying; + + // Inline fill colors to avoid array allocation and spread + let bfR, bfG, bfB; + if (isCurrentAndPlaying) { bfR = 230; bfG = 240; bfB = 245; } + else if (isActivePlaying) { bfR = 215; bfG = 225; bfB = 230; } + else { bfR = 195; bfG = 205; bfB = 210; } + ink(bfR, bfG, bfB).box(x, pianoY, whiteKeyWidth - 1, whiteKeyHeight); + + const stripBase = getCachedColor(noteKey, num); + const stripColor = isCurrentAndPlaying + ? brightenColor(stripBase, 70) + : isCurrentAwaiting + ? darkenColor(stripBase, 0.6) + : isActivePlaying + ? brightenColor(stripBase, 40) + : stripBase; + ink(stripColor[0], stripColor[1], stripColor[2]).box( + x, + pianoY + whiteKeyHeight - normalStripHeight, + whiteKeyWidth - 1, + normalStripHeight, + ); + ink(90, 110, 120).box(x, pianoY, whiteKeyWidth - 1, whiteKeyHeight, "outline"); // Border + } + + // Draw black keys on top (normal mode) + const nbkLen = blackKeys.length; + for (let bi = 0; bi < nbkLen; bi++) { + const { note, afterWhite } = blackKeys[bi]; + const x = pianoStartX + afterWhite * whiteKeyWidth + whiteKeyWidth - blackKeyWidth / 2; + const noteKey = note.toLowerCase(); + const isCurrentNote = song && note === songCurrentNoteForPiano; + const isActivePlaying = sounds[noteKey] !== undefined; + const isCurrentAndPlaying = isCurrentNote && isActivePlaying; + const isCurrentAwaiting = isCurrentNote && !isActivePlaying; + + // Inline fill colors to avoid array allocation and spread + let bbfR, bbfG, bbfB; + if (isCurrentAndPlaying) { bbfR = 35; bbfG = 40; bbfB = 45; } + else if (isActivePlaying) { bbfR = 28; bbfG = 32; bbfB = 36; } + else { bbfR = 14; bbfG = 18; bbfB = 20; } + ink(bbfR, bbfG, bbfB).box(x, pianoY, blackKeyWidth, blackKeyHeight); + + const stripBase = getCachedColor(noteKey, num); + const stripColor = isCurrentAndPlaying + ? brightenColor(stripBase, 70) + : isCurrentAwaiting + ? darkenColor(stripBase, 0.6) + : isActivePlaying + ? brightenColor(stripBase, 40) + : stripBase; + ink(stripColor[0], stripColor[1], stripColor[2]).box( + x, + pianoY + blackKeyHeight - normalStripHeight, + blackKeyWidth, + normalStripHeight, + ); + } + } // End of normal mode piano/keys block + + // Show QWERTY minimap in compact mode (always) or in song mode (skip in rotated/hidden/split mode) + if ((layout.compactMode || song) && !pianoGeometry.rotated && !pianoGeometry.hidden && !layout.splitLayout) { + const currentKeyLetter = song ? noteToKeyboardKey(song?.[songIndex]?.[0]) : null; + const nextKeyLetter = song ? noteToKeyboardKey(song?.[songIndex + 1]?.[0]) : null; + const activeKeyLetters = new Set( + Object.keys(sounds) + .map((activeNote) => noteToKeyboardKey(activeNote)) + .filter(Boolean), + ); + const isPercKeyActive = (key) => { + if (!key) return false; + if (miniMapActiveKey === key) return true; + // Space removed - now used for metronome toggle + if (key === "alt") return Boolean(percDowns.alt); + if (key === "left") return Boolean(percDowns.left); + if (key === "right") return Boolean(percDowns.right); + if (key === "up") return Boolean(percDowns.up); + if (key === "down") return Boolean(percDowns.down); + return false; + }; + + const qKeyWidth = 9; + const qKeyHeight = QWERTY_MINIMAP_KEY_HEIGHT; + const qKeySpacing = QWERTY_MINIMAP_KEY_SPACING; + const qwertyStartX = pianoGeometry.qwertyStartX ?? pianoStartX; + const qwertyStartY = + pianoGeometry.qwertyStartY ?? + pianoY + whiteKeyHeight + QWERTY_MINIMAP_SPACING; + + // 🎭 Recital mode: Wireframe QWERTY + if (recitalMode) { + const recitalR = recitalColor[0], recitalG = recitalColor[1], recitalB = recitalColor[2]; + const qGlyphWidth = matrixGlyphMetrics.width; + const qGlyphHeight = matrixGlyphMetrics.height; + const qRowCount = QWERTY_LAYOUT_ROWS.length; + + for (let rowIndex = 0; rowIndex < qRowCount; rowIndex++) { + const row = QWERTY_LAYOUT_ROWS[rowIndex]; + const rowLen = row.length; + const rowOffset = + rowIndex === 0 + ? 0 + : rowIndex === 1 + ? (qKeyWidth + qKeySpacing) / 2 + : rowIndex === 2 + ? qKeyWidth + : Math.floor((pianoWidth - (rowLen * (qKeyWidth + qKeySpacing))) / 2); + const y = qwertyStartY + rowIndex * (qKeyHeight + qKeySpacing); + + for (let keyIndex = 0; keyIndex < rowLen; keyIndex++) { + const keyLetter = row[keyIndex]; + const x = qwertyStartX + rowOffset + keyIndex * (qKeyWidth + qKeySpacing); + + const isActiveKey = activeKeyLetters.has(keyLetter) || isPercKeyActive(keyLetter); + + // Wireframe keys - brighter when active + if (isActiveKey) { + ink(recitalR, recitalG, recitalB, 200).box(x, y, qKeyWidth, qKeyHeight); + } else { + ink(recitalR, recitalG, recitalB, 50).box(x, y, qKeyWidth, qKeyHeight, "outline"); + } + + const label = formatKeyLabel(keyLetter); + const labelWidth = label.length * qGlyphWidth; + const labelX = x + (qKeyWidth - labelWidth) / 2; + const labelY = y + (qKeyHeight - qGlyphHeight) / 2; + + if (labelWidth <= qKeyWidth) { + ink(recitalR, recitalG, recitalB, isActiveKey ? 255 : 80).write( + label, + { x: labelX, y: labelY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + } + } + } + } else { + // Normal mode QWERTY - styled like a real computer keyboard (gray tones) + // Pre-create special keys Set for O(1) lookup + const specialKeysSet = new Set(["space", "alt", "left", "right", "up", "down"]); + const nGlyphWidth = matrixGlyphMetrics.width; + const nGlyphHeight = matrixGlyphMetrics.height; + const nRowCount = QWERTY_LAYOUT_ROWS.length; + + for (let rowIndex = 0; rowIndex < nRowCount; rowIndex++) { + const row = QWERTY_LAYOUT_ROWS[rowIndex]; + const rowLen = row.length; + const rowOffset = + rowIndex === 0 + ? 0 + : rowIndex === 1 + ? (qKeyWidth + qKeySpacing) / 2 + : rowIndex === 2 + ? qKeyWidth + : Math.floor((pianoWidth - (rowLen * (qKeyWidth + qKeySpacing))) / 2); + const y = qwertyStartY + rowIndex * (qKeyHeight + qKeySpacing); + + for (let keyIndex = 0; keyIndex < rowLen; keyIndex++) { + const keyLetter = row[keyIndex]; + const x = qwertyStartX + rowOffset + keyIndex * (qKeyWidth + qKeySpacing); + + const mappedNote = keyboardKeyToNote(keyLetter); + const isMapped = Boolean(mappedNote); + const isBlackKeyMapped = isMapped && isBlackKey(mappedNote); + const isCurrentKey = keyLetter === currentKeyLetter; + const isActiveKey = activeKeyLetters.has(keyLetter) || isPercKeyActive(keyLetter); + const isCurrentAndActive = isCurrentKey && isActiveKey; + const isNextKey = keyLetter === nextKeyLetter; + const isSpecialKey = specialKeysSet.has(keyLetter); + + // Computer keyboard style: gray keys, pure black for black note keys + // Use direct ink calls instead of spread operator + let fr, fg, fb, fa; + if (isCurrentAndActive) { + if (isBlackKeyMapped) { fr = 28; fg = 32; fb = 36; fa = 255; } + else { fr = 180; fg = 200; fb = 180; fa = 230; } + } else if (isActiveKey) { + if (isBlackKeyMapped) { fr = 28; fg = 32; fb = 36; fa = 255; } + else { fr = 200; fg = 200; fb = 180; fa = 220; } + } else if (isBlackKeyMapped) { + fr = 0; fg = 0; fb = 0; fa = 255; + } else if (isCurrentKey) { + fr = 140; fg = 160; fb = 160; fa = 200; + } else if (isNextKey) { + fr = 120; fg = 140; fb = 150; fa = 180; + } else if (isSpecialKey) { + fr = 55; fg = 60; fb = 65; fa = 200; + } else { + fr = 70; fg = 75; fb = 80; fa = 200; + } + + ink(fr, fg, fb, fa).box(x, y, qKeyWidth, qKeyHeight); + ink(45, 50, 55, 200).box(x, y, qKeyWidth, qKeyHeight, "outline"); + + const label = formatKeyLabel(keyLetter); + const labelWidth = label.length * nGlyphWidth; + const labelX = x + (qKeyWidth - labelWidth) / 2; + const labelY = y + (qKeyHeight - nGlyphHeight) / 2; + + // Keyboard-style text colors - tinted by mapped note color + let tr, tg, tb, ta; + if (isBlackKeyMapped) { + // White text on black keys (like the pads) + tr = 255; tg = 255; tb = 255; ta = isActiveKey ? 255 : 180; + } else if (isCurrentAndActive || isActiveKey) { + tr = 30; tg = 30; tb = 30; ta = 255; + } else if (isMapped) { + // Tint text with the mapped note's color (brightened for visibility) + const noteColor = getCachedColor(mappedNote, num); + tr = Math.min(255, noteColor[0] + 100); + tg = Math.min(255, noteColor[1] + 100); + tb = Math.min(255, noteColor[2] + 100); + ta = 230; + } else { + tr = 140; tg = 145; tb = 150; ta = 200; + } + + // Apply shake offset when playing - check both noteShake and active sounds + const isKeyPlaying = mappedNote && (sounds[mappedNote] !== undefined || sounds[`+${mappedNote}`] !== undefined); + const keyNoteShake = mappedNote ? (noteShake[mappedNote] || noteShake[`+${mappedNote}`] || (isKeyPlaying ? 2 : 0)) : 0; + const shakeX = keyNoteShake > 0 ? Math.floor((Math.random() - 0.5) * keyNoteShake * 2) : 0; + const shakeY = keyNoteShake > 0 ? Math.floor((Math.random() - 0.5) * keyNoteShake * 2) : 0; + + if (labelWidth <= qKeyWidth) { + ink(tr, tg, tb, ta).write( + label, + { x: labelX + shakeX, y: labelY + shakeY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + } + } + } + } // End of normal mode QWERTY + + const gamepad = Object.values(connectedGamepads).find( + (gp) => gp && (gp.id || gp.pressedButtons.length > 0 || Object.keys(gp.axes).length > 0), + ); + if (gamepad) { + const gamepadWidth = 48; + const gamepadHeight = 24; + const gamepadX = + qwertyStartX + Math.floor((pianoWidth - gamepadWidth) / 2); + const gamepadY = qwertyStartY + QWERTY_MINIMAP_HEIGHT + 2; + const maxY = layout?.topButtonY ? layout.topButtonY - 2 : screen.height; + if (gamepadY + gamepadHeight <= maxY) { + drawMiniControllerDiagram(ink, gamepad, gamepadX, gamepadY); + } + } + } + } // End of !pianoGeometry.hidden check + } + + // layout already computed above for piano/qwerty positioning + const midiBadgeMetrics = layout.midiBadge ?? cachedLayout.midiMetrics; + const melodyButtonRect = layout.melodyButtonRect; + + if (melodyAliasBtn && melodyButtonRect && melodyAliasBtn.box) { + melodyAliasBtn.box.x = melodyButtonRect.x; + melodyAliasBtn.box.y = melodyButtonRect.y; + melodyAliasBtn.box.w = melodyButtonRect.width; + melodyAliasBtn.box.h = melodyButtonRect.height; + } + + function drawSampleStream( + rect, + waveforms, + tick, + { + color = [80, 200, 255, 220], + idleColor = [120, 120, 120, 120], + } = {}, + ) { + if (!rect || rect.width <= 0 || rect.height <= 0) return; + + const { x, y, width, height } = rect; + const half = Math.max(1, Math.floor(height / 2)); + const mid = y + half; + const hasWaveforms = Array.isArray(waveforms) && waveforms.length > 0; + + const idleR = idleColor[0], idleG = idleColor[1], idleB = idleColor[2], idleA = idleColor[3] ?? 255; + if (!hasWaveforms) { + for (let i = 0; i < width; i += 2) { + ink(idleR, idleG, idleB, idleA).box(x + i, mid, 1, 1); + } + return; + } + + const colR = color[0], colG = color[1], colB = color[2], colA = color[3] ?? 255; + const step = waveforms.length / width; + const offset = waveforms.length > 0 ? (tick || 0) % waveforms.length : 0; + const wfLen = waveforms.length; + const yMax = y + height - 1; + for (let i = 0; i < width; i += 1) { + const idx = Math.floor(i * step + offset) % wfLen; + const sample = waveforms[idx] || 0; + const clamped = Math.max(-1, Math.min(1, sample)); + const py = Math.max(y, Math.min(yMax, Math.round(mid - clamped * half))); + ink(colR, colG, colB, colA).box(x + i, py, 1, 1); + } + } + + function drawStampleWaveform( + rect, + data, + progress, + needleColor, + { + lineColor = [180, 200, 220, 220], + background = [0, 0, 0, 120], + } = {}, + ) { + if (!rect || rect.width <= 2 || rect.height <= 2 || !data?.length) return; + + const { x, y, width, height } = rect; + const mid = y + Math.floor(height / 2); + const bgR = background[0], bgG = background[1], bgB = background[2], bgA = background[3] ?? 255; + ink(bgR, bgG, bgB, bgA).box(x, y, width, height); + + const lcR = lineColor[0], lcG = lineColor[1], lcB = lineColor[2], lcA = lineColor[3] ?? 255; + const step = data.length / width; + const halfHeight = height * 0.45; + for (let i = 0; i < width; i += 1) { + const idx = Math.floor(i * step); + const sample = data[idx] || 0; + const clamped = Math.max(-1, Math.min(1, sample)); + const py = Math.round(mid - clamped * halfHeight); + ink(lcR, lcG, lcB, lcA).box(x + i, py, 1, 1); + } + + if (typeof progress === "number" && progress >= 0) { + const needleX = Math.round(x + progress * width); + if (needleX >= x && needleX <= x + width) { + const ncR = Array.isArray(needleColor) ? needleColor[0] : 255; + const ncG = Array.isArray(needleColor) ? needleColor[1] : 255; + const ncB = Array.isArray(needleColor) ? needleColor[2] : 255; + ink(ncR, ncG, ncB).box(needleX, y, 1, height); + } + } + } + + function drawMidiBadge( + metrics, + connected, + rateLabel, + rateText, + maxX = Infinity, + { + connectedText = [255, 165, 0], + disconnectedText = [140, 140, 140, 200], + rateTextColor = [80, 200, 255, 220], + dividerColor = [80, 80, 80], + fpsColor = [120, 255, 120], + // Distinct background colors for each section + midiBgColor = [40, 30, 20, 160], + rateBgColor = [20, 35, 45, 160], + fpsBgColor = [20, 40, 25, 160], + api, + screenWidth, + } = {}, + ) { + if (!metrics) return; + + const { x, y } = metrics; + const textColor = connected ? connectedText : disconnectedText; + const tcR = textColor[0], tcG = textColor[1], tcB = textColor[2], tcA = textColor[3] ?? 255; + const divR = dividerColor[0], divG = dividerColor[1], divB = dividerColor[2]; + const rtR = rateTextColor[0], rtG = rateTextColor[1], rtB = rateTextColor[2], rtA = rateTextColor[3] ?? 255; + const gw = matrixGlyphMetrics.width; + const gh = matrixGlyphMetrics.height; + const baseX = x + MIDI_BADGE_PADDING_X; + const baseY = y + MIDI_BADGE_PADDING_Y; + // Constrain sectionH to never exceed SECONDARY_BAR_HEIGHT to prevent bleeding + const sectionH = Math.min(gh + MIDI_BADGE_PADDING_Y * 2, SECONDARY_BAR_HEIGHT); + // Ensure boxes start at bar top, not below + const boxY = SECONDARY_BAR_TOP; + + let cursorX = baseX; + + // Draw "M" (shortened from MIDI) with background + const midiText = "M"; + const midiTextW = measureMatrixTextWidth(midiText); + // Box: starts at cursorX, width = text width (no extra padding bloat) + if (cursorX + midiTextW > maxX) return; + ink(midiBgColor[0], midiBgColor[1], midiBgColor[2], midiBgColor[3]).box(cursorX, boxY, midiTextW, SECONDARY_BAR_HEIGHT); + ink(tcR, tcG, tcB, tcA).write( + midiText, + { x: cursorX, y: baseY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + cursorX += midiTextW; + + // Divider + cursorX += 2; + if (cursorX > maxX) return; + ink(divR, divG, divB).line(cursorX, boxY + 2, cursorX, boxY + SECONDARY_BAR_HEIGHT - 2); + cursorX += 3; + + // Draw sample rate (e.g., "48k") with background + if (rateText) { + const shortRate = rateText.replace("Hz", ""); // "48kHz" -> "48k" + const rateTextW = measureMatrixTextWidth(shortRate); + if (cursorX + rateTextW > maxX) return; + ink(rateBgColor[0], rateBgColor[1], rateBgColor[2], rateBgColor[3]).box(cursorX, boxY, rateTextW, SECONDARY_BAR_HEIGHT); + ink(rtR, rtG, rtB, rtA).write( + shortRate, + { x: cursorX, y: baseY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + cursorX += rateTextW; + } + + // Divider + cursorX += 2; + if (cursorX > maxX) return; + ink(divR, divG, divB).line(cursorX, boxY + 2, cursorX, boxY + SECONDARY_BAR_HEIGHT - 2); + cursorX += 3; + + // Draw FPS with suffix and background (skip if it would overlap metronome) + const fpsVal = Math.round(perfStats.fps); + const fpsText = fpsVal > 0 ? `${fpsVal}fps` : "--"; + const fpsTextW = + measureMatrixTextBoxWidth(fpsText, api, screenWidth) || + measureMatrixTextWidth(fpsText); + // Check if FPS box would overlap maxX (BPM button area) - need at least space for text + const fpsGap = 2; // extra safety gap before metronome buttons + const availableFpsSpace = maxX - cursorX - fpsGap; + if (fpsTextW > availableFpsSpace) return; + // Clip FPS background to never exceed maxX (with safety gap) + const clippedFpsW = Math.min(fpsTextW, availableFpsSpace); + ink(fpsBgColor[0], fpsBgColor[1], fpsBgColor[2], fpsBgColor[3]).box(cursorX, boxY, clippedFpsW, SECONDARY_BAR_HEIGHT); + let fpsR, fpsG, fpsB; + if (fpsVal < 30) { fpsR = 255; fpsG = 80; fpsB = 80; } + else if (fpsVal < 50) { fpsR = 255; fpsG = 200; fpsB = 80; } + else { fpsR = fpsColor[0]; fpsG = fpsColor[1]; fpsB = fpsColor[2]; } + ink(fpsR, fpsG, fpsB).write( + fpsText, + { x: cursorX, y: baseY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + cursorX += fpsTextW; + + // 🎹 Draw DAW label when in DAW mode + if (dawMode) { + // Divider + cursorX += 2; + if (cursorX > maxX) return; + ink(divR, divG, divB).line(cursorX, boxY + 2, cursorX, boxY + SECONDARY_BAR_HEIGHT - 2); + cursorX += 3; + + const dawText = "DAW"; + const dawTextW = measureMatrixTextWidth(dawText); + if (cursorX + dawTextW > maxX) return; + // Purple/magenta background for DAW mode indicator + const dawBgColor = dawSynced ? [60, 30, 80, 180] : [40, 30, 50, 160]; + const dawTextColor = dawSynced ? [220, 140, 255] : [140, 100, 160]; + ink(dawBgColor[0], dawBgColor[1], dawBgColor[2], dawBgColor[3]).box(cursorX, boxY, dawTextW, SECONDARY_BAR_HEIGHT); + ink(dawTextColor[0], dawTextColor[1], dawTextColor[2]).write( + dawText, + { x: cursorX, y: baseY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + } + } + + + if (projector) { + const sy = 33; + const sh = screen.height - sy; + + // console.log(sound.speaker.amplitudes, sound.speaker.waveforms); + sound.paint.bars( + api, + amplitude, + waveformsForBars, + 0, + sy, + screen.width, // width + sh, // height + [255, 0, 0, 255], + { noamp: true, primaryColor, secondaryColor }, + ); + //ink("yellow").write(scope, 6, sy + sh + 5); + ink("yellow").write(scope, 50 + 4, 6); + // ink("pink").write(scopeTrim, 6 + 18, sy + sh + 5); + sound.paint.audioEngineBadge( + { ink, screen }, + sound.speaker, + null, + sy + 4, + { align: "center", height: 12 }, + ); + + // MIDI badge now renders in the top mini bar (left) + } else if (!paintPictureOverlay) { + const sy = 3; + const sh = 15; // screen.height - sy; + + // Visualizer starts after the top bar piano (or at 54 if piano not shown) + const vizStartX = topBarPianoEndX; + const availableWidth = waveBtn.box.x - vizStartX; + + sound.paint.bars( + api, + amplitude, + waveformsForBars, + vizStartX, //0, + sy, //sy, + availableWidth, + //screen.width, // width + sh, // height + [255, 0, 0, 255], + { primaryColor, secondaryColor }, + ); + + // Only show stample waveform needle when relevant + if (wave === "stample" && stampleSampleData?.length) { + const needleNote = stampleNeedleNote || active?.[active.length - 1]; + const needleColor = needleNote ? colorFromNote(needleNote, num) : [255, 255, 255]; + const stripRight = waveBtn?.box?.x ? waveBtn.box.x - 6 : screen.width - 6; + const stripWidth = Math.min(72, Math.max(24, stripRight - 56)); + const stripLeft = Math.max(56, stripRight - stripWidth); + drawStampleWaveform( + { + x: stripLeft, + y: sy + 2, + width: stripRight - stripLeft, + height: 10, + }, + stampleSampleData, + stampleNeedleProgress, + needleColor, + ); + } + + // Active notes now render in the secondary mini bar. + + // ink("yellow").write(scope, 56 + 120 + 2, sy + 3); + // ink("pink").write(scopeTrim, 6 + 18, sy + sh + 3); + // ink("cyan").write(sound.sampleRate, 6 + 18 + 20, sy + sh + 3); + + const rightEdge = waveBtn?.box?.x ?? screen.width - 8; + + sound.paint.audioEngineBadge( + { ink, screen }, + sound.speaker, + rightEdge - 4, + sy + 2, + { align: "right", height: max(9, sh - 4), maxWidth: rightEdge - topBarPianoEndX - 4 }, + ); + + if (song && melodyAliasBtn && melodyAliasBtn.box && melodyButtonRect) { + const baseGlyphWidth = matrixGlyphMetrics.width; + const baseGlyphHeight = matrixGlyphMetrics.height; + + melodyAliasBtn.paint((btn) => { + const rect = btn.box; + const isActive = melodyAliasDown || songNoteDown; + const backgroundColor = isActive ? [0, 120, 140, 220] : [0, 0, 0, 150]; + const borderColor = isActive ? [0, 255, 234, 240] : [0, 200, 220, 180]; + + ink(...backgroundColor).box(rect.x, rect.y, rect.w, rect.h); + ink(...borderColor).box(rect.x, rect.y, rect.w, rect.h, "outline"); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(rect.x, rect.y, rect.w, rect.h); + } + + // Note value intentionally omitted per design request + + const sanitizeLyric = (value) => + typeof value === "string" ? value.replace(/-/g, "") : ""; + + const prevLyric = sanitizeLyric(song?.[songIndex - 1]?.[1]); + const currentLyric = sanitizeLyric(song?.[songIndex]?.[1]); + const nextLyric = sanitizeLyric(song?.[songIndex + 1]?.[1]); + + const glyphWidth = matrixGlyphMetrics.width; + const glyphHeight = matrixGlyphMetrics.height; + const centerY = rect.y + rect.h / 2; + + if (currentLyric) { + const lyricWidth = currentLyric.length * glyphWidth; + const lyricX = rect.x + (rect.w - lyricWidth) / 2; + const lyricY = centerY - glyphHeight / 2; + ink("white").write( + currentLyric, + { x: lyricX, y: lyricY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + + if (prevLyric) { + const prevWidth = prevLyric.length * glyphWidth; + const prevX = lyricX - prevWidth - 4; + if (prevX > rect.x) { + ink(160, 160, 160, 210).write( + prevLyric, + { x: prevX, y: lyricY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + } + } + + if (nextLyric) { + const nextWidth = nextLyric.length * glyphWidth; + const nextX = lyricX + lyricWidth + 4; + if (nextX + nextWidth < rect.x + rect.w) { + ink(160, 160, 160, 210).write( + nextLyric, + { x: nextX, y: lyricY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + } + } + } + + const hint = "tap or hold"; + const hintWidth = hint.length * glyphWidth; + const hintX = rect.x + (rect.w - hintWidth) / 2; + const hintY = rect.y + rect.h - glyphHeight - 4; + ink(200, 200, 200, 200).write( + hint, + { x: hintX, y: hintY }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + }); + } + + // MIDI badge now renders in the top mini bar (left) + } + + if (paintPerfEnabled && paintTick % paintPerfEvery === 0) { + const elapsed = performance.now() - paintStart; + console.log("🎨 notepat paint", { + ms: Number(elapsed.toFixed(2)), + frame: paintTick, + miniInputsEnabled: layout.miniInputsEnabled, + activeNotes: active.length, + songMode: Boolean(song), + compact: layout?.compactMode, + }); + } + + updateTheme({ num }); + + if (tap) { + ink("yellow"); + write("tap", { right: 6, top: 6 }); + } else if (!paintPictureOverlay) { + abletonBtn?.paint((btn) => { + ink(btn.down ? [52, 48, 90] : [30, 28, 62]).box(btn.box); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(btn.box); + ink(150, 175, 255, 160).box(btn.box, "outline"); + } + ink(90, 120, 210).line( + btn.box.x + btn.box.w, + btn.box.y, + btn.box.x + btn.box.w, + btn.box.y + btn.box.h - 1, + ); + ink(btn.down ? [235, 240, 255] : [175, 205, 255]).write( + abletonBtn.label || "m4l", + { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, + undefined, undefined, false, "MatrixChunky8" + ); + }); + + osBtn?.paint((btn) => { + ink(btn.down ? [20, 70, 70] : [10, 45, 45]).box(btn.box); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(btn.box); + ink(100, 255, 255, 140).box(btn.box, "outline"); + } + ink(70, 160, 160).line( + btn.box.x + btn.box.w, + btn.box.y, + btn.box.x + btn.box.w, + btn.box.y + btn.box.h - 1, + ); + ink(btn.down ? [220, 255, 255] : [120, 255, 255]).write( + osBtn.label || "os", + { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, + undefined, undefined, false, "MatrixChunky8" + ); + }); + + recBtn?.paint((btn) => { + // Pulse a half-cycle per second while armed so the red dot reads as live. + const pulse = recording + ? 0.7 + 0.3 * Math.sin(performance.now() / 160) + : 1; + const bgIdle = btn.down ? [90, 20, 20] : [55, 10, 10]; + const bgRec = [Math.round(180 * pulse), 20, 20]; + const bg = recording ? bgRec : bgIdle; + ink(bg).box(btn.box); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box(btn.box); + ink(255, 120, 120, 160).box(btn.box, "outline"); + } + ink(220, 80, 80).line( + btn.box.x + btn.box.w, + btn.box.y, + btn.box.x + btn.box.w, + btn.box.y + btn.box.h - 1, + ); + const label = recording + ? osBarButtonMetrics({ screen }).labels.recStop + : osBarButtonMetrics({ screen }).labels.rec; + const fg = recording + ? [255, 240, 240] + : btn.down ? [255, 220, 220] : [255, 150, 150]; + ink(fg).write( + label, + { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, + undefined, undefined, false, "MatrixChunky8" + ); + }); + + waveBtn?.paint((btn) => { + ink(btn.down ? [40, 40, 100] : "darkblue").box( + btn.box.x, + btn.box.y + 3, + btn.box.w, + btn.box.h - 3, + ); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box( + btn.box.x, + btn.box.y + 3, + btn.box.w, + btn.box.h - 3, + ); + ink(80, 140, 255, 140).box( + btn.box.x, + btn.box.y + 3, + btn.box.w, + btn.box.h - 3, + "outline", + ); + } + ink("orange").line( + btn.box.x + btn.box.w, + btn.box.y + 3, + btn.box.x + btn.box.w, + btn.box.y + btn.box.h - 1, + ); + ink(btn.down ? "yellow" : "orange"); + // Use shortened wave name and smaller font on narrow screens + const displayWave = btn.displayWave || wave; + if (btn.isNarrow) { + ink(btn.down ? "yellow" : "orange").write( + displayWave, + { x: btn.box.x + 3, y: btn.box.y + 5 }, + undefined, undefined, false, "MatrixChunky8" + ); + } else { + write(wave, { right: 27, top: 6 }); + } + }); + + octBtn?.paint((btn) => { + if (btn.down) { + ink(40, 40, 100); + } else { + ink(octaveTheme[octave], 196); + } + box(btn.box.x, btn.box.y + 3, btn.box.w - 4, btn.box.h - 3); + if (btn.over && !btn.down) { + ink(255, 255, 255, 24).box( + btn.box.x, + btn.box.y + 3, + btn.box.w - 4, + btn.box.h - 3, + ); + ink(255, 160, 220, 140).box( + btn.box.x, + btn.box.y + 3, + btn.box.w - 4, + btn.box.h - 3, + "outline", + ); + } + ink(btn.down ? "yellow" : "pink"); + // Use smaller font on narrow screens + if (btn.isNarrow) { + ink(btn.down ? "yellow" : "pink").write( + octave, + { x: btn.box.x + 3, y: btn.box.y + 5 }, + undefined, undefined, false, "MatrixChunky8" + ); + } else { + write(octave, { right: 8, top: 6 }); + } + }); + } + + if (tap) { + // Only render a visible window of keys around the current tapIndex + // to prevent performance degradation with long key sequences + const glyphWidth = 6; // Approximate character width + const maxVisibleChars = Math.ceil(screen.width / glyphWidth) + 10; + const halfVisible = Math.floor(maxVisibleChars / 2); + const startIdx = Math.max(0, tapIndex - halfVisible); + const endIdx = Math.min(keys.length, tapIndex + halfVisible); + const visibleKeys = keys.slice(startIdx, endIdx); + const offsetFromStart = tapIndex - startIdx; + ink("gray").write(visibleKeys, screen.width / 2 - offsetFromStart * glyphWidth, screen.height / 2); + + let nextToken = keys[tapIndex]; + let tempTapIndex = tapIndex; + if (octaves.includes(nextToken)) { + tempTapIndex += 1; + nextToken += keys[tempTapIndex]; + } + if (accents.includes(keys[tempTapIndex + 1])) { + nextToken += keys[tempTapIndex + 1]; + } + ink("red").write(nextToken, screen.width / 2, screen.height / 2); + } + + if (tap && !paintPictureOverlay) + ink("orange").line(screen.width / 2, 0, screen.width / 2, screen.height); + + if (!paintPictureOverlay) { + if (tap) { + ink("white"); + active.forEach((sound, index) => { + write(sound, screen.width / 2, screen.height / 2 + 12 + 12 * index); + }); + if (tapped) { + ink("lime"); + write(tapped, screen.width / 2, screen.height / 2); + } + } + } + + if (!tap /*&& !projector*/ && !paintPictureOverlay) { + // 🎵 Draw connecting line FIRST (behind everything) + if (song && songIndex < song.length - 1) { + const currentNoteKey = song[songIndex][0]; + const nextNoteKey = song[songIndex + 1][0]; + + if (currentNoteKey && nextNoteKey) { + // Find the button for current and next notes + const currentButton = buttons[currentNoteKey.toLowerCase()] || buttons["+" + currentNoteKey.toLowerCase()]; + const nextButton = buttons[nextNoteKey.toLowerCase()] || buttons["+" + nextNoteKey.toLowerCase()]; + + if (currentButton && nextButton) { + // Get center points of both buttons + const x1 = currentButton.box.x + currentButton.box.w / 2; + const y1 = currentButton.box.y + currentButton.box.h / 2; + const x2 = nextButton.box.x + nextButton.box.w / 2; + const y2 = nextButton.box.y + nextButton.box.h / 2; + + // Draw brighter line with fade based on progress + const lineOpacity = Math.floor(songProgress * 255); + if (lineOpacity > 0) { + ink(0, 255, 255, lineOpacity).line(x1, y1, x2, y2); + } + } + } + } + + const activeNote = buttonNotes.indexOf(active[0]); + + if (activeNote >= 0 && activeNote !== lastActiveNote) { + lastActiveNote = activeNote; + transposeOverlayFade = 0; + } + + // 🎵 Draw grid outlines for melody notes - cache melody notes set + if (song) { + // Build melody notes set once per song change (could be cached at song load time) + const melodyNotes = new Set(); + const songLen = song.length; + for (let i = 0; i < songLen; i++) { + melodyNotes.add(song[i][0].toLowerCase()); + } + + // Draw outline for each melody note button - use for loop + const btnNotesLen = buttonNotes.length; + for (let i = 0; i < btnNotesLen; i++) { + const note = buttonNotes[i]; + const btn = buttons[note]; + if (btn && melodyNotes.has(note.toUpperCase())) { + ink(0, 255, 255, 64).box(btn.box, "outline"); + } + } + } + + if (usePadsBase) { + if (padsBaseKey !== padsBaseCacheKey || !padsBase) { + padsBase = buildPadsBase({ api, screen, layout, matrixGlyphMetrics, num }); + padsBaseKey = padsBaseCacheKey; + } + if (padsBase?.painting) { + paste(padsBase.painting, 0, padsBase.gridTop, { width: screen.width }); + } + } + + // Paint all the keyboard buttons - use for loop for performance + const songCurrentNote = song?.[songIndex]?.[0]; + const btnNotesLength = buttonNotes.length; + const parsedOctave = parseInt(octave); + + for (let index = 0; index < btnNotesLength; index++) { + const note = buttonNotes[index]; + const btn = buttons[note]; + if (!btn) continue; + + btn.paint((btn) => { + let color; + let isBlocked = false; + + // In song mode, check if this note is blocked (not the current note) + let hoverBaseColor = null; + if (song && note.toUpperCase() !== songCurrentNote) { + isBlocked = true; + color = "black"; // Blocked notes are black + } else { + const outOctave = parsedOctave + (note.startsWith("+") ? upperOctaveShift : lowerOctaveShift); + const baseColor = getCachedColor(note, num, outOctave); + const isSemitone = note.includes("#"); + // Semitones get darker and shifted toward black for clear visual distinction + const tinted = isSemitone ? darkenColor(baseColor, 0.45) : baseColor; + hoverBaseColor = tinted; + // When held: extreme dayglow neon flash effect - cycle through RGB + if ((!slide && btn.down) || (btn.down && slide)) { + const flashPhase = (paintCount * 0.15) % 3; + const flashR = flashPhase < 1 ? 255 : (flashPhase < 2 ? 80 : 120); + const flashG = flashPhase < 1 ? 80 : (flashPhase < 2 ? 255 : 80); + const flashB = flashPhase < 1 ? 120 : (flashPhase < 2 ? 80 : 255); + // Blend base color with flash color for neon effect + color = [ + Math.min(255, Math.floor(tinted[0] * 0.4 + flashR * 0.6)), + Math.min(255, Math.floor(tinted[1] * 0.4 + flashG * 0.6)), + Math.min(255, Math.floor(tinted[2] * 0.4 + flashB * 0.6)), + ]; + } else { + color = tinted; + } + } + + const shouldRedrawFull = !usePadsBase || btn.down || isBlocked || recitalMode; + + // 🎭 Recital mode: Wireframe pads + if (recitalMode) { + const isActivePlaying = sounds[note] !== undefined || btn.down; + if (isActivePlaying) { + ink(recitalColor[0], recitalColor[1], recitalColor[2], 200).box(btn.box); + } + ink(recitalColor[0], recitalColor[1], recitalColor[2], isActivePlaying ? 180 : 50).box(btn.box, "outline"); + + // Ghost trails in recital mode + const trailVal = trail[note]; + if (trailVal > 0) { + ink(recitalColor[0], recitalColor[1], recitalColor[2], max(1, trailVal * 150)).box( + btn.box.x + btn.box.w / 2, + btn.box.y + btn.box.h / 2, + trailVal * btn.box.w, + trailVal * btn.box.h, + "center", + ); + } + + // Note label in recital mode + const meta = btn.meta || {}; + // Natural notes uppercase, black keys (sharps/flats) lowercase + const noteLabelText = meta.noteLabelText || (isBlackKey(note) || !shiftHeld ? note.toLowerCase() : note.toUpperCase()); + const noteFont = meta.noteFont || "MatrixChunky8"; + const glyphWidth = meta.noteGlyphWidth || matrixGlyphMetrics.width; + const glyphHeight = meta.noteGlyphHeight || matrixGlyphMetrics.height; + const labelWidth = noteLabelText.length * glyphWidth; + const labelX = btn.box.x + 2; + const labelY = btn.box.y + 1; + + ink(...recitalColor, isActivePlaying ? 255 : 100).write( + noteLabelText, + { x: labelX, y: labelY }, + undefined, + undefined, + false, + noteFont, + ); + return; // Skip normal rendering in recital mode + } + + // Normal mode rendering + if (shouldRedrawFull) { + if (!projector) { + ink(color, isBlocked ? 128 : 196).box(btn.box); // Blocked notes are darker + } else { + ink(color, 48).box(btn.box); // One solid colored box per note. + // ink("white", 32).box(btn.box, "inline"); // One solid colored box per note. + } + } + if (btn.over && !btn.down && !isBlocked) { + const hoverTint = hoverBaseColor || [200, 220, 255]; + ink(hoverTint[0], hoverTint[1], hoverTint[2], 40).box(btn.box); + ink(hoverTint[0], hoverTint[1], hoverTint[2], 140).box(btn.box, "outline"); + + if (!hoveredNote) { + hoveredNote = note; + hoveredKeyLabel = formatKeyLabel(noteToKeyboardKey(note) || ""); + hoveredNoteColor = hoverTint; + } + } + // const accent = colorFromNote(note, num); + // ink(accent).box(btn.box.x + btn.box.w - 8, btn.box.y + 4, 4); + // ink("black", 64).box( + // btn.box.x + btn.box.w - 8, + // btn.box.y + 4, + // 4, + // "outline", + // ); + + // Ghost trails 👻 + if (trail[note] > 0) { + ink("maroon", max(1, trail[note] * 180)).box( + btn.box.x + btn.box.w / 2, + btn.box.y + btn.box.h / 2, + trail[note] * btn.box.w, + trail[note] * btn.box.h, + "center", + ); + } + + // 🎵 Note label + const meta = btn.meta || {}; + const noteFont = meta.noteFont || (btn.box.w >= 22 ? "unifont" : "MatrixChunky8"); + const glyphWidth = meta.noteGlyphWidth || (noteFont === "unifont" ? 8 : matrixGlyphMetrics.width); + const glyphHeight = meta.noteGlyphHeight || (noteFont === "unifont" ? 16 : matrixGlyphMetrics.height); + const noteName = note.replace(/^[+-]+/, "").toLowerCase(); + const forceWhiteText = ["d", "e", "f"].includes(noteName[0]); + const isSemitoneKey = note.includes("#"); + // Black keys get slightly grayer text for subtler look + const baseTextColor = isSemitoneKey ? [140, 140, 140] : (forceWhiteText ? [255, 255, 255] : getContrastingTextColor(color)); + + let noteLabelText = null; + let noteLabelBounds = null; + + if (song) { + const isCurrentNote = note.toUpperCase() === song?.[songIndex][0]; + const isNextNote = note.toUpperCase() === song?.[songIndex + 1]?.[0]; + if (isCurrentNote || isNextNote) { + // Natural notes uppercase, black keys (sharps/flats) lowercase + noteLabelText = meta.noteLabelText || (isBlackKey(note) || !shiftHeld ? note.toLowerCase() : note.toUpperCase()); + noteLabelBounds = meta.noteLabelBoundsCentered || { + x: btn.box.x + btn.box.w / 2 - (noteLabelText.length * glyphWidth) / 2, + y: btn.box.y + btn.box.h / 2 - glyphHeight / 2, + w: noteLabelText.length * glyphWidth, + h: glyphHeight, + }; + } + } else { + // Natural notes uppercase, black keys (sharps/flats) lowercase + noteLabelText = meta.noteLabelText || (isBlackKey(note) || !shiftHeld ? note.toLowerCase() : note.toUpperCase()); + noteLabelBounds = meta.noteLabelBoundsDefault || { + x: btn.box.x + 2, + y: btn.box.y + 1, + w: noteLabelText.length * glyphWidth, + h: glyphHeight, + }; + } + const keyLabel = meta.keyLabel || null; + const keyFont = meta.keyFont || (btn.box.w >= 22 ? "unifont" : "MatrixChunky8"); + const keyGlyphWidth = meta.keyGlyphWidth || (keyFont === "unifont" ? 8 : matrixGlyphMetrics.width); + const keyGlyphHeight = meta.keyGlyphHeight || (keyFont === "unifont" ? 16 : matrixGlyphMetrics.height); + let keyBounds = null; + + if (keyLabel) { + if (song) { + keyBounds = meta.keyBoundsCentered; + if (meta.keyOverlapsCentered) noteLabelText = null; + } else { + keyBounds = meta.keyBoundsDefault; + if (meta.keyOverlapsDefault) noteLabelText = null; + } + } + + // Always draw labels (padsBase cache only has boxes, not text) + // Apply shake offset while note is playing (continuous shake) + const isPadPlaying = sounds[note] !== undefined; + const padNoteShake = noteShake[note] || (isPadPlaying ? 2 : 0); + const padShakeX = padNoteShake > 0 ? Math.floor((Math.random() - 0.5) * padNoteShake * 2) : 0; + const padShakeY = padNoteShake > 0 ? Math.floor((Math.random() - 0.5) * padNoteShake * 2) : 0; + + if (noteLabelText && noteLabelBounds) { + ink(0, 0, 0, 160).write( + noteLabelText, + { x: noteLabelBounds.x + 1 + padShakeX, y: noteLabelBounds.y + 1 + padShakeY }, + undefined, + undefined, + false, + noteFont, + ); + ink(...baseTextColor).write( + noteLabelText, + { x: noteLabelBounds.x + padShakeX, y: noteLabelBounds.y + padShakeY }, + undefined, + undefined, + false, + noteFont, + ); + } + + // � Repeat boxes for current note + if (song && note.toUpperCase() === song?.[songIndex][0]) { + let repeatCount = 0; + let currentNote = song[songIndex][0]; + // Count consecutive repeats of the same note + for (let i = songIndex; i < song.length; i++) { + if (song[i][0] === currentNote) { + repeatCount++; + } else { + break; + } + } + // Show solid boxes - one for each tap, stacked inside + for (let i = 0; i < repeatCount; i++) { + const inset = 6 + i * 3; // Start at 6px margin, each box gets progressively smaller + ink(0, 100, 120, 220).box( // Darker cyan for better contrast with white text + btn.box.x + inset, + btn.box.y + inset, + btn.box.w - inset * 2, + btn.box.h - inset * 2 + ); + } + } + // Fade in box for next note + else if (song && note.toUpperCase() === song?.[songIndex + 1]?.[0]) { + // Only show 1 box for the immediate next tap (not all repeats) + const inset = 6; + const fadeOpacity = Math.floor(songProgress * 100); // Reduced from 200 to 100 for more fade + if (fadeOpacity > 0) { + ink(0, 100, 120, fadeOpacity).box( // Darker cyan for better contrast + btn.box.x + inset, + btn.box.y + inset, + btn.box.w - inset * 2, + btn.box.h - inset * 2 + ); + } + } + + // 🧮 Transpose label + if (paintTransposeOverlay && lastActiveNote >= 0) { + let dist = index - lastActiveNote; + if (dist !== 0) { + // dist = dist > 0 ? "+" + dist : dist.toString(); + ink(dist > 0 ? "lime" : "red", transposeOverlayFade * 255).write( + abs(dist), + // btn.box.x + + // btn.box.w / 2 - + // ((dist.length + 1) * glyphWidth) / 2, + btn.box.x + 2 + note.length * glyphWidth, + btn.box.y + 1, + //btn.box.y + btn.box.h / 2 - glyphHeight / 2, + ); + } + } + + // Paint keyboard shortcuts (always - cache only has boxes) + if (keyLabel && keyBounds) { + ink(0, 0, 0, 120).write( + keyLabel, + { x: keyBounds.x + 1, y: keyBounds.y + 1 }, + undefined, + undefined, + false, + keyFont, + ); + ink("white", 180).write( + keyLabel, + { x: keyBounds.x, y: keyBounds.y }, + undefined, + undefined, + false, + keyFont, + ); + } + }); + } + + // 🎩👩 Piano Man + Sample Sally watch from above the keys. + // Disabled for now — character faces removed from the web version. + // if (!projector && !paintPictureOverlay) { + // paintCharacters({ ink }); + // } + + if (!projector && !usePadsBase) { + const gridAlpha = 40; + const topEdge = round(layout.topButtonY); + const bottomEdge = round(layout.topButtonY + layout.buttonHeight * layout.totalRows); + + if (!layout.splitLayout) { + const gridWidth = layout.buttonsPerRow * layout.buttonWidth; + const availableWidth = layout.usableWidth ?? screen.width; + // Left-align grid when mini inputs are to the right, otherwise center + const baseX = layout.miniInputsHorizontal + ? layout.margin + : ceil( + layout.margin + + Math.max(0, floor((availableWidth - layout.margin * 2 - gridWidth) / 2)), + ); + const leftEdge = baseX; + const rightEdge = round(baseX + gridWidth); + + for (let col = 0; col <= layout.buttonsPerRow; col += 1) { + const x = round(baseX + col * layout.buttonWidth); + ink(255, 255, 255, gridAlpha).line(x, topEdge, x, bottomEdge); + } + + for (let row = 0; row <= layout.totalRows; row += 1) { + const y = round(layout.topButtonY + row * layout.buttonHeight); + ink(255, 255, 255, gridAlpha).line(leftEdge, y, rightEdge, y); + } + } + } + } + + // 📊 Performance OSD (On-Screen Display) - toggle with ` key + // Uses MatrixChunky8 font and positions intelligently to avoid pads + if (perfOSD && !paintPictureOverlay && !projector) { + const font = "MatrixChunky8"; + const glyphW = 6; + const glyphH = 8; + const lineHeight = glyphH + 2; + const osdLines = 9; + const osdWidth = 24 * glyphW + 8; // ~152px + const osdHeight = osdLines * lineHeight + 4; + + // Position OSD above the button pads, on the right side + // Use layout metrics to find a safe spot + const padTop = layout?.topButtonY || (screen.height - 120); + const osdX = screen.width - osdWidth - 4; + const osdY = Math.max(OS_BAR_BOTTOM + 2, padTop - osdHeight - 4); + + // Semi-transparent background + ink(0, 0, 0, 210).box(osdX - 2, osdY - 2, osdWidth, osdHeight); + + // Border + ink(80, 80, 80).box(osdX - 2, osdY - 2, osdWidth, osdHeight, "outline"); + + let row = 0; + const writeRow = (color, text) => { + ink(color).write(text, { x: osdX, y: osdY + row * lineHeight }, undefined, undefined, false, font); + row++; + }; + + // Title + writeRow("orange", "PERF OSD [`]"); + + // Audio sample rate + const srStr = perfStats.sampleRate > 0 ? `${perfStats.sampleRate.toFixed(1)}kHz` : "--"; + writeRow("cyan", `RATE: ${srStr}`); + + // Audio context latencies (hardware) + const baseLatStr = perfStats.baseLatency > 0 ? `${perfStats.baseLatency.toFixed(1)}` : "--"; + const outLatStr = perfStats.outputLatency > 0 ? `${perfStats.outputLatency.toFixed(1)}` : "--"; + writeRow([180, 180, 180], `HW LAT: ${baseLatStr}/${outLatStr}ms`); + + // JS-side latency (key press to synth call) + const latStr = perfStats.latency > 0 ? `${perfStats.latency.toFixed(1)}ms` : "--"; + const avgStr = perfStats.avgLatency > 0 ? `${perfStats.avgLatency.toFixed(1)}ms` : "--"; + writeRow("lime", `JS LAT: ${latStr} AVG:${avgStr}`); + + // Min/Max latency + const minStr = perfStats.minLatency < Infinity ? `${perfStats.minLatency.toFixed(1)}` : "--"; + const maxStr = perfStats.maxLatency > 0 ? `${perfStats.maxLatency.toFixed(1)}` : "--"; + writeRow([120, 120, 120], `MIN/MAX: ${minStr}/${maxStr}ms`); + + // Sound counts + writeRow("yellow", `SND:${perfStats.soundCount} STK:${perfStats.tonesInStack} KEY:${perfStats.keysLength}`); + + // FPS and memory + const fpsColor = perfStats.fps >= 55 ? "lime" : perfStats.fps >= 30 ? "yellow" : "red"; + const memStr = perfStats.memoryUsed > 0 ? ` MEM:${perfStats.memoryUsed}MB` : ""; + writeRow(fpsColor, `FPS:${perfStats.fps}${memStr}`); + + // MIDI status + writeRow(midiConnected ? "lime" : [100, 100, 100], midiConnected ? "MIDI: CONNECTED" : "MIDI: --"); + + // Estimated total latency + const totalEst = perfStats.avgLatency + perfStats.baseLatency + perfStats.outputLatency; + const totalStr = totalEst > 0 ? `~${totalEst.toFixed(1)}ms` : "--"; + writeRow("white", `TOTAL EST: ${totalStr}`); + } + + // 🎹 Piano Roll Timeline - pixel-by-pixel held note history + // Can be horizontal (bottom-right) or vertical (center/right side based on layout) + // In single-column portrait mode, skip the horizontal piano roll entirely + if (!paintPictureOverlay && !projector && !recitalMode) { + const noteCount = buttonNotes.length; // 24 notes + + // Increment frame counter and check if we should scroll this frame + pianoRollFrameCounter = (pianoRollFrameCounter + 1) % PIANO_ROLL_SCROLL_DIVISOR; + const shouldScrollPianoRoll = pianoRollFrameCounter === 0; + + // Determine if we should use vertical layout based on available space + // In landscape mode, always prefer vertical roll on the right side + const isLandscapeScreen = screen.width > screen.height; + const useVerticalRoll = layout.splitLayout || layout.verticalTrack || isLandscapeScreen; + + // In single-column mode (non-split, portrait), hide piano roll entirely + const isSingleColumnPortrait = !layout.splitLayout && !isLandscapeScreen; + + if (useVerticalRoll) { + // 🎹 VERTICAL Piano Roll - time flows downward, notes spread horizontally + // Position: center of split layout, or right of single column + let rollX, rollY, rollW, rollH; + let skipRoll = false; + + if (layout.splitLayout) { + // In split layout: ONLY draw in center area, never on right side + if (layout.centerAreaWidth > noteCount + 4) { + // Expand to fill center area width + rollW = Math.max(noteCount, layout.centerAreaWidth - 8); + rollH = Math.min(PIANO_ROLL_WIDTH, layout.verticalTrackHeight || (screen.height - layout.hudReserved - layout.bottomPadding - 4)); + rollX = layout.centerX + Math.floor((layout.centerAreaWidth - rollW) / 2); + rollY = layout.hudReserved + 2; + } else { + // Not enough space in center - skip drawing entirely in split mode + skipRoll = true; + } + } else if (layout.verticalTrack) { + // Single column with vertical track space + rollW = Math.min(noteCount, layout.verticalTrackWidth || 36); + rollH = Math.min(PIANO_ROLL_WIDTH, screen.height - layout.hudReserved - layout.bottomPadding - 4); + rollX = layout.verticalTrackX || (screen.width - rollW - 2); + rollY = layout.verticalTrackY || layout.hudReserved + 2; + } else { + // Fallback for landscape: right side vertical roll + // Calculate available space on the right of the button grid + const gridWidth = layout.buttonsPerRow * layout.buttonWidth; + const availableRight = screen.width - gridWidth - layout.margin * 3; + rollW = Math.min(noteCount, Math.max(24, availableRight)); + rollH = Math.min(PIANO_ROLL_WIDTH, screen.height - (layout.hudReserved || 34) - (layout.bottomPadding || 2) - 4); + rollX = screen.width - rollW - 2; + rollY = (layout.hudReserved || 34) + 2; + } + + // Skip all drawing if there's no space (split mode with narrow center) + if (skipRoll) { + // Still update history even when not drawing + if (shouldScrollPianoRoll) { + pianoRollScrollPosition++; + for (let y = 0; y < PIANO_ROLL_WIDTH - 1; y++) { + pianoRollBeatHistory[y] = pianoRollBeatHistory[y + 1]; + } + pianoRollBeatHistory[PIANO_ROLL_WIDTH - 1] = 0; + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const row = pianoRollHistory[noteIdx]; + for (let y = 0; y < PIANO_ROLL_WIDTH - 1; y++) { + row[y] = row[y + 1]; + } + const note = buttonNotes[noteIdx]; + row[PIANO_ROLL_WIDTH - 1] = sounds[note] !== undefined ? 1 : 0; + } + } else { + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const note = buttonNotes[noteIdx]; + if (sounds[note] !== undefined) { + pianoRollHistory[noteIdx][PIANO_ROLL_WIDTH - 1] = 1; + } + } + } + } else { + // Update history: shift all rows up, add current state at bottom + // Only scroll on designated frames for slower movement + if (shouldScrollPianoRoll) { + pianoRollScrollPosition++; // Track total scroll for beat marker sync + + // Shift beat history + for (let y = 0; y < PIANO_ROLL_WIDTH - 1; y++) { + pianoRollBeatHistory[y] = pianoRollBeatHistory[y + 1]; + } + pianoRollBeatHistory[PIANO_ROLL_WIDTH - 1] = 0; // Clear newest slot (will be set by metronome tick) + + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const row = pianoRollHistory[noteIdx]; + // Shift up by 1 pixel (older history moves toward index 0) + for (let y = 0; y < PIANO_ROLL_WIDTH - 1; y++) { + row[y] = row[y + 1]; + } + // Set bottom pixel based on current note state + const note = buttonNotes[noteIdx]; + row[PIANO_ROLL_WIDTH - 1] = sounds[note] !== undefined ? 1 : 0; + } + } else { + // Even when not scrolling, update the current pixel to reflect held notes + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const note = buttonNotes[noteIdx]; + if (sounds[note] !== undefined) { + pianoRollHistory[noteIdx][PIANO_ROLL_WIDTH - 1] = 1; + } + } + } + + // 🎹 Draw mini piano keys above the track to show note relationship + const miniKeyH = 4; // Height of mini piano keys + const pianoY = rollY; // Piano at top of roll area + const trackStartY = rollY + miniKeyH + 1; // Track starts below piano + const actualRollH = rollH - miniKeyH - 1; // Adjust roll height for piano + + // Calculate note width to fill the available roll width + const noteWidth = Math.max(1, Math.floor(rollW / noteCount)); + const actualNoteAreaW = noteWidth * noteCount; // Actual width used by notes + const noteAreaX = rollX + Math.floor((rollW - actualNoteAreaW) / 2); // Center notes in roll + + // Draw subtle background for the whole area + ink(10, 10, 15, 180).box(rollX, rollY, rollW, rollH); + + // Draw mini piano keys at the top (white keys full color, black keys lighter) + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const note = buttonNotes[noteIdx]; + const x = noteAreaX + noteIdx * noteWidth; + const baseColor = getCachedColor(note, num); + const isActive = sounds[note] !== undefined; + const isBlack = note.includes('#'); + + if (isBlack) { + // Black keys: lighter version (0.55 instead of 0.3 for better visibility) + const lite = [Math.floor(baseColor[0] * 0.55), Math.floor(baseColor[1] * 0.55), Math.floor(baseColor[2] * 0.55)]; + ink(lite[0], lite[1], lite[2], isActive ? 255 : 200).box(x, pianoY, noteWidth, miniKeyH); + } else { + // White keys: full or slightly dimmed + ink(baseColor[0], baseColor[1], baseColor[2], isActive ? 255 : 140).box(x, pianoY, noteWidth, miniKeyH); + } + // Flash bright when active + if (isActive) { + ink(255, 255, 255, 120).box(x, pianoY, noteWidth, miniKeyH); + } + } + + // 🎨 Draw colored "groove" indicators - full height desaturated note colors + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const note = buttonNotes[noteIdx]; + const x = noteAreaX + noteIdx * noteWidth; + const baseColor = getCachedColor(note, num); + // Desaturate: blend toward gray (reduce saturation by ~70%) + const gray = (baseColor[0] + baseColor[1] + baseColor[2]) / 3; + const desat = [ + Math.round(baseColor[0] * 0.3 + gray * 0.7), + Math.round(baseColor[1] * 0.3 + gray * 0.7), + Math.round(baseColor[2] * 0.3 + gray * 0.7), + ]; + // Full height groove for each note lane + ink(desat[0], desat[1], desat[2], 35).box(x, trackStartY, noteWidth, actualRollH); + } + + // Draw each pixel - vertical layout (time = Y axis, notes = X axis) + // Optimized: pre-calculate base offset and skip empty pixels efficiently + const histBaseIdx = PIANO_ROLL_WIDTH - actualRollH; + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const row = pianoRollHistory[noteIdx]; + const x = noteAreaX + noteIdx * noteWidth; + // Cache color once per note column + const baseColor = getCachedColor(buttonNotes[noteIdx], num); + const r = baseColor[0], g = baseColor[1], b = baseColor[2]; + + // Only iterate if this note has any history + for (let yOff = 0; yOff < actualRollH; yOff++) { + if (row[histBaseIdx + yOff]) { + // Fade older notes (top = older) - simplified alpha calc + const alpha = Math.max(80, 255 - ((actualRollH - yOff) << 1)); + ink(r, g, b, alpha).box(x, trackStartY + yOff, noteWidth, 1); + } + } + } + + // Draw subtle grid lines for octave separation (vertical lines at note 12) + ink(40, 40, 50, 100).line(noteAreaX + 12 * noteWidth, trackStartY, noteAreaX + 12 * noteWidth, trackStartY + actualRollH - 1); + + // 🥁 Draw horizontal beat markers from recorded history + // These are actual metronome beats, not calculated intervals + // (reuse histBaseIdx from above) + for (let yOff = 0; yOff < actualRollH; yOff++) { + const beatValue = pianoRollBeatHistory[histBaseIdx + yOff]; + if (beatValue > 0) { + const lineY = trackStartY + yOff; + const isDownbeat = beatValue === 2; + // Current beat (at bottom) blinks + const isCurrentBeat = yOff === actualRollH - 1 && beatValue > 0; + const blinkAlpha = isCurrentBeat ? Math.floor(140 + Math.sin(metronomeVisualPhase * Math.PI * 2) * 80) : 0; + const baseAlpha = isDownbeat ? 120 : 55; + const alpha = isCurrentBeat ? Math.max(blinkAlpha, baseAlpha) : baseAlpha; + const color = isDownbeat ? [140, 160, 200] : [90, 100, 130]; + ink(color[0], color[1], color[2], alpha).line(rollX, lineY, rollX + rollW - 1, lineY); + } + } + } // End of skipRoll else block + + } else if (!isSingleColumnPortrait) { + // 🎹 HORIZONTAL Piano Roll (original) - time flows left-to-right, notes stacked vertically + // Skip in single-column portrait mode to avoid cluttering the compact layout + const rollHeight = noteCount; + const rollWidth = Math.min(PIANO_ROLL_WIDTH, screen.width); + const rollY = screen.height - rollHeight - 1; + const rollX = screen.width - rollWidth; + + // Update history: shift all columns left, add current state on right + // Only scroll on designated frames for slower movement + if (shouldScrollPianoRoll) { + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const row = pianoRollHistory[noteIdx]; + // Shift left by 1 pixel + for (let x = 0; x < PIANO_ROLL_WIDTH - 1; x++) { + row[x] = row[x + 1]; + } + // Set rightmost pixel based on current note state + const note = buttonNotes[noteIdx]; + row[PIANO_ROLL_WIDTH - 1] = sounds[note] !== undefined ? 1 : 0; + } + } else { + // Even when not scrolling, update the current pixel to reflect held notes + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const note = buttonNotes[noteIdx]; + if (sounds[note] !== undefined) { + pianoRollHistory[noteIdx][PIANO_ROLL_WIDTH - 1] = 1; + } + } + } + + // Draw subtle background + ink(10, 10, 15, 180).box(rollX, rollY, rollWidth, rollHeight); + + // 🎨 Draw colored "groove" indicators - full length desaturated note colors + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const note = buttonNotes[noteIdx]; + const y = rollY + noteIdx; + const baseColor = getCachedColor(note, num); + // Desaturate: blend toward gray + const gray = (baseColor[0] + baseColor[1] + baseColor[2]) / 3; + const desat = [ + Math.round(baseColor[0] * 0.4 + gray * 0.6), + Math.round(baseColor[1] * 0.4 + gray * 0.6), + Math.round(baseColor[2] * 0.4 + gray * 0.6), + ]; + // Full length groove for each note row + ink(desat[0], desat[1], desat[2], 40).box(rollX, y, rollWidth, 1); + } + + // Draw each pixel + for (let noteIdx = 0; noteIdx < noteCount; noteIdx++) { + const note = buttonNotes[noteIdx]; + const row = pianoRollHistory[noteIdx]; + const y = rollY + noteIdx; + const baseColor = getCachedColor(note, num); + + for (let x = 0; x < rollWidth; x++) { + const histIdx = PIANO_ROLL_WIDTH - rollWidth + x; + if (row[histIdx]) { + // Fade older notes slightly + const age = rollWidth - x; + const alpha = Math.max(80, 255 - age * 2); + ink(baseColor[0], baseColor[1], baseColor[2], alpha).box(rollX + x, y, 1, 1); + } + } + } + + // Draw subtle grid lines for octave separation + ink(40, 40, 50, 100).line(rollX, rollY + 12, rollX + rollWidth, rollY + 12); + } + } + + // 🥁 Metronome pulse post-process (sharpen) + if (metronomeEnabled && metronomeFlash > 0 && !paintPictureOverlay && !projector) { + const sharpenAmount = 0.8 * metronomeFlash; + if (typeof sharpen === "function") { + sharpen(sharpenAmount); + } else if (api?.sharpen) { + api.sharpen(sharpenAmount); + } + } +} + +let anyDown = true; + +const percDowns = {}; +const connectedGamepads = {}; +let miniMapActiveNote = null; +let miniMapActiveKey = null; +let topBarPianoActiveNote = null; // Track active note from top bar piano +let soundContext = null; + +function setSoundContext(ctx) { + soundContext = ctx; + maybeInitGM(); +} + +function lowerBaseOctave() { + return parseInt(octave) + lowerOctaveShift; +} + +function upperBaseOctave() { + return parseInt(octave) + 1 + upperOctaveShift; +} + +function midiNoteToRelayButton(noteNumber) { + if (typeof noteNumber !== "number") return null; + + const normalizedNote = ((noteNumber % 12) + 12) % 12; + const noteName = MIDI_NOTE_NAMES[normalizedNote]; + const noteOctave = Math.floor(noteNumber / 12) - 1; + const lowerOct = lowerBaseOctave(); + const upperOct = upperBaseOctave(); + + if (noteOctave === lowerOct && buttonNoteLookup.has(noteName)) { + return noteName; + } + + if (noteOctave === upperOct) { + const upperNote = `+${noteName}`; + if (buttonNoteLookup.has(upperNote)) return upperNote; + } + + if (noteOctave < lowerOct) { + const baseNote = noteOctave < lowerOct - 1 ? noteName : `+${noteName}`; + if (buttonNoteLookup.has(baseNote)) return baseNote; + } + + if (noteOctave > upperOct) { + const baseNote = noteOctave > upperOct + 1 ? noteName : `+${noteName}`; + if (buttonNoteLookup.has(baseNote)) return baseNote; + } + + if (buttonNoteLookup.has(noteName)) return noteName; + if (buttonNoteLookup.has(`+${noteName}`)) return `+${noteName}`; + return null; +} + +// Compute a stable voice identity from the current button + global +// wave/octave state. Used as the key into heldVoices so the same +// physical button press with a different wave or octave produces a +// distinct voice id (and re-pressing the same voice id is a re-trigger). +function computeVoiceId(buttonNote) { + let noteName = buttonNote; + let targetOctave = lowerBaseOctave(); + if (buttonNote.startsWith("+")) { + noteName = buttonNote.slice(1); + targetOctave = upperBaseOctave(); + } + return `${wave}:${targetOctave}:${noteName}`; +} + +// Kill every voice currently in the heldVoices map. Bound to BACKSPACE. +// Uses a quick fade so the clear feels percussive rather than cut. +function clearHeldVoices(fade = 0.05) { + if (heldVoices.size === 0) return false; + for (const [, entry] of heldVoices) { + entry?.sound?.kill?.(fade); + } + heldVoices.clear(); + return true; +} + +if (typeof window !== "undefined") { + window.__notepat_heldVoices = heldVoices; +} + +function startRelayButtonNote(buttonNote, velocity = 127) { + if (!buttonNote) return false; + + // Re-trigger: if a held voice already exists for this exact voiceId + // (same wave + same octave + same note), kill it before we start the + // fresh press. This is the "play notes over what is held" rule — + // including same note in same octave should restart from attack. + const voiceId = computeVoiceId(buttonNote); + const existingHeld = heldVoices.get(voiceId); + if (existingHeld) { + existingHeld.sound?.kill?.(0.02); + heldVoices.delete(voiceId); + } + + const num = soundContext?.num; + const synth = soundContext?.synth; + const freq = soundContext?.freq; + + if (song && buttonNote.toUpperCase() !== song?.[songIndex]?.[0]) { + synth?.({ + type: "noise-white", + tone: 1000, + duration: 0.05, + volume: 0.3, + attack: 0, + }); + return false; + } + + anyDown = true; + noteShake[buttonNote] = 3; + + let noteName = buttonNote; + let targetOctave = lowerBaseOctave(); + if (buttonNote.startsWith("+")) { + noteName = buttonNote.slice(1); + targetOctave = upperBaseOctave(); + } + + const tone = `${targetOctave}${noteName.toUpperCase()}`; + const active = orderedByCount(sounds); + + if (slide && active.length > 0) { + sounds[active[0]]?.sound?.update({ tone, duration: 0.1 }); + tonestack[buttonNote] = { + count: Object.keys(tonestack).length, + tone, + }; + sounds[buttonNote] = sounds[active[0]]; + if (sounds[buttonNote]) sounds[buttonNote].note = buttonNote; + delete sounds[active[0]]; + applyPitchBendToNotes([buttonNote], { immediate: true }); + } else { + tonestack[buttonNote] = { + count: Object.keys(tonestack).length, + tone, + }; + + const pan = getPanForButtonNote(buttonNote); + let soundHandle = makeNoteSound(tone, velocity, pan); + + if (!soundHandle || typeof soundHandle.kill !== "function") { + const velocityRatio = velocity === undefined ? 1 : velocity / 127; + const clampedRatio = num?.clamp + ? num.clamp(velocityRatio, 0, 1) + : Math.max(0, Math.min(1, velocityRatio)); + const minVelocityVolume = 0.05; + const fallbackVolume = + toneVolume * (minVelocityVolume + (1 - minVelocityVolume) * clampedRatio); + soundHandle = synth?.({ + type: "sine", + tone: freq?.(tone), + attack: quickFade ? 0.0015 : attack, + decay: 0.9, + duration: 0.4, + volume: fallbackVolume, + pan, + }); + } + + // Tag with voiceId + sustainMode so stopRelayButtonNote can decide + // whether to kill, long-fade, or move to the heldVoices map. ENTER + // wins over SHIFT if both are physically held at the press moment. + // Drum kit voices never sustain — percussion is intrinsically transient. + const isDrum = wave === "drum"; + const sustainMode = isDrum + ? null + : (enterHeld ? "enter" : (shiftHeld ? "shift" : null)); + sounds[buttonNote] = { + note: buttonNote, + count: active.length + 1, + sound: soundHandle, + voiceId, + sustainMode, + }; + + applyPitchBendToNotes([buttonNote], { immediate: true }); + + // 🎹 Mirror to /amxd. Don't republish events we received over the relay + // (would create a feedback loop with another notepat.com tab listening). + if (amxdPublishEnabled) { + const midi = toneToMidi(tone); + if (midi !== null) { + amxdActiveNotes.set(buttonNote, midi); + publishNotepatMidi("note_on", midi, Math.max(1, Math.min(127, velocity | 0)), 0); + } + } + + if (buttonNote.toUpperCase() === song?.[songIndex]?.[0]) { + songNoteDown = true; + } + + delete trail[buttonNote]; + + if (autopatApi) pictureAdd(autopatApi, tone); + } + + if (buttons[buttonNote]) { + buttons[buttonNote].down = true; + buttons[buttonNote].over = true; + } + + return true; +} + +function stopRelayButtonNote(buttonNote) { + if (!buttonNote) return; + + // 🎹 Mirror note_off to /amxd. Sustained voices (shift/enter) keep ringing + // locally but still get a MIDI note_off so Ableton doesn't latch them — + // the M4L device path is fire-and-forget, not stateful sustain. + if (amxdPublishEnabled && amxdActiveNotes.has(buttonNote)) { + publishNotepatMidi("note_off", amxdActiveNotes.get(buttonNote), 0, 0); + amxdActiveNotes.delete(buttonNote); + } + + const orderedTones = orderedByCount(tonestack); + const soundEntry = sounds[buttonNote]; + const sustainMode = soundEntry?.sustainMode; + + // Sustain modes: SHIFT-held → long decay tail; ENTER-held → no kill, + // sound persists in heldVoices until BACKSPACE clears it. Skip slide + // logic entirely for sustained voices (slide is for legato glide, + // sustain is the opposite — let it ring its own length). + if (sustainMode && soundEntry?.sound) { + if (sustainMode === "shift") { + soundEntry.sound.kill?.(SHIFT_DECAY_SECONDS); + } + // For "enter" mode we don't call kill at all — voice plays until + // clearHeldVoices kills it (backspace). + heldVoices.set(soundEntry.voiceId, { + sound: soundEntry.sound, + source: sustainMode, + startedAt: soundEntry.sound?.startedAt ?? null, + }); + } else if (slide && orderedTones.length > 1 && soundEntry) { + const previousKey = orderedTones[orderedTones.length - 2]; + const previousTone = tonestack[previousKey]?.tone; + if (previousTone) { + soundEntry.sound?.update?.({ tone: previousTone, duration: 0.1 }); + sounds[previousKey] = soundEntry; + if (sounds[previousKey]) sounds[previousKey].note = previousKey; + applyPitchBendToNotes([previousKey], { immediate: true }); + } + } else if (soundEntry?.sound) { + const lifespan = soundEntry.sound?.startedAt + ? performance.now() / 1000 - soundEntry.sound.startedAt + : 0.1; + const fade = max(0.075, min(lifespan, 0.15)); + soundEntry.sound.kill(quickFade ? fastFade : fade); + } + + if (buttonNote.toUpperCase() === song?.[songIndex]?.[0]) { + songIndex = (songIndex + 1) % song.length; + songNoteDown = false; + songShifting = true; + } + + delete tonestack[buttonNote]; + delete sounds[buttonNote]; + trail[buttonNote] = 1; + + if (buttons[buttonNote]) { + buttons[buttonNote].down = false; + buttons[buttonNote].over = false; + } +} + +function flushRelayMidiQueue() { + // Inbound relay notes are LISTENS, not local triggers — suppress amxd + // publish so we don't echo subscribed traffic back out as a fresh source. + amxdSuppressPublish = true; + try { + flushRelayMidiQueueInner(); + } finally { + amxdSuppressPublish = false; + } +} + +function flushRelayMidiQueueInner() { + if (relayNeedsPanic) { + for (const buttonNote of relayActiveNotes.values()) { + stopRelayButtonNote(buttonNote); + } + relayActiveNotes.clear(); + relayNeedsPanic = false; + } + + while (relayMidiQueue.length > 0) { + const event = relayMidiQueue.shift(); + const noteNumber = Number(event?.note); + if (!Number.isFinite(noteNumber)) continue; + + const key = [ + normalizeRelayHandle(event?.handle), + `${event?.machineId || "unknown"}`, + `${event?.channel ?? 0}`, + `${Math.round(noteNumber)}`, + ].join(":"); + + if (event?.event === "note_off" || Number(event?.velocity) === 0) { + const activeButtonNote = relayActiveNotes.get(key) || midiNoteToRelayButton(Math.round(noteNumber)); + if (activeButtonNote) stopRelayButtonNote(activeButtonNote); + relayActiveNotes.delete(key); + continue; + } + + const buttonNote = midiNoteToRelayButton(Math.round(noteNumber)); + if (!buttonNote) continue; + if (relayActiveNotes.has(key)) { + stopRelayButtonNote(relayActiveNotes.get(key)); + } + if (startRelayButtonNote(buttonNote, Number(event?.velocity) || 127)) { + relayActiveNotes.set(key, buttonNote); + } + } +} + +// 🎼 Switch the live GM patch. Awaitable; safely no-ops if a newer call +// supersedes this one mid-load. Triggered by the digit-buffered picker +// in `act()` and by incoming MIDI program-change messages. +async function setGmProgram(program, apiRef) { + const next = Math.max(0, Math.min(127, program | 0)); + gmProgram = next; + if (gmPatchLoading) return; + gmPatchLoading = true; + try { + const patch = await gm.loadPatch(next); + if (gmProgram === next) { + gmPatch = patch; + gmReady = true; + } + if (apiRef) buildWaveButton(apiRef); + } catch (err) { + console.warn("🎼 GM: loadPatch failed for", next, err); + } finally { + gmPatchLoading = false; + } +} + +function makeNoteSound(tone, velocity = 127, pan = 0) { + const synth = soundContext?.synth; + const play = soundContext?.play; + const freq = soundContext?.freq; + const num = soundContext?.num; + + if (!synth || !play || !freq) return null; + + // 📊 Track sound creation time for latency measurement + perfStats.lastSoundTime = performance.now(); + if (perfStats.lastKeyTime > 0) { + const latency = perfStats.lastSoundTime - perfStats.lastKeyTime; + perfStats.latency = latency; + perfStats.latencyHistory.push(latency); + if (perfStats.latencyHistory.length > 30) perfStats.latencyHistory.shift(); + perfStats.avgLatency = perfStats.latencyHistory.reduce((a, b) => a + b, 0) / perfStats.latencyHistory.length; + perfStats.maxLatency = Math.max(perfStats.maxLatency, latency); + perfStats.minLatency = Math.min(perfStats.minLatency, latency); + } + + const velocityRatioRaw = velocity === undefined ? 1 : velocity / 127; + const velocityRatio = num?.clamp + ? num.clamp(velocityRatioRaw, 0, 1) + : Math.max(0, Math.min(1, velocityRatioRaw)); + const minVelocityVolume = 0.05; // Keep a subtle floor so very light taps still play. + const volumeScale = minVelocityVolume + (1 - minVelocityVolume) * velocityRatio; + + if (wave === "gm") { + // 🎼 GM playback via lib/gm.mjs. Falls back to a sine oscillator if the + // patch isn't ready yet (manifest still loading, or asset host down). + // The shim mimics the synth handle shape — { startedAt, kill, update } — + // so the rest of notepat (panic, sustain, pitch-bend) doesn't have to + // care which backend played the note. Pitch-bend update is a no-op for + // GM voices in v1; gm.mjs doesn't expose a per-voice frequency setter. + if (gmReady && gmPatch) { + const hz = freq(tone); + const midi = Math.round(12 * Math.log2(hz / 440) + 69); + const ctx = + typeof window !== "undefined" ? /** @type {any} */ (window).audioContext : null; + const startedAt = ctx?.currentTime ?? performance.now() / 1000; + const noteHandle = gmPatch.play(midi, { + velocity: Math.round(velocityRatio * 127), + }); + return { + startedAt, + kill: (_fade) => noteHandle.release(), + update: () => {}, + }; + } + // Fallback while GM is still loading or unavailable. + return synth({ + type: "sine", + attack: quickFade ? 0.0015 : attack, + tone, + duration: "🔁", + volume: toneVolume * volumeScale, + pan, + }); + } else if (wave === "stample" || wave === "sample") { + const sampleId = stampleSampleId || startupSfx; + return play(sampleId, { + volume: volumeScale, + pitch: freq(tone), + pan, + loop: true, + }); + } else if (wave === "composite") { + let toneA, toneB, toneC, toneD, toneE; + const baseFreq = freq(tone); + + toneA = synth({ + type: "sine", + attack: 0.0025, + decay: 0.9, + tone: baseFreq, + duration: "🔁", + volume: toneVolume * volumeScale, + pan, + }); + + toneB = synth({ + type: "sine", + attack: 0.0025, + tone: baseFreq + 9 + num.randIntRange(-1, 1), + duration: "🔁", + volume: (toneVolume / 3) * volumeScale, + pan, + }); + + toneC = synth({ + type: "sawtooth", + attack, + decay: 0.9, + tone: baseFreq + num.randIntRange(-6, 6), + duration: 0.15 + num.rand() * 0.05, + volume: (toneVolume / 48) * volumeScale, + pan, + }); + + toneD = synth({ + type: "triangle", + attack: 0.999, + tone: baseFreq + 8 + num.randIntRange(-5, 5), + duration: "🔁", + volume: (toneVolume / 32) * volumeScale, + pan, + }); + + toneE = synth({ + type: "square", + attack: 0.05, + tone: baseFreq + num.randIntRange(-10, 10), + duration: "🔁", + volume: (toneVolume / 64) * volumeScale, + pan, + }); + + return { + startedAt: toneA?.startedAt || toneB.startedAt, + kill: (fade) => { + toneA?.kill(fade); + toneB?.kill(fade); + toneC?.kill(fade * 2); // TODO: Does not kill 1 shot sounds. 24.11.19.20.56 + toneD?.kill(fade * 1.4); + toneE?.kill(fade / 2); + }, + }; + } else { + // Map 'noise' shorthand to 'noise-white' for the synth + const synthType = wave === "noise" ? "noise-white" : wave; + return synth({ + type: synthType, + attack: quickFade ? 0.0015 : attack, + tone, + duration: "🔁", + volume: toneVolume * volumeScale, + pan, + }); + } +} + +function computePitchBendRatio() { + const semitoneOffset = midiPitchBendValue * MIDI_PITCH_BEND_RANGE; + return 2 ** (semitoneOffset / 12); +} + +function applyPitchBendToNotes(noteKeys, { immediate = false } = {}) { + const freq = soundContext?.freq; + if (!freq) return; + + const ratio = computePitchBendRatio(); + const targets = Array.isArray(noteKeys) && noteKeys.length > 0 + ? noteKeys + : Object.keys(sounds); + + targets.forEach((noteKey) => { + const soundEntry = sounds[noteKey]; + const toneInfo = tonestack[noteKey]; + if (!soundEntry?.sound?.update || !toneInfo?.tone) return; + + const baseFrequency = freq(toneInfo.tone); + if (typeof baseFrequency !== "number" || Number.isNaN(baseFrequency)) return; + + // For sample-based waves, use sampleSpeed instead of tone frequency + if (wave === "stample" || wave === "sample") { + // Calculate the target speed based on the base speed and pitch bend ratio + // The base speed is calculated from the original pitch / 440 (A4) + const baseSpeed = baseFrequency / 440; + const targetSpeed = baseSpeed * ratio; + const payload = { sampleSpeed: targetSpeed }; + try { + soundEntry.sound.update(payload); + } catch (err) { + console.warn("🎛️ Sample pitch bend update failed", { noteKey, err }); + } + } else { + const bentFrequency = baseFrequency * ratio; + const payload = immediate ? { tone: bentFrequency } : { tone: bentFrequency, duration: 0.05 }; + try { + soundEntry.sound.update(payload); + } catch (err) { + console.warn("🎛️ Pitch bend update failed", { noteKey, err }); + } + } + }); +} + +function startButtonNote(note, velocity = 127, apiRef = null) { + anyDown = true; + perfStats.lastKeyTime = performance.now(); + noteShake[note] = 3; // Trigger per-note typography shake + + if (song && note.toUpperCase() !== song?.[songIndex][0]) { + synth({ + type: "noise-white", + tone: 1000, + duration: 0.05, + volume: 0.3, + attack: 0, + }); + return false; + } + + if (downs[note]) return false; + + // 🥁 Drum voice: when the selected wave is "drum", every note (both octaves) + // fires a one-shot from the shared 12-drum kit in lib/percussion.mjs. Strip + // any octave prefix (++, +, -) and lowercase so "C", "+c", and "++c" all + // land on the same drum slot. The note's pan is derived from its key + // position like the pitched voices. + if (wave === "drum" && soundContext) { + const letter = note.replace(/^[+\-]+/, "").toLowerCase(); + const pan = getPanForButtonNote(note); + const volume = Math.max(0.1, velocity / 127); + console.log("🥁 drum:", letter, "from", note, "vol", volume.toFixed(2), "pan", pan.toFixed(2)); + playPercussion(soundContext, letter, { + volume, + pan, + pitchFactor: 1.0, + phase: "both", + }); + // 🎹 Mirror drum hits to /amxd at the same tone the note's letter would + // have played at, in the current base octave. Lets Ableton route to a + // drum rack keyed to the same letters notepat shows on its pads. + if (amxdPublishEnabled) { + const drumTone = `${parseInt(octave)}${letter.toUpperCase()}`; + const midi = toneToMidi(drumTone); + if (midi !== null) { + publishNotepatMidi("note_on", midi, Math.max(1, Math.min(127, velocity | 0)), 0); + // Drums are one-shot — release immediately so Ableton doesn't latch. + publishNotepatMidi("note_off", midi, 0, 0); + } + } + return true; + } + + let noteUpper = note.toUpperCase(); + keys += noteUpper; + const active = orderedByCount(sounds); + + let tempOctave = parseInt(octave); + + // Handle octave prefix modifiers: ++ (2 up), + (1 up), - (1 down) + if (note.startsWith("++")) { + noteUpper = noteUpper.replace(/^\+\+/, ""); + tempOctave += 2 + upperOctaveShift; + } else if (note.startsWith("+")) { + noteUpper = noteUpper.replace(/^\+/, ""); + tempOctave += 1 + upperOctaveShift; + } else if (note.startsWith("-")) { + noteUpper = noteUpper.replace(/^-/, ""); + tempOctave -= 1 + lowerOctaveShift; + } else { + tempOctave += lowerOctaveShift; + } + + const tone = `${tempOctave}${noteUpper}`; + + // Validate the tone can be parsed before trying to make a sound + const freq = soundContext?.freq; + if (freq) { + try { + freq(tone); // Test if it's valid + } catch (e) { + console.warn("Invalid note:", note, "tone:", tone); + return false; + } + } + + if (slide && active.length > 0) { + // For sample-based waves, use sampleSpeed; for synths, use tone frequency + const freq = soundContext?.freq; + if (wave === "stample" || wave === "sample") { + // Calculate sample speed from the target pitch + const targetFreq = freq ? freq(tone) : 440; + const targetSpeed = targetFreq / 440; // Speed ratio relative to A4 + sounds[active[0]]?.sound?.update({ sampleSpeed: targetSpeed }); + } else { + sounds[active[0]]?.sound?.update({ tone, duration: 0.1 }); + } + tonestack[note] = { + count: Object.keys(tonestack).length, + tone, + }; + sounds[note] = sounds[active[0]]; + if (sounds[note]) sounds[note].note = note; + delete sounds[active[0]]; + applyPitchBendToNotes([note], { immediate: true }); + } else { + tonestack[note] = { + count: Object.keys(tonestack).length, + tone, + }; + + // Voice id for held tracking. Drum mode never sustains. Same + // re-trigger rule as startRelayButtonNote: if a held voice with + // this exact id already exists, kill it before starting fresh. + const voiceId = `${wave}:${tempOctave}:${noteUpper.toLowerCase()}`; + const existingHeld = heldVoices.get(voiceId); + if (existingHeld) { + existingHeld.sound?.kill?.(0.02); + heldVoices.delete(voiceId); + } + const isDrum = wave === "drum"; + const sustainMode = isDrum + ? null + : (enterHeld ? "enter" : (shiftHeld ? "shift" : null)); + + const pan = getPanForButtonNote(note); + sounds[note] = { + note, + count: active.length + 1, + sound: makeNoteSound(tone, velocity, pan), + voiceId, + sustainMode, + }; + + applyPitchBendToNotes([note], { immediate: true }); + + if (note.toUpperCase() === song?.[songIndex][0]) { + songNoteDown = true; + } + + delete trail[note]; + + if (apiRef) { + pictureAdd(apiRef, tone); + } + udpServer?.send("tv", { note }); + + // 🎹 Mirror to /amxd (sibling to the relay path). + if (amxdPublishEnabled) { + const midi = toneToMidi(tone); + if (midi !== null) { + amxdActiveNotes.set(note, midi); + publishNotepatMidi("note_on", midi, Math.max(1, Math.min(127, velocity | 0)), 0); + } + } + } + + return true; +} + +function stopButtonNote(note, { force = false } = {}) { + if (downs[note]) return false; + + // 🎹 Mirror note_off to /amxd. + if (amxdPublishEnabled && amxdActiveNotes.has(note)) { + publishNotepatMidi("note_off", amxdActiveNotes.get(note), 0, 0); + amxdActiveNotes.delete(note); + } + + const orderedTones = orderedByCount(tonestack); + const soundEntry = sounds[note]; + const sustainMode = soundEntry?.sustainMode; + + if (sustainMode && soundEntry?.sound) { + // Sustained voice — long fade for shift, no kill for enter. Move + // into heldVoices keyed by voiceId so re-press of the same id + // can interrupt it (and BACKSPACE clears all of these). + if (sustainMode === "shift") { + soundEntry.sound.kill?.(SHIFT_DECAY_SECONDS); + } + heldVoices.set(soundEntry.voiceId, { + sound: soundEntry.sound, + source: sustainMode, + startedAt: soundEntry.sound?.startedAt ?? null, + }); + } else if (slide && orderedTones.length > 1 && soundEntry) { + const previousKey = orderedTones[orderedTones.length - 2]; + const previousTone = tonestack[previousKey]?.tone; + + // For sample-based waves, use sampleSpeed; for synths, use tone frequency + const freq = soundContext?.freq; + if (wave === "stample" || wave === "sample") { + const targetFreq = freq ? freq(previousTone) : 440; + const targetSpeed = targetFreq / 440; + soundEntry.sound?.update?.({ sampleSpeed: targetSpeed }); + } else { + soundEntry.sound?.update?.({ + tone: previousTone, + duration: 0.1, + }); + } + sounds[previousKey] = soundEntry; + if (sounds[previousKey]) sounds[previousKey].note = previousKey; + applyPitchBendToNotes([previousKey], { immediate: true }); + } else { + soundEntry?.sound?.kill?.(force ? fastFade : quickFade ? fastFade : killFade); + } + + trail[note] = 1; + + if (note.toUpperCase() === song?.[songIndex][0]) { + songIndex = (songIndex + 1) % song.length; + songNoteDown = false; + songShifting = true; + } + + delete tonestack[note]; + delete sounds[note]; + // Note: trail[note] is intentionally NOT deleted here so the trail animation can play + return true; +} + +function act({ + event: e, + sound: { synth, speaker, play, freq, midi: midiUtil, room, glitch }, + num, + pens, + hud, + screen, + painting, + api, +}) { + setSoundContext({ synth, play, freq, num }); + if (pendingAudioReinit && !audioReinitRequested && api?.send) { + if (e.is("touch") || e.is("keyboard:down")) { + api.send({ + type: "audio:reinit", + content: { + latencyHint: 0.005, + sampleRate: 48000, + speakerPerformanceMode: "disabled", + }, + }); + audioReinitRequested = true; + pendingAudioReinit = false; + // Return early - don't process this gesture as a note, let the reinit complete first + return; + } + } + if (e.is("reframed")) { + setupButtons(api); + buildOctButton(api); + buildWaveButton(api); + buildAbletonButton(api); + buildOsButton(api); + buildRecButton(api); + buildToggleButtons(api); + buildMetronomeButtons(api); + // Resize picture to quarter resolution (half width, half height) + const resizedPictureWidth = Math.max(1, Math.floor(screen.width / 2)); + const resizedPictureHeight = Math.max(1, Math.floor(screen.height / 2)); + picture = painting(resizedPictureWidth, resizedPictureHeight, ({ wipe }) => { + wipe("gray"); + }); + } + + if ( + autopatConfig.enabled && + autopatConfig.ignoreInput && + (e.is("touch") || + e.is("lift") || + e.is("keyboard:down") || + e.is("keyboard:up") || + e.is("gamepad")) + ) { + return; + } + + if (e.is("gamepad")) { + const gpIndex = e.gamepad ?? 0; + if (!connectedGamepads[gpIndex]) { + connectedGamepads[gpIndex] = { + id: e.gamepadId || null, + pressedButtons: [], + axes: {}, + lastEvent: null, + }; + } + + const gp = connectedGamepads[gpIndex]; + + if (e.gamepadId && !gp.id) { + gp.id = e.gamepadId; + } + + gp.lastEvent = e.name; + + if (e.button !== undefined) { + const buttonIndex = e.button; + if (e.action === "push") { + if (!gp.pressedButtons.includes(buttonIndex)) { + gp.pressedButtons.push(buttonIndex); + } + } else if (e.action === "release") { + gp.pressedButtons = gp.pressedButtons.filter((b) => b !== buttonIndex); + } + } + + if (e.axis !== undefined) { + const axisIndex = e.axis; + const value = e.value; + if (Math.abs(value) > 0.1) { + gp.axes[axisIndex] = value.toFixed(2); + } else { + delete gp.axes[axisIndex]; + } + } + } + + // 🎭 In recital mode, tap on top bar (BACK button) to exit recital mode + if (recitalMode && e.is("touch") && e.y < TOP_BAR_BOTTOM) { + recitalMode = false; + return; + } + + // � Handle top bar piano touches (before recital mode toggle check) + if (e.is("touch") && e.y < TOP_BAR_BOTTOM && !projector && !paintPictureOverlay && !recitalMode) { + const topBarPianoNote = getTopBarPianoNoteAt(e.x, e.y, screen); + if (topBarPianoNote) { + // Stop any previous top bar piano note + if (topBarPianoActiveNote && topBarPianoActiveNote !== topBarPianoNote) { + stopButtonNote(topBarPianoActiveNote, { force: true }); + } + // Start the new note if different + if (topBarPianoActiveNote !== topBarPianoNote) { + startButtonNote(topBarPianoNote, 127, api); + } + topBarPianoActiveNote = topBarPianoNote; + return; // Don't process further (don't toggle recital mode) + } + } + + // 🎹 Handle lift for top bar piano + if (e.is("lift") && topBarPianoActiveNote) { + stopButtonNote(topBarPianoActiveNote, { force: true }); + topBarPianoActiveNote = null; + } + + // 🎭 Tap on top bar to toggle recital mode (minimal wireframe UI) + // Only in visualizer area (between piano end and waveBtn), not on piano keys + if (e.is("touch") && e.y < TOP_BAR_BOTTOM && !projector && !paintPictureOverlay && !recitalMode) { + // Check that tap is in the visualizer area (after piano, before waveBtn) + const topBarBase = dotComMode ? 75 : 54; + const topPianoWidth = Math.min(140, Math.floor((screen.width - topBarBase) * 0.5)); + const topPianoEndX = topBarBase + topPianoWidth; + const vizLeft = topPianoEndX; // Start after piano + const vizRight = Math.min( + osBtn?.box?.x ?? Infinity, + abletonBtn?.box?.x ?? Infinity, + waveBtn?.box?.x ?? screen.width, + ) - 1; + if (e.x >= vizLeft && e.x <= vizRight) { + recitalMode = true; + recitalBlinkPhase = 0; + } + } + + if ((e.is("touch") || e.is("lift")) && !paintPictureOverlay && !projector) { + const sampleRateText = getSampleRateText(speaker?.sampleRate); + const sampleRateLabel = sampleRateText ? MIDI_RATE_LABEL_TEXT : null; + const showTrack = Boolean(song) && autopatConfig.showTrack !== false; + const cachedLayout = getCachedLayout(screen, { + songMode: Boolean(showTrack), + pictureOverlay: paintPictureOverlay, + rateText: sampleRateText, + rateLabel: sampleRateLabel, + sidePanelWidth: autopatConfig.sidePanelWidth, + }); + const layout = cachedLayout.layout; + + // Skip mini input interactions in recital mode (wireframe-only rendering) + if (layout.miniInputsEnabled && !recitalMode) { + + const trackHeight = showTrack ? TRACK_HEIGHT : 0; + const trackY = showTrack ? OS_BAR_BOTTOM : null; + const pianoGeometry = getMiniPianoGeometry({ + screen, + layout, + song: showTrack ? song : null, + trackY, + trackHeight, + }); + + if (e.is("touch")) { + const velocity = e.velocity ?? 1; + // NOTE: Mini piano hit testing disabled since painting is disabled (uses top bar piano instead) + // const pianoNote = getMiniPianoNoteAt(e.x, e.y, pianoGeometry); + + // Only check QWERTY hits if minimap is visible (compact mode or song, not rotated/hidden/split) + const qwertyVisible = (layout.compactMode || Boolean(song)) && !pianoGeometry.rotated && !pianoGeometry.hidden && !layout.splitLayout; + if (qwertyVisible) { + const key = getQwertyKeyAt(e.x, e.y, pianoGeometry); + if (key) { + const mappedNote = keyboardKeyToNote(key); + miniMapActiveKey = key; + if (mappedNote) { + if (miniMapActiveNote && miniMapActiveNote !== mappedNote) { + stopButtonNote(miniMapActiveNote, { force: true }); + } + if (miniMapActiveKey && miniMapActiveKey !== key) { + triggerPercKey(miniMapActiveKey, 1, false); + } + if (miniMapActiveNote !== mappedNote) { + startButtonNote(mappedNote, 127, api); + } + miniMapActiveNote = mappedNote; + } else { + if (miniMapActiveNote) { + stopButtonNote(miniMapActiveNote, { force: true }); + miniMapActiveNote = null; + } + if (miniMapActiveKey && miniMapActiveKey !== key) { + triggerPercKey(miniMapActiveKey, 1, false); + } + triggerPercKey(key, velocity, true); + } + return; + } + } + } + + if (e.is("lift")) { + if (miniMapActiveNote) { + stopButtonNote(miniMapActiveNote, { force: true }); + } + if (miniMapActiveKey) { + triggerPercKey(miniMapActiveKey, 1, false); + } + miniMapActiveNote = null; + miniMapActiveKey = null; + } + } + } + + if (e.is("keyboard:down:.") && !e.repeat) { + upperOctaveShift += 1; + } + + if (e.is("keyboard:down:,") && !e.repeat) { + upperOctaveShift -= 1; + } + + // 🏠 Room mode toggle (/ key) - global reverb + if (e.is("keyboard:down:/") && !e.repeat) { + roomMode = !roomMode; + room.toggle(); + } + + // 🔇 BACKSPACE — clear all held (shift-decaying / enter-latched) voices. + // Falls through to the original glitch-mode toggle when no held voices + // exist, so the binding still serves both purposes without surprise. + if (e.is("keyboard:down:backspace") && !e.repeat) { + if (!clearHeldVoices()) { + glitchMode = !glitchMode; + glitch?.toggle?.(); + cleanupOrphanedSounds(pens, true); + } + } + + // 🔒 SHIFT held → next press uses short-attack / long-decay envelope. + // Both physical shifts qualify; left/right shift's own toggles + // (quickFade, slide) still fire on shift-down — they're orthogonal to + // the held-state tracking that the press dispatcher reads. + if (e.is("keyboard:down:shift")) shiftHeld = true; + if (e.is("keyboard:up:shift")) shiftHeld = false; + + // 🔒 ENTER held → next press latches the voice into heldVoices until + // backspace clears it. ENTER wins over SHIFT when both are held. + if (e.is("keyboard:down:enter")) enterHeld = true; + if (e.is("keyboard:up:enter")) enterHeld = false; + + if ( + e.is("keyboard:down") && + !e.repeat && + e.key === "Shift" && + e.code === "ShiftLeft" + ) { + quickFade = !quickFade; + } + + if ( + e.is("keyboard:down") && + !e.repeat && + e.key === "Shift" && + e.code === "ShiftRight" + ) { + // console.log("Code:", e.code); + slide = !slide; + + if (slide && Object.keys(tonestack).length > 1) { + const orderedTones = orderedByCount(tonestack); + orderedTones.forEach((tone, index) => { + if (index > 0) { + sounds[tone]?.sound.kill(quickFade ? fastFade : fade); // Kill a sound if it exists. + trail[tone] = 1; + delete tonestack[tone]; // Remove this key from the notestack. + delete sounds[tone]; + if (buttons[tone]) buttons[tone].down = false; + } + }); + } + // Clean up any orphaned sounds when toggling slide + cleanupOrphanedSounds(pens, true); + } + + // if ( + // e.is("keyboard:down:control") || + // (e.is("keyboard:down:capslock") && !e.repeat) + // ) { + // lowerOctaveShift += 1; + // } + + if (e.is("keyboard:down:tab") && !e.repeat) { + api.beep(); + waveIndex = (waveIndex + 1) % wavetypes.length; + wave = wavetypes[waveIndex]; + buildWaveButton(api); + buildAbletonButton(api); + buildOsButton(api); + buildRecButton(api); + } + + // 🎼 GM digit-buffered patch picker — top-row 0-9 like menuband. Each + // digit press updates the live GM program; the buffer auto-clears after + // GM_DIGIT_TIMEOUT_MS of inactivity or when a non-digit key arrives. + // NuPhy WebHID also delivers digits through this channel, so hardware + // presses pick patches too. + { + const isKbdDown = e.is("keyboard:down") && !e.repeat; + const digit = isKbdDown && /^[0-9]$/.test(e.key) ? e.key : null; + const now = performance.now(); + + if (gmDigitBuffer && now > gmDigitBufferDeadline) { + gmDigitBuffer = ""; + } + + if (digit) { + if (gmDigitBuffer.length >= 3) gmDigitBuffer = ""; + gmDigitBuffer += digit; + gmDigitBufferDeadline = now + GM_DIGIT_TIMEOUT_MS; + + // "0" / "00" / "000" → program 0; otherwise typed value clamped 0-127. + const v = parseInt(gmDigitBuffer, 10); + const program = Math.max(0, Math.min(127, v)); + + // Friendly auto-switch: typing a digit while in a legacy wave snaps + // the wave switcher to "gm" so the user actually hears the program + // they just picked. + if (wave !== "gm") { + waveIndex = wavetypes.indexOf("gm"); + if (waveIndex >= 0) { + wave = "gm"; + buildAbletonButton(api); + buildOsButton(api); + buildRecButton(api); + } + } + + setGmProgram(program, api); + buildWaveButton(api); // Immediate label refresh — shows the buffer live. + api.beep(); + return; + } + + // Non-digit keypress clears any pending buffer (the live program was + // already applied per-digit, so nothing to commit). + if (isKbdDown && gmDigitBuffer) { + gmDigitBuffer = ""; + } + } + + // if (e.is("keyboard:down:shift") && !e.repeat) { + // lowerOctaveShift -= 1; + // } + + if (e.is("keyboard:down:-")) paintTransposeOverlay = !paintTransposeOverlay; + if (e.is("keyboard:down:=")) { + paintPictureOverlay = !paintPictureOverlay; + hud.label(overlayMode || paintPictureOverlay ? undefined : "notepat"); + setupButtons(api); + } + if (e.is("keyboard:down:\\")) projector = !projector; + + // � Performance OSD toggle (backtick key) + if (e.is("keyboard:down:`") && !e.repeat) { + perfOSD = !perfOSD; + } + + // �🚨 PANIC BUTTON: Press Escape to stop all stuck sounds + if (e.is("keyboard:down:escape")) { + console.log("🚨 PANIC: Force stopping all sounds!"); + Object.keys(sounds).forEach(note => { + console.log("🔇 Force killing stuck sound:", note); + sounds[note]?.sound?.kill(0.01); // Very fast kill + }); + // Clear all state + Object.keys(sounds).forEach(note => delete sounds[note]); + Object.keys(tonestack).forEach(note => delete tonestack[note]); + Object.keys(trail).forEach(note => delete trail[note]); + console.log("🧹 All audio state cleared"); + } + + // if (e.is("keyboard:down:arrowleft")) { + // scopeTrim -= 1; + // if (scopeTrim < 0) scopeTrim = 0; + // } + + // if (e.is("keyboard:down:arrowright")) { + // scopeTrim += 1; + // } + + /* + if (e.is("keyboard:down:arrowdown")) { + scope -= 1; + if (scope < 0) scope = 0; + } + + if (e.is("keyboard:down:arrowup")) { + scope += 1; + if (scope > tones.waveforms.left.length) { + scope = tones.waveforms.left.length; + } + } + */ + + //if (tap) { + // if (e.is("keyboard:down:shift") && !e.repeat) hold = true; + // if (e.is("keyboard:up:shift")) hold = false; + //} else { + // if (e.is("keyboard:down:shift") && !e.repeat) sharps = true; + // if (e.is("keyboard:up:shift")) sharps = false; + // if (e.is("keyboard:down:alt") && !e.repeat) flats = true; + // if (e.is("keyboard:up:alt")) flats = false; + //} + + // 🥁 Percussion Sound Design + // Each drum uses layered synthesis for a more musical sound + // Velocity (0-127) scales volume for expressive playing + const pc = "maroon"; + + // Kick drum - 808-style with BIG sustained BOOOOOOM + const makeKick = (velocity = 127) => { + const vel = velocity / 127; // Normalize to 0-1 + + // === LAYER 1: THE BIG BOOM - long sustained sub === + // This is the BOOOOOooooom that sustains and rumbles + synth({ + type: "sine", + tone: 38, // Very deep sub + duration: 0.6, // LONG tail for that 808 sustain + attack: 0.001, + decay: 0.5, // Slower decay = longer boom + volume: 1.0 * vel, + }); + + // === LAYER 2: Sub harmonic for warmth === + synth({ + type: "sine", + tone: 55, // Octave-ish above for body + duration: 0.5, + attack: 0.001, + decay: 0.55, + volume: 0.7 * vel, + }); + + // === LAYER 3: Punch body (the THUMP) === + synth({ + type: "sine", + tone: 90, + duration: 0.07, + attack: 0, + decay: 0.85, + volume: 0.8 * vel, + }); + + // === LAYER 4: Click transient (the SNAP) === + synth({ + type: "square", + tone: 1200, + duration: 0.008, + attack: 0, + decay: 0.99, + volume: 0.5 * vel, + }); + + // === LAYER 5: Noise burst for attack presence === + synth({ + type: "noise-white", + tone: 800, + duration: 0.012, + attack: 0, + decay: 0.95, + volume: 0.4 * vel, + }); + + // === LAYER 6: Mid knock (beater sound) === + synth({ + type: "triangle", + tone: 150, + duration: 0.04, + attack: 0, + decay: 0.9, + volume: 0.5 * vel, + }); + + // === LAYER 7: Extra low end sustain === + // Second sub layer that decays even slower + synth({ + type: "sine", + tone: 42, + duration: 0.8, // Even longer! + attack: 0.01, + decay: 0.4, // Very slow decay + volume: 0.6 * vel, + }); + }; + + // Snare - layered noise at different pitches for character + const makeSnare = (velocity = 127) => { + const vel = velocity / 127; + // High "snap" - bright filtered noise + synth({ + type: "noise-white", + tone: 5000, + duration: 0.1, + attack: 0, + decay: 0.85, + volume: 0.5 * vel, + }); + // Mid "body" - pitched noise around snare resonance + synth({ + type: "noise-white", + tone: 250, // Pitched noise for snare body character + duration: 0.15, + attack: 0.001, + decay: 0.88, + volume: 0.45 * vel, + }); + // Fundamental tone - the "drum" part + synth({ + type: "triangle", + tone: 180, + duration: 0.1, + attack: 0, + decay: 0.9, + volume: 0.35 * vel, + }); + }; + + // Hi-hat - layered high-pitched noise for shimmer + const makeHihat = (velocity = 127) => { + const vel = velocity / 127; + // Main hat - very high pitched noise + synth({ + type: "noise-white", + tone: 10000, + duration: 0.05, + attack: 0, + decay: 0.8, + volume: 0.3 * vel, + }); + // Shimmer layer - slightly lower for body + synth({ + type: "noise-white", + tone: 7000, + duration: 0.04, + attack: 0, + decay: 0.75, + volume: 0.2 * vel, + }); + }; + + // Open hi-hat / ride - longer decay with bell character + const makeRide = (velocity = 127) => { + const vel = velocity / 127; + // Main body - mid-high pitched noise + synth({ + type: "noise-white", + tone: 6000, + duration: 0.3, + attack: 0.005, + decay: 0.65, + volume: 0.25 * vel, + }); + // Shimmer - higher pitched noise + synth({ + type: "noise-white", + tone: 9000, + duration: 0.2, + attack: 0.01, + decay: 0.6, + volume: 0.2 * vel, + }); + // Bell tone - adds pitch character + synth({ + type: "sine", + tone: 800, + duration: 0.18, + attack: 0.005, + decay: 0.75, + volume: 0.12 * vel, + }); + }; + + // Crash cymbal - wide frequency spread + const makeCrash = (velocity = 127) => { + const vel = velocity / 127; + // Low body + synth({ + type: "noise-white", + tone: 3000, + duration: 0.5, + attack: 0.001, + decay: 0.55, + volume: 0.35 * vel, + }); + // Mid shimmer + synth({ + type: "noise-white", + tone: 6000, + duration: 0.45, + attack: 0.001, + decay: 0.5, + volume: 0.4 * vel, + }); + // High sparkle + synth({ + type: "noise-white", + tone: 10000, + duration: 0.35, + attack: 0, + decay: 0.45, + volume: 0.3 * vel, + }); + }; + + // Low tom + const makeTomLow = (velocity = 127) => { + const vel = velocity / 127; + synth({ + type: "sine", + tone: 100, + duration: 0.2, + attack: 0.001, + decay: 0.9, + volume: 0.7 * vel, + }); + synth({ + type: "triangle", + tone: 180, + duration: 0.1, + attack: 0, + decay: 0.85, + volume: 0.3 * vel, + }); + }; + + // High tom + const makeTomHigh = (velocity = 127) => { + const vel = velocity / 127; + synth({ + type: "sine", + tone: 160, + duration: 0.15, + attack: 0.001, + decay: 0.88, + volume: 0.6 * vel, + }); + synth({ + type: "triangle", + tone: 280, + duration: 0.08, + attack: 0, + decay: 0.82, + volume: 0.25 * vel, + }); + }; + + // Legacy makePerc for backwards compatibility (clicky sounds) + const makePerc = (hz) => { + synth({ + type: "triangle", + tone: hz / 2, + duration: 0.01, + attack: 0, + volume: 0.5, + }); + synth({ type: "sawtooth", tone: hz, duration: 0.0025, volume: 1 }); + synth({ + type: "square", + tone: hz / 4, + duration: 0.005, + volume: 0.95, + decay: 0.999, + }); + }; + + // Helper to trigger percussion keys (alt, arrows) + // Note: Space is no longer percussion - it toggles metronome now + const triggerPercKey = (key, velocity = 1, isDown = true) => { + // Space key removed - now used for metronome toggle + if (key === "alt" && isDown) { + perc = pc; + percDowns.alt = true; + makeCrash(velocity); + return true; + } + if (key === "alt" && !isDown) { + delete percDowns.alt; + return true; + } + if (key === "left") { + if (isDown && !percDowns.left) { + perc = "brown"; + percDowns.left = true; + makeTomLow(velocity); + } else if (!isDown) { + delete percDowns.left; + } + return true; + } + if (key === "down") { + if (isDown && !percDowns.down) { + perc = "pink"; + percDowns.down = true; + makeKick(velocity); + } else if (!isDown) { + delete percDowns.down; + } + return true; + } + if (key === "right") { + if (isDown && !percDowns.right) { + perc = "orange"; + percDowns.right = true; + makeTomHigh(velocity); + } else if (!isDown) { + delete percDowns.right; + } + return true; + } + if (key === "up") { + if (isDown && !percDowns.up) { + perc = "cyan"; + percDowns.up = true; + makeHihat(velocity); + } else if (!isDown) { + delete percDowns.up; + } + return true; + } + return false; + }; + + const lowerBaseOctave = () => parseInt(octave) + lowerOctaveShift; + const upperBaseOctave = () => parseInt(octave) + 1 + upperOctaveShift; + + const flatToSharp = { + DB: "C#", + EB: "D#", + GB: "F#", + AB: "G#", + BB: "A#", + }; + + const MIDI_NOTE_ON = 0x90; + const MIDI_NOTE_OFF = 0x80; + const MIDI_PITCH_BEND = 0xe0; + const MIDI_PROGRAM_CHANGE = 0xc0; + + const midiNoteToButton = (noteNumber) => { + if (!midiUtil?.note || typeof noteNumber !== "number") return null; + + const raw = midiUtil.note(noteNumber); + if (!raw) return null; + + const normalized = raw.toUpperCase(); + const match = normalized.match(/^([A-G])([#B]?)(-?\d+)$/); + if (!match) return null; + + let [, letter, accidental, octaveStr] = match; + + if (accidental === "B") { + const enharmonic = flatToSharp[`${letter}${accidental}`]; + if (!enharmonic) return null; + letter = enharmonic[0]; + accidental = enharmonic[1] || ""; + } + + const noteName = (letter + accidental).toLowerCase(); + const noteOctave = parseInt(octaveStr, 10); + + if (Number.isNaN(noteOctave)) return null; + + const lowerOct = lowerBaseOctave(); + const upperOct = upperBaseOctave(); + + if (noteOctave === lowerOct && buttonNoteLookup.has(noteName)) { + return noteName; + } + if (noteOctave === upperOct) { + const upperNote = `+${noteName}`; + if (buttonNoteLookup.has(upperNote)) { + return upperNote; + } + } + + if (noteOctave < lowerOct) { + const offset = (lowerOct - noteOctave) % 2; + const baseNote = offset === 0 ? noteName : `+${noteName}`; + if (buttonNoteLookup.has(baseNote)) { + return baseNote; + } + } + + if (noteOctave > upperOct) { + const offset = (noteOctave - upperOct) % 2; + const baseNote = offset === 0 ? `+${noteName}` : noteName; + if (buttonNoteLookup.has(baseNote)) { + return baseNote; + } + } + + if (buttonNoteLookup.has(noteName)) { + console.warn("🎹 MIDI note folded to base octave:", { + noteNumber, + raw, + mappedTo: noteName, + }); + return noteName; + } + + const sharpCandidate = `+${noteName}`; + if (buttonNoteLookup.has(sharpCandidate)) { + console.warn("🎹 MIDI note folded to upper octave:", { + noteNumber, + raw, + mappedTo: sharpCandidate, + }); + return sharpCandidate; + } + + console.warn("🎹 MIDI note unmapped:", { noteNumber, raw, lowerOct, upperOct }); + return null; + }; + + const startMidiButtonNote = (buttonNote, velocity = 127) => { + if (!buttonNote) return false; + + if (song && buttonNote.toUpperCase() !== song?.[songIndex][0]) { + synth({ + type: "noise-white", + tone: 1000, + duration: 0.05, + volume: 0.3, + attack: 0, + }); + return false; + } + + anyDown = true; + noteShake[buttonNote] = 3; // Trigger per-note typography shake + + let noteName = buttonNote; + let targetOctave = lowerBaseOctave(); + if (buttonNote.startsWith("+")) { + noteName = buttonNote.slice(1); + targetOctave = upperBaseOctave(); + } + + const noteUpper = noteName.toUpperCase(); + const tone = `${targetOctave}${noteUpper}`; + const active = orderedByCount(sounds); + + if (slide && active.length > 0) { + sounds[active[0]]?.sound?.update({ tone, duration: 0.1 }); + tonestack[buttonNote] = { + count: Object.keys(tonestack).length, + tone, + }; + sounds[buttonNote] = sounds[active[0]]; + if (sounds[buttonNote]) sounds[buttonNote].note = buttonNote; + delete sounds[active[0]]; + applyPitchBendToNotes([buttonNote], { immediate: true }); + } else { + tonestack[buttonNote] = { + count: Object.keys(tonestack).length, + tone, + }; + + const pan = getPanForButtonNote(buttonNote); + let soundHandle = makeNoteSound(tone, velocity, pan); + + if (!soundHandle || typeof soundHandle.kill !== "function") { + const velocityRatio = velocity === undefined ? 1 : velocity / 127; + const clampedRatio = num?.clamp + ? num.clamp(velocityRatio, 0, 1) + : Math.max(0, Math.min(1, velocityRatio)); + const minVelocityVolume = 0.05; + const fallbackVolume = + toneVolume * (minVelocityVolume + (1 - minVelocityVolume) * clampedRatio); + soundHandle = synth({ + type: "sine", + tone: freq(tone), + attack: quickFade ? 0.0015 : attack, + decay: 0.9, + duration: 0.4, + volume: fallbackVolume, + pan, + }); + } + + sounds[buttonNote] = { + note: buttonNote, + count: active.length + 1, + sound: soundHandle, + }; + + applyPitchBendToNotes([buttonNote], { immediate: true }); + + if (buttonNote.toUpperCase() === song?.[songIndex][0]) { + songNoteDown = true; + } + + delete trail[buttonNote]; + + pictureAdd(api, tone); + udpServer?.send("tv", { note: buttonNote }); + } + + if (buttons[buttonNote]) { + buttons[buttonNote].down = true; + buttons[buttonNote].over = true; + } + + return true; + }; + + const stopMidiButtonNote = (buttonNote) => { + if (!buttonNote) return; + + const orderedTones = orderedByCount(tonestack); + + if (slide && orderedTones.length > 1 && sounds[buttonNote]) { + const previousKey = orderedTones[orderedTones.length - 2]; + const previousTone = tonestack[previousKey]?.tone; + if (previousTone) { + sounds[buttonNote]?.sound?.update({ tone: previousTone, duration: 0.1 }); + sounds[previousKey] = sounds[buttonNote]; + if (sounds[previousKey]) sounds[previousKey].note = previousKey; + applyPitchBendToNotes([previousKey], { immediate: true }); + } + } else if (sounds[buttonNote]?.sound) { + const soundEntry = sounds[buttonNote]; + const lifespan = soundEntry.sound?.startedAt + ? performance.now() / 1000 - soundEntry.sound.startedAt + : 0.1; + const fade = max(0.075, min(lifespan, 0.15)); + soundEntry.sound.kill(quickFade ? fastFade : fade); + } + + if (buttonNote.toUpperCase() === song?.[songIndex][0]) { + songIndex = (songIndex + 1) % song.length; + songNoteDown = false; + songShifting = true; + } + + delete tonestack[buttonNote]; + delete sounds[buttonNote]; + trail[buttonNote] = 1; + + if (buttons[buttonNote]) { + buttons[buttonNote].down = false; + buttons[buttonNote].over = false; + } + + if (melodyAliasActiveNote === buttonNote) { + melodyAliasDown = false; + melodyAliasActiveNote = null; + melodyAliasStartedNote = false; + } + }; + + if (e.is("midi:keyboard")) { + midiConnected = true; + + const status = e.data?.[0] ?? 0; + const noteNumber = e.data?.[1]; + const velocity = e.data?.[2] ?? 0; + const command = status & 0xf0; + + if (command === MIDI_PITCH_BEND) { + const lsb = e.data?.[1] ?? 0; + const msb = e.data?.[2] ?? 0; + const rawValue = (msb << 7) | lsb; // 0 - 16383 + const normalized = (rawValue - 8192) / 8192; + const clamped = num?.clamp + ? num.clamp(normalized, -1, 1) + : Math.max(-1, Math.min(1, normalized)); + + if (clamped !== midiPitchBendValue) { + midiPitchBendValue = clamped; + applyPitchBendToNotes(undefined, { immediate: true }); + } + + return; + } + + // 🎼 Incoming GM program-change → switch the active GM patch. Auto- + // snaps the wave switcher to "gm" so the user hears the new voice. + if (command === MIDI_PROGRAM_CHANGE) { + const program = noteNumber ?? 0; + if (wave !== "gm") { + const idx = wavetypes.indexOf("gm"); + if (idx >= 0) { + waveIndex = idx; + wave = "gm"; + } + } + setGmProgram(program, api); + return; + } + + if (typeof noteNumber === "number") { + if (command === MIDI_NOTE_ON && velocity > 0) { + // 📊 Track MIDI key press time for latency measurement + perfStats.lastKeyTime = performance.now(); + + const buttonNote = midiNoteToButton(noteNumber); + if (buttonNote && startMidiButtonNote(buttonNote, velocity)) { + midiActiveNotes.set(noteNumber, buttonNote); + } + } else if (command === MIDI_NOTE_OFF || (command === MIDI_NOTE_ON && velocity === 0)) { + const mappedNote = midiActiveNotes.get(noteNumber) ?? midiNoteToButton(noteNumber); + if (mappedNote) { + stopMidiButtonNote(mappedNote); + } + midiActiveNotes.delete(noteNumber); + } + } + + return; + } + + if (!tap) { + // 🥁 Spacebar now toggles metronome on/off (instead of snare) + if (e.is("keyboard:down:space") && !e.repeat) { + metronomeEnabled = !metronomeEnabled; + if (metronomeEnabled) { + // Seed beat count to (current beat - 1) so the very next sim + // tick computes a fresh beatNumber and fires the click on the + // press itself. Previously we seeded to the *current* beat, + // which made the comparison at line 1844 a no-op and forced a + // wait of up-to-one full beat-interval before the first click + // (~333ms of silence at 180 BPM, every toggle-on). + const now = metronomeClockRef?.time?.()?.getTime?.() ?? Date.now(); + metronomeBeatCount = Math.floor(now / (60000 / metronomeBPM)) - 1; + } else { + metronomeVisualPhase = 0; + metronomeBallPos = 0; + metronomeFlash = 0; + } + } + } + + if (e.is("keyboard:down:alt") && !e.repeat && e.code === "AltLeft") { + perc = pc; //"cyan"; + percDowns.alt = true; + makeCrash(e.velocity); + } + + if (e.is("keyboard:down:alt") && !e.repeat && e.code === "AltRight") { + perc = pc; //"cyan"; + percDowns.alt = true; + makeRide(e.velocity); + } + + if (e.is("keyboard:down:arrowleft") && !e.repeat && !percDowns.left) { + perc = "brown"; + percDowns.left = true; + makeTomLow(e.velocity); + } + + if (e.is("keyboard:down:arrowdown") && !e.repeat && !percDowns.down) { + perc = "pink"; + percDowns.down = true; + makeKick(e.velocity); + } + + if (e.is("keyboard:down:arrowright") && !e.repeat && !percDowns.right) { + perc = "orange"; + percDowns.right = true; + makeTomHigh(e.velocity); + } + + if (e.is("keyboard:down:arrowup") && !e.repeat && !percDowns.up) { + percDowns.up = true; + perc = "cyan"; + makeHihat(e.velocity); + } + if (e.is("keyboard:up:arrowleft")) { + delete percDowns.left; + } + + if (e.is("keyboard:up:arrowdown")) { + delete percDowns.down; + } + + if (e.is("keyboard:up:arrowright")) { + delete percDowns.right; + } + + if (e.is("keyboard:up:arrowup")) { + delete percDowns.up; + } + + // Spacebar keyup no longer needed (metronome toggle happens on keydown only) + + if (e.is("keyboard:up:alt")) { + delete percDowns.alt; + } + + if (!tap) { + const activePens = pens?.(); + if (activePens?.length > 0) { + anyDown = true; + } + + if (e.is("lift") && activePens?.length <= 1) { + anyDown = false; + // Check for orphaned sounds when all touches are lifted + cleanupOrphanedSounds(pens); + } + + octBtn?.act(e, { + down: () => api.beep(400), + push: (btn) => { + api.beep(); + waveIndex = (waveIndex + 1) % wavetypes.length; + const octNum = parseInt(octave); + octave = max(1, (octNum + 1) % 10).toString(); + buildOctButton(api); + buildWaveButton(api); + buildAbletonButton(api); + buildOsButton(api); + buildRecButton(api); + }, + }); + + waveBtn?.act(e, { + down: () => api.beep(400), + push: (btn) => { + api.beep(); + waveIndex = (waveIndex + 1) % wavetypes.length; + wave = wavetypes[waveIndex]; + buildWaveButton(api); + buildAbletonButton(api); + buildOsButton(api); + buildRecButton(api); + }, + }); + + abletonBtn?.act(e, { + down: () => api.beep(400), + push: () => { + api.beep(); + jump("out:https://notepat.com/amxd"); + }, + }); + + osBtn?.act(e, { + down: () => api.beep(400), + push: () => { + api.beep(); + jump("os"); + }, + }); + + recBtn?.act(e, { + down: () => api.beep(400), + push: () => { + api.beep(); + if (!recording) { + recordBuffer = []; + recordingActive = {}; + recording = true; + // Seed any currently-held notes so a press mid-chord still captures them. + const now = performance.now(); + for (const bn of Object.keys(tonestack)) { + const tone = tonestack[bn]?.tone; + if (tone) recordingActive[bn] = { tone, downAt: now }; + } + } else { + // Close out any still-held notes before serializing. + const now = performance.now(); + for (const bn of Object.keys(recordingActive)) { + const { tone, downAt } = recordingActive[bn]; + recordBuffer.push({ tone, downAt, upAt: now }); + } + recordingActive = {}; + finishRecordingAndJump(api); + } + }, + }); + + // 🎛️ Toggle button interactions + slideBtn?.act(e, { + push: () => { + api.beep(); + slide = !slide; + // Kill extra tones when enabling slide mode (keep only most recent) + if (slide && Object.keys(tonestack).length > 1) { + const orderedTones = orderedByCount(tonestack); + orderedTones.forEach((tone, index) => { + if (index > 0) { + sounds[tone]?.sound.kill(quickFade ? fastFade : fade); + trail[tone] = 1; + delete tonestack[tone]; + delete sounds[tone]; + if (buttons[tone]) buttons[tone].down = false; + } + }); + } + // Also clean up orphaned sounds when toggling slide + cleanupOrphanedSounds(pens, true); + }, + }); + + roomBtn?.act(e, { + push: () => { + api.beep(); + roomMode = !roomMode; + room.toggle(); + }, + }); + + // 🎚️ Room parameter bar interaction (drag to adjust room amount) + if (roomMode && roomBtn?.box && (e.is("touch") || e.is("draw"))) { + const barY = OS_BAR_BOTTOM + 2; + const barHeight = 6; + const barX = roomBtn.box.x; + const barWidth = Math.max(40, roomBtn.box.w * 2); + const px = e.x ?? e.pointer?.x; + const py = e.y ?? e.pointer?.y; + if (px >= barX && px <= barX + barWidth && py >= barY && py <= barY + barHeight + 4) { + const newAmount = Math.max(0, Math.min(1, (px - barX) / barWidth)); + roomAmount = newAmount; + // Apply room amount to reverb if supported + room?.setAmount?.(roomAmount); + } + } + + glitchBtn?.act(e, { + push: () => { + api.beep(); + glitchMode = !glitchMode; + glitch?.toggle?.(); + // Clean up any stuck sounds when toggling glitch + cleanupOrphanedSounds(pens, true); + }, + }); + + quickBtn?.act(e, { + push: () => { + api.beep(); + quickFade = !quickFade; + }, + }); + + // 🥁 Metronome button interactions + bpmMinusBtn?.act(e, { + push: () => { + api.beep(); + // Decrease BPM by 5 (min 20) + metronomeBPM = Math.max(20, metronomeBPM - 5); + // Resync beat count to current beat at new BPM + const now = metronomeClockRef?.time?.()?.getTime?.() ?? Date.now(); + metronomeBeatCount = Math.floor(now / (60000 / metronomeBPM)); + }, + }); + + bpmPlusBtn?.act(e, { + push: () => { + api.beep(); + // Increase BPM by 5 (max 300) + metronomeBPM = Math.min(300, metronomeBPM + 5); + // Resync beat count to current beat at new BPM + const now = metronomeClockRef?.time?.()?.getTime?.() ?? Date.now(); + metronomeBeatCount = Math.floor(now / (60000 / metronomeBPM)); + }, + }); + + metroBtn?.act(e, { + push: () => { + api.beep(); + metronomeEnabled = !metronomeEnabled; + if (metronomeEnabled) { + // Seed beat count to current beat so first tick doesn't fire immediately + const now = metronomeClockRef?.time?.()?.getTime?.() ?? Date.now(); + metronomeBeatCount = Math.floor(now / (60000 / metronomeBPM)); + metronomeVisualPhase = 0; + } + }, + }); + + const activateMelodyAlias = () => { + if (!song || paintPictureOverlay || projector) return false; + const currentNote = song?.[songIndex]?.[0]; + if (!currentNote) return false; + + const targetButtonNote = currentNote.toLowerCase(); + + if (melodyAliasDown && melodyAliasActiveNote === targetButtonNote) { + return false; + } + + if (sounds[targetButtonNote]) { + melodyAliasDown = true; + melodyAliasActiveNote = targetButtonNote; + melodyAliasStartedNote = false; + return true; + } + + const started = startMidiButtonNote(targetButtonNote, 127); + if (started) { + melodyAliasDown = true; + melodyAliasActiveNote = targetButtonNote; + melodyAliasStartedNote = true; + return true; + } + + return false; + }; + + const releaseMelodyAlias = () => { + if (!melodyAliasDown) return; + if (melodyAliasStartedNote && melodyAliasActiveNote) { + stopMidiButtonNote(melodyAliasActiveNote); + } + melodyAliasDown = false; + melodyAliasActiveNote = null; + melodyAliasStartedNote = false; + }; + + if (melodyAliasBtn && song && !paintPictureOverlay && !projector) { + melodyAliasBtn.act( + e, + { + down: () => { + activateMelodyAlias(); + }, + push: () => { + releaseMelodyAlias(); + }, + up: () => { + releaseMelodyAlias(); + }, + cancel: () => { + releaseMelodyAlias(); + }, + out: () => { + releaseMelodyAlias(); + }, + }, + pens?.(), + ); + } + + buttonNotes.forEach((note) => { + if (buttons[note]) { + buttons[note].act( + e, + { + down: (btn) => { + return startButtonNote(note, 127, api); + }, + over: (btn) => { + if (btn.up && anyDown) { + btn.up = false; + startButtonNote(note, 127, api); + + // In song mode, if we drag into the correct note, mark it as pressed + if (song && note.toUpperCase() === song?.[songIndex][0]) { + songNoteDown = true; + } + } + }, + // TODO: The order of over and out will be important... + out: (btn) => { + // Only stop the sound when dragging off, but don't affect button down state + sounds[note]?.sound?.kill(killFade); + delete tonestack[note]; // Remove from notestack + delete sounds[note]; // Remove sound reference + delete trail[note]; // Clean up trail + + // If this was the song note, reset the flag so it doesn't advance on wrong note + if (song && note.toUpperCase() === song?.[songIndex][0] && songNoteDown) { + songNoteDown = false; + } + }, + cancel: (btn) => { + // Force cleanup when button gets stuck - stop the sound immediately + sounds[note]?.sound?.kill(fastFade); // Use fast fade for force cleanup + delete tonestack[note]; // Remove from notestack + delete sounds[note]; // Remove sound reference + delete trail[note]; // Clean up trail + }, + up: (btn) => { + // ❤️‍🔥 TODO: How to manage the sliding here? + if (downs[note]) return false; + + const orderedTones = orderedByCount(tonestack); + + if (slide && orderedTones.length > 1 && sounds[note]) { + sounds[note]?.sound?.update({ + tone: tonestack[orderedTones[orderedTones.length - 2]].tone, + duration: 0.1, + }); + const previousKey = orderedTones[orderedTones.length - 2]; + sounds[previousKey] = sounds[note]; + if (sounds[previousKey]) sounds[previousKey].note = previousKey; + applyPitchBendToNotes([previousKey], { immediate: true }); + } else { + sounds[note]?.sound.kill(quickFade ? fastFade : killFade); + } + + // console.log("🪱 Trail:", note); + + trail[note] = 1; + + if (note.toUpperCase() === song?.[songIndex][0]) { + songIndex = (songIndex + 1) % song.length; + songNoteDown = false; + songShifting = true; + } + + delete tonestack[note]; // Remove this key from the notestack. + delete sounds[note]; + //} else { + stopButtonNote(note); + // sounds[orderedTones[orderedTones.length - 2]] = sounds[key]; + //} + }, + }, + pens?.(), + ); + } + }); + } + + if (tap) { + if ((e.is("touch") || e.is("keyboard:down:space")) && !e.repeat) { + let reset = false; + let tappedOctave; + let tempTapIndex = tapIndex; + + if (!keys || keys.length === 0) { + reset = true; + } else { + if (octaves.indexOf(keys[tempTapIndex]) > -1) { + tappedOctave = keys[tempTapIndex]; + tempTapIndex += 1; + } + + tapped = keys[tempTapIndex]; + if (!tapped) reset = true; + } + + if (!reset) { + if (accents.indexOf(keys[tempTapIndex + 1]) > -1) { + tapped = keys[tempTapIndex] + keys[tempTapIndex + 1]; + } + + const tone = tapped; + if (tappedOctave) tapped = tappedOctave + tapped; + + const pan = getPanForButtonNote(tone.toLowerCase()); + sounds[tapped] = makeNoteSound(octave + tone, e.velocity ?? 127, pan); // Use velocity from event + } else { + resetModeState(); + } + } + + // TODO: This needs to work for multi-touch. + + if (e.is("keyboard:up:space") || e.is("lift")) { + // Push the `tapIndex` forward according to octave, then for note, then + // for the accent. + if (octaves.indexOf(keys[tapIndex]) > -1) tapIndex += 1; + tapIndex += 1; + if (accents.indexOf(keys[tapIndex]) > -1) tapIndex += 1; + + if (tapIndex >= keys.length) { + octave = STARTING_OCTAVE; + tapIndex = 0; + } + + sounds[tapped]?.kill(quickFade ? fastFade : killFade); + delete sounds[tapped]; + tapped = undefined; + } + } + + // Individual Keyboard Notes + [...(octaves + notes + edges).split(""), "control"].forEach((key) => { + if (e.is(`keyboard:down:${key}`) && !e.repeat && !downs[key]) { + // 📊 Track key press time for latency measurement + perfStats.lastKeyTime = performance.now(); + + downs[key] = true; + + if (!tap) { + if (octaves.includes(key) && octaves.includes(keys.slice(-1))) + keys = keys.slice(0, -1); + editable = true; + } else if (keys[tapIndex] === key) { + tapped = key; + } + + if (octaves.includes(key)) { + // 🎹 Keyboard -> 🎼 Octave recognition. + octave = parseInt(key); + sounds[key] = "held"; + keys += key.toUpperCase(); + } else { + // 🎹 Keyboard -> 🎵 Note recognition. + let note = key.toUpperCase(); + + // console.log("🫐 Extended Note ?:", note); + + if ("ZX".includes(note) || note === "CONTROL") { + switch (note) { + case "CONTROL": + note = "-A"; + break; + case "Z": + note = "-A#"; + break; + case "X": + note = "-B"; + break; + } + } + + // console.log(note); + + if (";']".includes(note)) { + switch (note) { + case ";": + note = "++C"; + break; + case "'": + note = "++C#"; + break; + case "]": + note = "++D"; + break; + } + } + + if ("VSWRQ".includes(note)) { + switch (note) { + case "V": + note = "C#"; + break; + case "S": + note = "D#"; + break; + case "W": + note = "F#"; + break; + case "R": + note = "G#"; + break; + case "Q": + note = "A#"; + break; + } + } + + let activeOctave = parseInt(octave); //parseInt(octave); + let buttonNote; + + if (("HIJKLMN" + "TYUOP").includes(note)) { + switch (note) { + case "H": + note = "C"; + break; + case "I": + note = "D"; + break; + case "J": + note = "E"; + break; + case "K": + note = "F"; + break; + case "L": + note = "G"; + break; + case "M": + note = "A"; + break; + case "N": + note = "B"; + break; + // Semitones + case "T": + note = "C#"; + break; + case "Y": + note = "D#"; + break; + case "U": + note = "F#"; + break; + case "O": + note = "G#"; + break; + case "P": + note = "A#"; + break; + } + activeOctave += 1; + activeOctave += upperOctaveShift; + buttonNote = "+" + note.toLowerCase(); + } else { + activeOctave += lowerOctaveShift; + buttonNote = note.toLowerCase(); + } + + // Adjust active octave based on extension note + // if needed. + + if (note.startsWith("-")) { + note = note.replace("-", ""); + activeOctave -= 1; + } else if (note.startsWith("++")) { + note = note.replace("++", ""); + activeOctave += 2; + activeOctave += upperOctaveShift; + } + + // if (activeOctave !== parseInt(octave)) { + // keys += activeOctave + note; + // } else { + keys += activeOctave + note; + // } + + // const buttonNote = + // (activeOctave === parseInt(octave) ? "" : "+") + note.toLowerCase(); + + // 🎵 Block wrong keyboard keys in song mode (melody learning mode) + if (song && buttonNote.toUpperCase() !== song?.[songIndex][0]) { + // Play white noise bump for wrong key press + synth({ + type: "noise-white", + tone: 1000, + duration: 0.05, + volume: 0.3, + attack: 0, + }); + return; // Block this wrong key in melody mode + } + + if (buttons[buttonNote]) buttons[buttonNote].down = true; + + const active = orderedByCount(sounds); + + const tone = `${activeOctave}${note}`; + + if (slide && active.length > 0) { + // TODO: Fix slide here... 24.08.16.06.18 + //console.log("Fix slide...", active[0], sounds[active[0]]?.sound, tone, key); + + sounds[active[0]]?.sound?.update({ tone, duration: 0.1 }); + + tonestack[buttonNote] = { + count: Object.keys(tonestack).length, + tone, + }; + sounds[buttonNote] = sounds[active[0]]; // Switch the note label. + if (sounds[buttonNote]) sounds[buttonNote].note = buttonNote; + delete sounds[active[0]]; // Swap the sound reference. + applyPitchBendToNotes([buttonNote], { immediate: true }); + } else { + tonestack[buttonNote] = { + count: Object.keys(tonestack).length, + tone, + }; + const pan = getPanForQwertyKey(key); + sounds[buttonNote] = { + note: buttonNote, + count: active.length + 1, + sound: makeNoteSound(tone, e.velocity ?? 127, pan), // Use velocity from event + }; + + applyPitchBendToNotes([buttonNote], { immediate: true }); + + if (buttonNote.toUpperCase() === song?.[songIndex][0]) { + songNoteDown = true; + } + + delete trail[buttonNote]; + + pictureAdd(api, tone); + udpServer?.send("tv", { note: buttonNote }); // Send udp message for note. + } + } + } + + // Just for the notes, not the octaves... + if (e.is(`keyboard:up:${key}`) /*&& notes.indexOf(key) > -1*/) { + if (tap && tapped === key) { + tapIndex = (tapIndex + 1) % keys.length; + tapped = undefined; + } + + if (downs[key]) { + delete downs[key]; + + // let buttonNote = key; + function noteFromKey(key) { + let buttonNote = key; + + if ("vswrq".includes(key)) { + switch (key) { + case "v": + buttonNote = "c#"; + break; + case "s": + buttonNote = "d#"; + break; + case "w": + buttonNote = "f#"; + break; + case "r": + buttonNote = "g#"; + break; + case "q": + buttonNote = "a#"; + break; + } + } + // TODO: This matching code should be combined across event handlers. 24.08.15.06.40 + let activeOctave = octave; + + if (("hijklmn" + "tyuop").includes(buttonNote)) { + switch (buttonNote) { + case "h": + buttonNote = "c"; + break; + case "i": + buttonNote = "d"; + break; + case "j": + buttonNote = "e"; + break; + case "k": + buttonNote = "f"; + break; + case "l": + buttonNote = "g"; + break; + case "m": + buttonNote = "a"; + break; + case "n": + buttonNote = "b"; + break; + // Semitones + case "t": + buttonNote = "c#"; + break; + case "y": + buttonNote = "d#"; + break; + case "u": + buttonNote = "f#"; + break; + case "o": + buttonNote = "g#"; + break; + case "p": + buttonNote = "a#"; + break; + } + activeOctave = parseInt(octave) + 1; + } + + if (activeOctave !== octave) buttonNote = "+" + buttonNote; + + if ("zx".includes(buttonNote) || buttonNote === "control") { + switch (buttonNote) { + case "x": + buttonNote = "-b"; + break; + case "z": + buttonNote = "-a#"; + break; + case "control": + buttonNote = "-a"; + break; + } + } + + if (";']".includes(buttonNote)) { + switch (buttonNote) { + case ";": + buttonNote = "++c"; + break; + case "'": + buttonNote = "++c#"; + break; + case "]": + buttonNote = "++d"; + break; + } + } + + return buttonNote; + } + + const buttonNote = noteFromKey(key); + + const orderedTones = orderedByCount(tonestack); + + // console.log( + // "Ordered Tones:", + // orderedTones, + // "Sounds:", + // sounds, + // "Key:", + // key, + // ); + + // In slide mode, the sound reference may have been moved to another key + // Only process if this note still has an active sound reference + const hasSound = sounds[buttonNote] !== undefined; + + if (slide && orderedTones.length > 1 && hasSound) { + sounds[buttonNote]?.sound?.update({ + tone: tonestack[orderedTones[orderedTones.length - 2]].tone, + duration: 0.1, + }); + + // console.log( + // "New sound key is:", + // tonestack[orderedTones[orderedTones.length - 2]], + // noteFromKey(orderedTones[orderedTones.length - 2]), + // orderedTones, + // sounds + // ); + + const previousKeyRaw = orderedTones[orderedTones.length - 2]; + const n = noteFromKey(previousKeyRaw); + sounds[n] = sounds[buttonNote]; + if (sounds[n]) sounds[n].note = n; + applyPitchBendToNotes([previousKeyRaw], { immediate: true }); + // console.log("Replaced:", buttonNote, "with:", n); + + // delete sounds[buttonNote]; + } else if (hasSound) { + // Only kill the sound if we still have a reference to it + // console.log("Killing sound:", buttonNote); + + if (sounds[buttonNote]?.sound) { + const fade = max( + 0.075, //0.175, + min( + (performance.now() / 1000 - sounds[buttonNote].sound.startedAt), + 0.15, //0.45, + ), + ); + // console.log("🦋 Fade length:", fade); + // killFade + sounds[buttonNote]?.sound.kill(quickFade ? fastFade : fade); // Kill a sound if it exists. + } + delete sounds[buttonNote]; + } + // If hasSound is false in slide mode, the sound was already moved + // to another key, so we don't need to kill anything + + if (buttonNote.toUpperCase() === song?.[songIndex][0]) { + songIndex = (songIndex + 1) % song.length; + songNoteDown = false; + songShifting = true; + } + + // Always clean up tonestack and trail + delete tonestack[buttonNote]; // Remove this key from the notestack. + trail[buttonNote] = 1; + if (buttons[buttonNote]) buttons[buttonNote].down = false; + } + } + }); + + // After processing all key up events, check for orphaned sounds + if (Object.keys(downs).length === 0) { + cleanupOrphanedSounds(pens); + } +} + +// 📚 Library + +function pictureAdd({ page, screen, wipe, write, line, ink, num }, note) { + if (!picture?.pixels) return; + note = note.toLowerCase(); + + const letter = note.slice(1); + // console.log(note); + + page(picture); + // wipe("black"); + ink("yellow"); + // write(note, { center: "xy"}); // TOOD: Write's random values should be confined to the + // page and not the screen. + // write("POW", { center: "xy" }); // TOOD: Write's random values should be confined to the + + const notesToCols = { + c: [255, 0, 0], // Red + "c#": [128, 128, 128], // Gray + d: [0, 255, 0], // Green + "d#": [0, 0, 0], // Black + e: [0, 0, 255], // Blue + f: [0, 255, 255], // Cyan + "f#": [128, 0, 128], // Purple + g: [255, 0, 255], // Magenta + "g#": [255, 165, 0], // Orange + a: [255, 255, 0], // Yellow + "a#": [139, 69, 19], // Brown + b: [255, 255, 255], // White + }; + + // write(note, num.randInt(128), num.randInt(128)); + + // TODO: Rather than draw a whole box from 0, 0, to 128, 128, i wanna + // draw horizontal scanlines in aloop and randomly skip some. + const color = notesToCols[letter]; + if (!color) { + page(screen); + return; + } + + ink(...color, 32).box(0, 0, picture.width, picture.height); + + // for (let y = 0; y < 128; y += 1) { + // // Adjust step size (e.g., 2) for scanline spacing + // if (Math.random() > 0.3) { + // // 30% chance to skip a line + // ink(...notesToCols[letter], 32).box(0, y, 128, 1); // Draw a single horizontal line + // } + // } + + // ink(undefined); + // line(); + page(screen); +} + +function pictureLines( + { page, ink, wipe, screen, blur, box, line, plot, num, paintCount, sound, scroll }, + { amplitude, waveforms, audioReady }, +) { + if (!picture?.pixels) return; + page(picture); + + // Blur instead of clearing for trail effect + blur(0.5); + + // Scroll horizontally to the right + scroll(1, 0); + + const safeAmplitude = + typeof amplitude === "number" && Number.isFinite(amplitude) ? amplitude : 0; + + let waveformArray = []; + if (Array.isArray(waveforms)) { + waveformArray = waveforms.slice(); + } else if (waveforms && typeof waveforms.length === "number") { + waveformArray = Array.from(waveforms); + } + + if (waveformArray.length > 0) { + waveformArray = waveformArray.map((value) => + typeof value === "number" && Number.isFinite(value) ? value : 0, + ); + } + + const hasWaveformData = audioReady && waveformArray.length > 0; + + // Get the active notes from the sounds object + const activeNotes = Object.keys(sounds).filter( + (key) => sounds[key]?.sound && key, + ); + + if (activeNotes.length > 0) { + // Get color from the first active note + const firstNote = activeNotes[0]; + if (firstNote) { + const color = colorFromNote(firstNote, num); + + // === WAVEFORM LINE (BACKGROUND) === + // Draw waveform line first in the background + if (hasWaveformData && waveformArray.length > 16) { + const step = picture.width / waveformArray.length; + const centerY = picture.height / 2; + + for (let i = 1; i < waveformArray.length; i++) { + const x1 = (i - 1) * step; + const y1 = centerY + waveformArray[i - 1] * picture.height * 0.4; + const x2 = i * step; + const y2 = centerY + waveformArray[i] * picture.height * 0.4; + + // Draw waveform line with slight transparency + ink(...color, 120).line(x1, y1, x2, y2); + } + } + + // === WAVEFORM BARS === + // Draw vertical bars based on waveform amplitude + if (hasWaveformData && waveformArray.length > 16) { + const barWidth = picture.width / waveformArray.length; + const centerY = picture.height / 2; + + for (let i = 0; i < waveformArray.length; i++) { + const x = i * barWidth; + const barHeight = Math.abs(waveformArray[i]) * picture.height * 0.4; + const y = centerY - barHeight / 2; + + // Draw vertical bar filling the full width + ink(...color, 180).box(x, y, Math.ceil(barWidth), barHeight); + } + } + + // === HORIZONTAL STRIPES FOR EACH NOTE === + // Draw colored stripes across the screen for each active note + const stripeHeight = picture.height / activeNotes.length; + activeNotes.forEach((stripeNote, idx) => { + if (!stripeNote) return; + const stripeColor = colorFromNote(stripeNote, num); + const stripeY = idx * stripeHeight; + + // Oscillate stripe position based on amplitude + const oscillation = Math.sin(paintCount * 0.1 + idx) * safeAmplitude * 20; + + // Draw thin horizontal stripes with gaps, oscillating vertically + for (let x = 0; x < picture.width; x += 8) { + ink(...stripeColor, 60).box(x, stripeY + oscillation, 4, stripeHeight); + } + }); + } + + // === ACTIVE NOTES DISPLAY === + // Display each active note as a colored orb/circle + const orbSize = Math.min(picture.width, picture.height) * 0.3; // Widened from 0.25 + const spacing = picture.width / (activeNotes.length + 1); + + activeNotes.forEach((note, index) => { + if (!note) return; + + const color = colorFromNote(note, num); + const x = spacing * (index + 1); + const y = picture.height * 0.5; // Centered vertically + const pulseSize = orbSize * (0.8 + safeAmplitude * 0.4); // Pulse with audio + + // Draw concentric circles for orb effect (wider glow) + ink(...color, 100).box(x - pulseSize / 2, y - pulseSize / 2, pulseSize, pulseSize); + ink(...color, 200).box( + x - (pulseSize * 0.6) / 2, + y - (pulseSize * 0.6) / 2, + pulseSize * 0.6, + pulseSize * 0.6, + ); + ink(...color, 255).box( + x - (pulseSize * 0.3) / 2, + y - (pulseSize * 0.3) / 2, + pulseSize * 0.3, + pulseSize * 0.3, + ); + + // Convert note name to keyboard key for display + // Handle both lower octave (c, d, e...) and upper octave (+c, +d, +e...) + let displayKey = ""; + + const mappedKey = noteToKeyboardKey(note); + displayKey = mappedKey || note.toLowerCase(); + + // Draw shadow (1px offset down and right) + ink(0, 0, 0).write(displayKey, { center: "xy", x: x + 1, y: y }); + + // Flicker between white and yellow + const flickerColor = num.rand() > 0.5 ? [255, 255, 255] : [255, 255, 0]; + ink(...flickerColor).write(displayKey, { center: "xy", x, y: y - 1 }); + }); + + // Apply blur for dreamy effect + // blur(1.5); + } else { + // Clear to transparent when no notes + wipe(0, 0); + } + + page(screen); +} + +function orderedByCount(obj) { + return Object.keys(obj) + .filter((key) => obj[key]?.hasOwnProperty("count")) + .sort((a, b) => obj[a].count - obj[b].count); +} + +function resetModeState() { + tapped = undefined; + tapIndex = 0; + octave = STARTING_OCTAVE; + // hold = sharps = flats = false; +} + +// Initialize and/or lay out the UI buttons on the bottom of the display. +function setupButtons({ ui, screen, geo }) { + const sampleRateText = getSampleRateText(undefined); + const sampleRateLabel = sampleRateText ? MIDI_RATE_LABEL_TEXT : null; + const cachedLayout = getCachedLayout(screen, { + songMode: Boolean(song) && autopatConfig.showTrack !== false, + pictureOverlay: paintPictureOverlay, + rateLabel: sampleRateLabel, + rateText: sampleRateText, + sidePanelWidth: autopatConfig.sidePanelWidth, + }); + const layout = cachedLayout.layout; + const glyphMetrics = resolveMatrixGlyphMetrics(); + const rectsOverlap = (a, b) => + a && b && + a.x < b.x + b.w && + a.x + a.w > b.x && + a.y < b.y + b.h && + a.y + a.h > b.y; + padsBaseKey = null; + padsBase = null; + const { + buttonWidth, + buttonHeight, + topButtonY, + buttonsPerRow, + totalRows, + margin, + melodyButtonRect, + splitLayout, + leftOctaveX, + rightOctaveX, + notesPerSide, + usableWidth, + miniInputsHorizontal, + } = layout; + + buttonNotes.forEach((label, i) => { + let x, y; + + if (splitLayout) { + // Split layout: first 12 notes on left, second 12 on right + const isSecondOctave = i >= notesPerSide; + const localIndex = isSecondOctave ? i - notesPerSide : i; + const row = floor(localIndex / buttonsPerRow); + const col = localIndex % buttonsPerRow; + + y = topButtonY + row * buttonHeight; + if (isSecondOctave) { + x = rightOctaveX + col * buttonWidth; + } else { + x = leftOctaveX + col * buttonWidth; + } + } else { + // Normal grid layout + const row = floor(i / buttonsPerRow); + const col = i % buttonsPerRow; + y = topButtonY + row * buttonHeight; + const gridWidth = buttonsPerRow * buttonWidth; + const availableWidth = usableWidth ?? screen.width; + // Left-align grid when mini inputs are to the right, otherwise center + const baseX = miniInputsHorizontal + ? margin + : ceil(margin + Math.max(0, floor((availableWidth - margin * 2 - gridWidth) / 2))); + x = baseX + col * buttonWidth; + } + + const geometry = [x, y, buttonWidth, buttonHeight]; + if (!buttons[label]) { + buttons[label] = new ui.Button(...geometry); + buttons[label].id = `note-${label}`; // Add identifier for debugging + } else { + buttons[label].box = new geo.Box(...geometry); + } + + const noteLabelText = label.toUpperCase(); + const noteFont = buttonWidth >= 22 ? "unifont" : "MatrixChunky8"; + const noteGlyphWidth = noteFont === "unifont" ? 8 : glyphMetrics.width; + const noteGlyphHeight = noteFont === "unifont" ? 16 : glyphMetrics.height; + const noteLabelWidth = noteLabelText.length * noteGlyphWidth; + const noteLabelBoundsDefault = { + x: x + 2, + y: y + 1, + w: noteLabelWidth, + h: noteGlyphHeight, + }; + const noteLabelBoundsCentered = { + x: x + buttonWidth / 2 - noteLabelWidth / 2, + y: y + buttonHeight / 2 - noteGlyphHeight / 2, + w: noteLabelWidth, + h: noteGlyphHeight, + }; + + const keyLabelCandidate = noteToKeyboardKey(label) || label.toLowerCase(); + // Skip apostrophe key - it's too wide and cuts into narrow pads + const keyLabel = keyLabelCandidate === "'" ? null : keyLabelCandidate; + const keyFont = buttonWidth >= 22 ? "unifont" : "MatrixChunky8"; + const keyGlyphWidth = keyFont === "unifont" ? 8 : glyphMetrics.width; + const keyGlyphHeight = keyFont === "unifont" ? 16 : glyphMetrics.height; + + let keyLabelWidth = 0; + let keyBoundsDefault = null; + let keyBoundsCentered = null; + let keyOverlapsDefault = false; + let keyOverlapsCentered = false; + + if (keyLabel) { + keyLabelWidth = keyLabel.length * keyGlyphWidth; + const keyBottom = { + x: x + buttonWidth - keyLabelWidth - 2, + y: y + buttonHeight - keyGlyphHeight - 1, + w: keyLabelWidth, + h: keyGlyphHeight, + }; + const keyTop = { + x: x + buttonWidth - keyLabelWidth - 2, + y: y + 1, + w: keyLabelWidth, + h: keyGlyphHeight, + }; + + keyBoundsDefault = rectsOverlap(keyBottom, noteLabelBoundsDefault) ? keyTop : keyBottom; + keyOverlapsDefault = rectsOverlap(keyBoundsDefault, noteLabelBoundsDefault); + + keyBoundsCentered = rectsOverlap(keyBottom, noteLabelBoundsCentered) ? keyTop : keyBottom; + keyOverlapsCentered = rectsOverlap(keyBoundsCentered, noteLabelBoundsCentered); + } + + buttons[label].meta = { + noteLabelText, + noteFont, + noteGlyphWidth, + noteGlyphHeight, + noteLabelBoundsDefault, + noteLabelBoundsCentered, + keyLabel, + keyFont, + keyGlyphWidth, + keyGlyphHeight, + keyLabelWidth, + keyBoundsDefault, + keyBoundsCentered, + keyOverlapsDefault, + keyOverlapsCentered, + }; + }); + + if (song && !paintPictureOverlay && melodyButtonRect) { + if (!melodyAliasBtn) { + melodyAliasBtn = new ui.Button( + melodyButtonRect.x, + melodyButtonRect.y, + melodyButtonRect.width, + melodyButtonRect.height, + ); + melodyAliasBtn.id = "melody-alias-button"; + } else { + melodyAliasBtn.box = new geo.Box( + melodyButtonRect.x, + melodyButtonRect.y, + melodyButtonRect.width, + melodyButtonRect.height, + ); + } + } else { + melodyAliasBtn = undefined; + } + + if (!melodyAliasBtn) { + melodyAliasDown = false; + melodyAliasActiveNote = null; + melodyAliasStartedNote = false; + } +} + +function buildWaveButton({ screen, ui, typeface }) { + const isNarrow = screen.width < 200; + const glyphWidth = typeface?.glyphs?.["0"]?.resolution?.[0] ?? matrixFont?.glyphs?.["0"]?.resolution?.[0] ?? 6; + + // Shorten wave names for narrow screens + const shortWaveNames = { + sine: "sin", + triangle: "tri", + sawtooth: "saw", + square: "sqr", + noise: "noi", + composite: "cmp", + stample: "stp", + drum: "drm", + gm: "gm", + }; + // GM wave shows the live program/buffer so the user always knows what's + // loaded and what they're partway through typing. + let displayWave; + if (wave === "gm") { + const shown = gmDigitBuffer || String(gmProgram).padStart(3, "0"); + displayWave = isNarrow ? `g${shown}` : `GM:${shown}`; + } else { + displayWave = isNarrow ? (shortWaveNames[wave] || wave.slice(0, 3)) : wave; + } + const waveWidth = displayWave.length * glyphWidth; + const margin = isNarrow ? 2 : 4; + waveBtn = new ui.Button( + screen.width - waveWidth - 26 - margin * 2, + 0, + waveWidth + margin * 2 + 5, + 10 + margin * 2 - 1 + 2, + ); + waveBtn.id = "wave-button"; + waveBtn.isNarrow = isNarrow; + waveBtn.displayWave = displayWave; +} + +function buildOctButton({ screen, ui, typeface }) { + const isNarrow = screen.width < 200; + const glyphWidth = typeface?.glyphs?.["0"]?.resolution?.[0] ?? matrixFont?.glyphs?.["0"]?.resolution?.[0] ?? 6; + const octWidth = octave.length * glyphWidth; + const margin = isNarrow ? 2 : 4; + octBtn = new ui.Button( + screen.width - octWidth - 6 - margin * 2, + 0, + octWidth + margin * 2 + 7, + 10 + margin * 2 - 1 + 2, + ); + octBtn.id = "oct-button"; + octBtn.isNarrow = isNarrow; +} + +// OS bar (m4l / os / drum) — dedicated row below the secondary bar so the +// top bar stays uncluttered. Buttons are right-aligned and chain leftward; +// when the screen is narrow they collapse widths/labels so they still fit. +const OS_BAR_BTN_GAP = 3; +const OS_BAR_RIGHT_MARGIN = 4; + +function osBarButtonMetrics({ screen }) { + const isNarrow = screen.width < 240; + const isVeryNarrow = screen.width < 180; + const padX = isVeryNarrow ? 2 : isNarrow ? 3 : 4; + const glyph = 6; + return { + isNarrow, + isVeryNarrow, + padX, + glyph, + y: OS_BAR_TOP + 1, + h: OS_BAR_HEIGHT - 2, + labels: { + ableton: "m4l", + os: "os", + rec: "rec", + recStop: "stop", + }, + }; +} + +function buildAbletonButton({ ui, screen }) { + const m = osBarButtonMetrics({ screen }); + const w = m.labels.ableton.length * m.glyph + m.padX * 2; + const x = screen.width - OS_BAR_RIGHT_MARGIN - w; + abletonBtn = new ui.Button(x, m.y, w, m.h); + abletonBtn.id = "ableton-button"; + abletonBtn.label = m.labels.ableton; +} + +function buildOsButton({ ui, screen }) { + const m = osBarButtonMetrics({ screen }); + const w = m.labels.os.length * m.glyph + m.padX * 2; + const anchorX = abletonBtn?.box?.x ?? (screen.width - OS_BAR_RIGHT_MARGIN); + osBtn = new ui.Button(anchorX - w - OS_BAR_BTN_GAP, m.y, w, m.h); + osBtn.id = "os-button"; + osBtn.label = m.labels.os; +} + +// 🔴 Record button — sits to the left of osBtn on the OS bar. +// Width reserves space for the wider label ("stop") so the button doesn't +// reflow when toggling between idle and recording states. +function buildRecButton({ ui, screen }) { + const m = osBarButtonMetrics({ screen }); + const labelLen = Math.max(m.labels.rec.length, m.labels.recStop.length); + const w = labelLen * m.glyph + m.padX * 2; + const anchorX = osBtn?.box?.x ?? abletonBtn?.box?.x ?? (screen.width - OS_BAR_RIGHT_MARGIN); + recBtn = new ui.Button(anchorX - w - OS_BAR_BTN_GAP, m.y, w, m.h); + recBtn.id = "rec-button"; +} + +// Snapshot tonestack each sim tick so every note-on/note-off path feeds +// the recording buffer without hooking each release site individually. +function recordSync() { + if (!recording) return; + const now = performance.now(); + for (const bn of Object.keys(tonestack)) { + if (!recordingActive[bn]) { + const tone = tonestack[bn]?.tone; + if (tone) recordingActive[bn] = { tone, downAt: now }; + } + } + for (const bn of Object.keys(recordingActive)) { + if (!tonestack[bn]) { + const { tone, downAt } = recordingActive[bn]; + recordBuffer.push({ tone, downAt, upAt: now }); + delete recordingActive[bn]; + } + } +} + +// Convert recordBuffer into a clock-compatible melody string + timing. +// Splits each tone (e.g. "4c#") into octave + note, clamps octave to 0-9 +// since clock's parser reads a single digit, and emits notes back-to-back. +// Sticky duration modifiers in clock would make per-note timing fragile, so +// v1 uses one base tempo (median of recorded durations) and drops gaps. +function serializeRecording() { + if (!recordBuffer.length) return null; + const durations = recordBuffer.map((e) => Math.max(0.05, (e.upAt - e.downAt) / 1000)); + const sorted = [...durations].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)] || 0.5; + let melody = ""; + for (const entry of recordBuffer) { + const m = /^(-?\d+)(.+)$/.exec(entry.tone); + if (!m) continue; + let oct = parseInt(m[1], 10); + if (!Number.isFinite(oct)) oct = 4; + oct = Math.max(0, Math.min(9, oct)); + const note = m[2].toLowerCase(); + melody += `${oct}${note}`; + } + if (!melody) return null; + return { melody, timing: Number(median.toFixed(3)) }; +} + +function finishRecordingAndJump(api) { + recording = false; + recordingActive = {}; + const result = serializeRecording(); + recordBuffer = []; + if (!result) return; + const target = `clock ${result.melody}:${result.timing}`; + if (typeof api?.jump === "function") api.jump(target); +} + +// Build metronome controls and toggle buttons with responsive layout +// Calculates available space and shortens labels as needed to prevent overlap +function buildMetronomeButtons({ screen, ui, typeface, text }) { + const btnHeight = SECONDARY_BAR_HEIGHT - 2; + const btnY = SECONDARY_BAR_TOP + 1; + const glyphWidth = typeface?.glyphs?.["0"]?.resolution?.[0] ?? matrixFont?.glyphs?.["0"]?.resolution?.[0] ?? 6; + const glyphMetrics = resolveMatrixGlyphMetrics(); + const hideMidiBadge = screen.width < 240; + + // Calculate actual MIDI badge width using proper text measurement + // Use stored sample rate or fallback to window.audioContext + const sampleRate = storedSampleRate || (typeof window !== "undefined" ? window.audioContext?.sampleRate : null); + const sampleRateText = getSampleRateText(sampleRate); + const midiTextW = measureMatrixTextWidth("M"); + const divWidth = 5; // divider (2px) + spacing (3px) + const shortRate = sampleRateText ? sampleRateText.replace("Hz", "") : ""; + const rateTextW = measureMatrixTextWidth(shortRate); + const fpsTextW = measureMatrixTextWidth("120fps"); // Max FPS width (6 chars) + const totalMidiTextWidth = midiTextW + divWidth + rateTextW + divWidth + fpsTextW; + const midiBadgeWidth = hideMidiBadge ? 0 : totalMidiTextWidth + MIDI_BADGE_PADDING_X + MIDI_BADGE_PADDING_RIGHT; + + // Left reserved = MIDI badge margin + badge width + tiny gap + const leftReserved = hideMidiBadge ? 0 : MIDI_BADGE_MARGIN + midiBadgeWidth + 1; + const rightMargin = 4; + + // Fixed elements: [-] [+] [BPM] - BPM is now 3 chars with minimal padding + const minusBtnWidth = glyphWidth + TOGGLE_BTN_PADDING_X * 2; + const plusBtnWidth = glyphWidth + TOGGLE_BTN_PADDING_X * 2; + const bpmTextWidth = 3 * glyphWidth + TOGGLE_BTN_PADDING_X; // Reduced padding - only left side + + // Available width for metronome + toggle buttons + const availableWidth = screen.width - leftReserved - rightMargin; + + // Calculate width needed for each label variant level + const calcLabelWidth = (label) => { + return (measureMatrixTextBoxWidth(label, { text }, screen.width) || (label.length * glyphWidth)) + TOGGLE_BTN_PADDING_X * 2; + }; + + // Fixed metronome elements width: [-] [+] [BPM] (BPM is the toggle button now) + const metroFixedWidth = minusBtnWidth + 1 + plusBtnWidth + 1 + bpmTextWidth + TOGGLE_BTN_GAP; + + // Try different label lengths until everything fits + let variantLevel = 0; + let totalNeeded = Infinity; + + while (variantLevel < 3 && totalNeeded > availableWidth) { + // Get labels at this variant level (no metro label needed - BPM display is the toggle) + const slideLabel = LABEL_VARIANTS.slide[Math.min(variantLevel, LABEL_VARIANTS.slide.length - 1)]; + const roomLabel = LABEL_VARIANTS.room[Math.min(variantLevel, LABEL_VARIANTS.room.length - 1)]; + const glitchLabel = LABEL_VARIANTS.glitch[Math.min(variantLevel, LABEL_VARIANTS.glitch.length - 1)]; + const quickLabel = LABEL_VARIANTS.quick[Math.min(variantLevel, LABEL_VARIANTS.quick.length - 1)]; + + const toggleWidths = calcLabelWidth(slideLabel) + calcLabelWidth(roomLabel) + + calcLabelWidth(glitchLabel) + calcLabelWidth(quickLabel) + (3 * TOGGLE_BTN_GAP); + + totalNeeded = metroFixedWidth + toggleWidths; + variantLevel++; + } + + // Use the labels that fit (or shortest if nothing fits) + const finalLevel = Math.min(variantLevel - 1, 2); + secondaryBarLabels = { + slide: LABEL_VARIANTS.slide[Math.min(finalLevel, LABEL_VARIANTS.slide.length - 1)], + room: LABEL_VARIANTS.room[Math.min(finalLevel, LABEL_VARIANTS.room.length - 1)], + glitch: LABEL_VARIANTS.glitch[Math.min(finalLevel, LABEL_VARIANTS.glitch.length - 1)], + quick: LABEL_VARIANTS.quick[Math.min(finalLevel, LABEL_VARIANTS.quick.length - 1)], + }; + + // Calculate toggle button widths with chosen labels + const toggleBtns = [ + { key: "slide", width: calcLabelWidth(secondaryBarLabels.slide) }, + { key: "room", width: calcLabelWidth(secondaryBarLabels.room) }, + { key: "glitch", width: calcLabelWidth(secondaryBarLabels.glitch) }, + { key: "quick", width: calcLabelWidth(secondaryBarLabels.quick) }, + ]; + const toggleTotalWidth = toggleBtns.reduce((sum, b) => sum + b.width, 0) + (toggleBtns.length - 1) * TOGGLE_BTN_GAP; + + // Position toggle buttons from right side + let toggleX = screen.width - rightMargin - toggleTotalWidth; + toggleBtns.forEach(({ key, width }) => { + const btn = new ui.Button(toggleX, btnY, width, btnHeight); + btn.id = `${key}-toggle`; + if (key === "slide") slideBtn = btn; + else if (key === "room") roomBtn = btn; + else if (key === "glitch") glitchBtn = btn; + else if (key === "quick") quickBtn = btn; + toggleX += width + TOGGLE_BTN_GAP; + }); + + // Position metronome controls from left, after MIDI badge + let btnX = leftReserved; + + // Build minus button + bpmMinusBtn = new ui.Button(btnX, btnY, minusBtnWidth, btnHeight); + bpmMinusBtn.id = "bpm-minus"; + btnX += minusBtnWidth + 1; + + // Build plus button (to the left of BPM display) + bpmPlusBtn = new ui.Button(btnX, btnY, plusBtnWidth, btnHeight); + bpmPlusBtn.id = "bpm-plus"; + btnX += plusBtnWidth + 1; + + // BPM display area + const bpmDisplayX = btnX; + btnX += bpmTextWidth + 1; + + // The BPM display itself is the metronome toggle button (no separate metro button) + metroBtn = new ui.Button(bpmDisplayX, btnY, bpmTextWidth, btnHeight); + metroBtn.id = "metro-toggle"; +} + +// Build toggle buttons - now handled by buildMetronomeButtons for unified layout +function buildToggleButtons({ screen, ui, typeface, text }) { + // Toggle buttons are now built in buildMetronomeButtons for responsive layout + // This function is kept for backwards compatibility but does nothing +} + +let primaryColor = [0, 0, 0]; +let currentAverage = [0, 0, 0]; + +let secondaryColor = [0, 0, 0]; +let lastAverage = [0, 0, 0]; +let lastActive = null; +let activeStr; + +function updateTheme({ num }) { + let colors = ["red"]; + const active = orderedByCount(sounds); + activeStr = active.join(""); + + if (activeStr.length > 0 && activeStr !== lastActive) { + lastActive = activeStr; + colors = active.map((note) => colorFromNote(note, num)); + const average = averageRGB(colors); + lastAverage = currentAverage; + currentAverage = average; + } + // lerp the primary color to the current average. +} + +// Average an array of [[r, g, b], [r, g, b]] values. +function averageRGB(colors) { + return colors + .reduce( + (acc, color) => { + return [acc[0] + color[0], acc[1] + color[1], acc[2] + color[2]]; + }, + [0, 0, 0], + ) + .map((sum) => round(sum / colors.length)); +} + +export { boot, paint, sim, act, configureAutopat }; diff --git a/easel/context/reference/lib/cam-doll.mjs b/easel/context/reference/lib/cam-doll.mjs new file mode 100644 index 000000000..9c423b203 --- /dev/null +++ b/easel/context/reference/lib/cam-doll.mjs @@ -0,0 +1,709 @@ +// TODO: +// CamDoll +// A 3D camera dolly with user input. + +// TODO: Use key repeat / e.repeat to shorten code if necessary / prevent +// unwanted resets of state / ✨ allow disabling of state... ✨ - 24.07.03.02.50 + +import { GAMEPAD_MAPPINGS } from "./gamepad-mappings.mjs"; +import { resolveObstacles } from "./pmove.mjs"; + +const { abs } = Math; + +export class CamDoll { + cam; + sensitivity; + + #dolly; + + // Keyboard controls. + #W; + #S; + #A; + #D; + #SPACE; + #SHIFT; + #UP; + #DOWN; + #LEFT; + #RIGHT; + + // Gamepad button look controls (8BitDo Micro face buttons) + #BTN_LOOK_UP; + #BTN_LOOK_DOWN; + #BTN_LOOK_LEFT; + #BTN_LOOK_RIGHT; + + // Button look velocity for smoothing + #buttonLookVelX = 0; + #buttonLookVelY = 0; + #buttonLookAccel = 0.05; // Even more subtle acceleration + #buttonLookDecel = 0.85; + #buttonLookMaxSpeed = 1.0; // Slower max speed + + // Stick based controls. + #ANALOG = { + move: { + x: 0, + y: 0, + z: 0, + }, + look: { + x: 0, + y: 0, + z: 0, + }, + }; + + #penLocked = false; + + // 🧗 Optional Quake-style grounded physics (enabled when opts.gravity is set). + // When enabled, SPACE jumps only while grounded, SHIFT crouches (clamped to + // ground, cannot dip below), WASD moves at runSpeed (walkSpeed while crouched), + // and vertical motion is integrated with gravity + a ground clamp. + // Conventions: speeds in world-units/second, gravity in u/s². cam.y is inverted + // relative to world Y (positive cam.y = below origin), hence world-up = -cam.y. + #physicsEnabled = false; + #runSpeed = 0; + #walkSpeed = 0; + #jumpVelocity = 0; + #gravity = 0; + #groundY = 0; + #eyeHeight = 1.5; + #crouchEyeHeight = 0.8; + #simHz = 120; + #worldYVel = 0; // world-space vertical velocity, + is up + #onGround = true; + #crouchT = 0; // 0 standing → 1 crouched + #moveDamp = 0.002; + // Optional XZ rectangle bounding the solid floor. Outside these bounds the + // floor clamp is disabled so the player falls off the edge. Format: + // { xMin, xMax, zMin, zMax } in world units. + #groundBounds = null; + // When true, all physics input is ignored and the camera just free-falls. + // External callers (e.g. a piece with a death state) set this via setFrozen(). + #frozen = false; + + // 🎥 Third-person mode. When enabled, the render camera lazily follows a + // target point (player − forward·distance + up·height). The current render + // position is stored in #tpCurrent and lerped toward the target each tick. + // Physics runs on the *logical* player position; the render offset is + // applied at the end of sim and undone at the start of the next. + #thirdPerson = false; + #thirdPersonDistance = 4.5; + #thirdPersonHeight = 1.5; + #thirdPersonFollow = 0.08; // per-tick lerp factor (≈100 ms to catch up) + #appliedOffset = [0, 0, 0]; + #tpCurrent = null; // current render offset (cam-space), init lazy + + // 💀 Death floor clamp — when set, a frozen (dead) player lands on this + // world Y instead of falling forever. + #deathFloorY = null; + + // 🏃 Quake-style air physics. Set to a number to enable strafe-jumping / + // bunny-hop: in air the dolly's horizontal decay is replaced by an + // air-accelerate primitive that adds velocity along wishdir without + // touching perpendicular speed. On the ground a friction+accelerate model + // takes over. Must mirror lib/pmove.mjs so the server arrives at the same + // state. + #airAccel = null; + #groundAccel = 80; + #airCapSpeed = 1.5; + #groundFriction = 6; + #obstacles = null; + #playerRadius = 0.4; + #deathFloorEyeClearance = 0.3; + + // 🖖 Disable built-in touch controls for pieces that implement custom mobile UI + #disableTouchControls = false; + + constructor(Camera, Dolly, opts) { + this.cam = new Camera(opts.fov || 80, { + z: opts.z || 0, + y: opts.y * -1 || -0.5, + scale: [1, 1, 1], + }); + this.sensitivity = opts.sensitivity || 0.00025; + this.#dolly = new Dolly(this.cam); // moves the camera + + if (opts.disableTouchControls === true) this.#disableTouchControls = true; + + if (opts.gravity !== undefined) { + this.#physicsEnabled = true; + this.#gravity = opts.gravity; + this.#runSpeed = opts.runSpeed ?? 10; + this.#walkSpeed = opts.walkSpeed ?? this.#runSpeed * 0.5; + this.#jumpVelocity = opts.jumpVelocity ?? 8; + this.#groundY = opts.groundY ?? 0; + this.#eyeHeight = opts.eyeHeight ?? 1.5; + this.#crouchEyeHeight = opts.crouchEyeHeight ?? this.#eyeHeight * 0.55; + this.#simHz = opts.simHz ?? 120; + // Dolly steady-state speed = push / (1 - decay) = 10 * push (decay 0.9). + // So analog gamepad moveDamp scales to hit runSpeed at full stick deflection. + this.#moveDamp = this.#runSpeed / this.#simHz / 10; + if (opts.groundBounds) this.#groundBounds = opts.groundBounds; + if (typeof opts.deathFloorY === "number") this.#deathFloorY = opts.deathFloorY; + // 🏃 Quake-style physics — opt-in. When enabled the dolly stops decaying + // (we own velocity) and friction/air-accel run inline below. + if (typeof opts.airAccel === "number") { + this.#airAccel = opts.airAccel; + if (typeof opts.groundAccel === "number") this.#groundAccel = opts.groundAccel; + if (typeof opts.airCapSpeed === "number") this.#airCapSpeed = opts.airCapSpeed; + if (typeof opts.groundFriction === "number") this.#groundFriction = opts.groundFriction; + } + if (Array.isArray(opts.obstacles)) this.#obstacles = opts.obstacles; + if (typeof opts.playerRadius === "number") this.#playerRadius = opts.playerRadius; + // Snap camera onto the floor at boot. + this.cam.y = -(this.#groundY + this.#eyeHeight); + } + } + + /** Clear every held input state. Called on window defocus / visibility loss + * so keys don't "stick" if the user alt-tabs mid-movement. */ + clearHeldKeys() { + this.#W = this.#S = this.#A = this.#D = false; + this.#SPACE = this.#SHIFT = false; + this.#UP = this.#DOWN = this.#LEFT = this.#RIGHT = false; + this.#BTN_LOOK_UP = this.#BTN_LOOK_DOWN = false; + this.#BTN_LOOK_LEFT = this.#BTN_LOOK_RIGHT = false; + this.#ANALOG.move.x = this.#ANALOG.move.y = this.#ANALOG.move.z = 0; + this.#ANALOG.look.x = this.#ANALOG.look.y = this.#ANALOG.look.z = 0; + } + + /** Teleport the camera back to standing on the floor at the configured + * ground position, clearing velocity. Used for respawn. */ + respawn(worldX = 0, worldZ = 0) { + this.cam.x = -worldX; + this.cam.z = -worldZ; + this.cam.y = -(this.#groundY + this.#eyeHeight); + this.#worldYVel = 0; + this.#onGround = true; + this.#frozen = false; + // Kill any residual momentum in the dolly so we don't slide on spawn. + this.#dolly.xVel = this.#dolly.yVel = this.#dolly.zVel = 0; + this.clearHeldKeys(); + } + + /** When frozen the physics pass is skipped entirely (e.g. during a death + * animation). Player input is already zeroed via clearHeldKeys. */ + setFrozen(v) { + this.#frozen = !!v; + if (v) this.clearHeldKeys(); + } + + /** Enable / disable third-person view. On entry the render offset is + * re-initialised so the camera snaps into position; on exit it instantly + * returns to first person. */ + setThirdPerson(v, distance, height) { + this.#thirdPerson = !!v; + if (typeof distance === "number") this.#thirdPersonDistance = distance; + if (typeof height === "number") this.#thirdPersonHeight = height; + if (!this.#thirdPerson) this.#tpCurrent = null; + } + + /** Flip between 1P and 3P. Use for single-tap / middle-mouse bindings. */ + toggleThirdPerson() { + this.setThirdPerson(!this.#thirdPerson); + } + + /** True if third-person mode is currently active. */ + get thirdPerson() { return this.#thirdPerson; } + + /** Apply a one-shot impulse in world space. Horizontal goes through the + * dolly (decay-driven, so it fades over ~10 frames); vertical adds to + * worldYVel directly. Used by external systems like grenade explosions. */ + applyImpulse({ x = 0, y = 0, z = 0 } = {}) { + // cam.x = -worldX, cam.z = -worldZ; dolly applies xVel directly to cam.x, + // so to push +worldX we feed -x into the dolly. + // In Q3 mode our dolly.xVel is per-frame (= worldVel/simHz), so scale + // accordingly. Legacy mode keeps the old per-tick convention untouched. + const scale = this.#airAccel !== null ? 1 / this.#simHz : 1; + if (x) this.#dolly.xVel += -x * scale; + if (z) this.#dolly.zVel += -z * scale; + if (y) { + this.#worldYVel += y; + if (y > 0) this.#onGround = false; + } + } + + /** Set movement key state directly (for mobile UI buttons). */ + setMovement(dir, pressed) { + if (dir === "forward") this.#W = pressed; + else if (dir === "back") this.#S = pressed; + else if (dir === "left") this.#A = pressed; + else if (dir === "right") this.#D = pressed; + else if (dir === "jump") this.#SPACE = pressed; + else if (dir === "crouch") this.#SHIFT = pressed; + } + + /** Live telemetry for HUDs / debug panels. */ + get physics() { + if (!this.#physicsEnabled) return null; + return { + fov: this.cam.fov, + runSpeed: this.#runSpeed, + walkSpeed: this.#walkSpeed, + jumpVelocity: this.#jumpVelocity, + gravity: this.#gravity, + onGround: this.#onGround, + crouch: this.#crouchT, + worldYVel: this.#worldYVel, + thirdPerson: this.#thirdPerson, + // Logical player position in the camera's negated-world coordinate + // system (useful for anchoring body parts at the player, not at the + // offset render camera). + playerCamX: this.cam.x - this.#appliedOffset[0], + playerCamY: this.cam.y - this.#appliedOffset[1], + playerCamZ: this.cam.z - this.#appliedOffset[2], + }; + } + + sim() { + // 🎥 Undo last frame's third-person offset so physics runs on the logical + // player position. We'll reapply a fresh offset at the end of this tick. + if (this.#appliedOffset[0] !== 0 || this.#appliedOffset[1] !== 0 || this.#appliedOffset[2] !== 0) { + this.cam.x -= this.#appliedOffset[0]; + this.cam.y -= this.#appliedOffset[1]; + this.cam.z -= this.#appliedOffset[2]; + this.#appliedOffset[0] = this.#appliedOffset[1] = this.#appliedOffset[2] = 0; + } + + // 🔫 FPS style camera movement. + let forward = 0, + updown = 0, + strafe = 0; + + if (this.#physicsEnabled) { + // Quake-style: scale push so Dolly's 0.9-decay momentum settles at the + // requested run/walk speed. Steady-state vel/frame = push/(1-decay), + // and we want vel/frame = speed/simHz, so push = speed/(simHz*10). + const speed = this.#SHIFT ? this.#walkSpeed : this.#runSpeed; + const push = speed / this.#simHz / 10; + if (this.#W) forward = -push; + if (this.#S) forward = push; + if (this.#A) strafe = push; + if (this.#D) strafe = -push; + // SPACE and SHIFT no longer push Y — handled in the physics pass below. + // In Q3 mode we own horizontal velocity directly (qStep below); skip + // dolly.push so we don't double-apply input. + if ((this.#W || this.#S || this.#A || this.#D) && this.#airAccel === null) { + this.#dolly.push({ x: strafe, y: 0, z: forward }); + } + } else { + if (this.#W) forward = -this.sensitivity; + if (this.#S) forward = this.sensitivity; + if (this.#A) strafe = this.sensitivity; // A moves left (positive in rotated space) + if (this.#D) strafe = -this.sensitivity; // D moves right (negative in rotated space) + if (this.#SPACE) updown = -this.sensitivity; + if (this.#SHIFT) updown = this.sensitivity; + + if ( + this.#W || + this.#S || + this.#A || + this.#D || + this.#SPACE || + this.#SHIFT + ) { + this.#dolly.push({ x: strafe, y: updown, z: forward }); + } + } + + if ( + (abs(this.#ANALOG.move.z) > 0 || abs(this.#ANALOG.move.x) > 0) && + this.#airAccel === null + ) { + // console.log("Over zero:", this.#ANALOG.move.z); + this.#dolly.push({ + x: this.#ANALOG.move.x, + y: 0, + z: this.#ANALOG.move.z, + }); + } + + if (abs(this.#ANALOG.look.x) > 0 || abs(this.#ANALOG.look.y) > 0) { + this.cam.rotX += this.#ANALOG.look.x; + this.cam.rotY += this.#ANALOG.look.y; + } + + // Button-based looking with acceleration/deceleration (gamepad face buttons) + // Apply acceleration when buttons are held + if (this.#BTN_LOOK_UP || this.#BTN_LOOK_DOWN) { + if (this.#BTN_LOOK_UP) this.#buttonLookVelY += this.#buttonLookAccel; + if (this.#BTN_LOOK_DOWN) this.#buttonLookVelY -= this.#buttonLookAccel; + + // Clamp to max speed + if (this.#buttonLookVelY > this.#buttonLookMaxSpeed) this.#buttonLookVelY = this.#buttonLookMaxSpeed; + if (this.#buttonLookVelY < -this.#buttonLookMaxSpeed) this.#buttonLookVelY = -this.#buttonLookMaxSpeed; + } else { + // Decelerate when no button pressed + this.#buttonLookVelY *= this.#buttonLookDecel; + if (abs(this.#buttonLookVelY) < 0.01) this.#buttonLookVelY = 0; + } + + if (this.#BTN_LOOK_LEFT || this.#BTN_LOOK_RIGHT) { + if (this.#BTN_LOOK_RIGHT) this.#buttonLookVelX += this.#buttonLookAccel; + if (this.#BTN_LOOK_LEFT) this.#buttonLookVelX -= this.#buttonLookAccel; + + // Clamp to max speed + if (this.#buttonLookVelX > this.#buttonLookMaxSpeed) this.#buttonLookVelX = this.#buttonLookMaxSpeed; + if (this.#buttonLookVelX < -this.#buttonLookMaxSpeed) this.#buttonLookVelX = -this.#buttonLookMaxSpeed; + } else { + // Decelerate when no button pressed + this.#buttonLookVelX *= this.#buttonLookDecel; + if (abs(this.#buttonLookVelX) < 0.01) this.#buttonLookVelX = 0; + } + + // Apply button look velocity to camera + this.cam.rotX += this.#buttonLookVelY; + this.cam.rotY += this.#buttonLookVelX; + + // Keyboard arrow keys + if (this.#UP) this.cam.rotX += 1; + if (this.#DOWN) this.cam.rotX -= 1; + if (this.#LEFT) this.cam.rotY -= 1; + if (this.#RIGHT) this.cam.rotY += 1; + + // Clamp pitch to prevent looking past straight up/down (prevents gimbal lock) + const maxPitch = 89; + if (this.cam.rotX > maxPitch) this.cam.rotX = maxPitch; + if (this.cam.rotX < -maxPitch) this.cam.rotX = -maxPitch; + + // 🏃 Q3 step: when airAccel is enabled we own the dolly's horizontal + // velocity. Read previous-frame world velocity from the dolly, run + // friction (ground) + accelerate (ground/air), and write back. The + // dolly's decay is set to 1 so dolly.sim() acts as pure integration. + if (this.#physicsEnabled && this.#airAccel !== null) { + this.#dolly.dec = 1; + const dt = 1 / this.#simHz; + + // Per-frame dolly velocities are camera-coords (cam.x = -worldX), + // so worldVel = -dollyVel * simHz. Convert in & out. + let wvx = -this.#dolly.xVel * this.#simHz; + let wvz = -this.#dolly.zVel * this.#simHz; + + // Wishvel from W/S/A/D + analog (camera-local → world by yaw). + let ix = 0, iz = 0; + if (this.#D) ix += 1; + if (this.#A) ix -= 1; + if (this.#W) iz += 1; + if (this.#S) iz -= 1; + if (this.#moveDamp > 0) { + const ax = this.#ANALOG.move.x / this.#moveDamp; + const az = this.#ANALOG.move.z / this.#moveDamp; + // Analog convention: dolly.push x = camera-local +x (A→strafe-left), + // z = -forward (W → -push). Our ix is "right=+1", iz is "fwd=+1", so + // negate to map. + ix += -ax > 1 ? 1 : -ax < -1 ? -1 : -ax; + iz += -az > 1 ? 1 : -az < -1 ? -1 : -az; + } + const ilen = Math.hypot(ix, iz); + if (ilen > 1) { ix /= ilen; iz /= ilen; } + + const speed = this.#SHIFT ? this.#walkSpeed : this.#runSpeed; + const yr = this.cam.rotY * Math.PI / 180; + const sy = Math.sin(yr), cy = Math.cos(yr); + const wx = (ix * cy + iz * sy) * speed; + const wz = (-ix * sy + iz * cy) * speed; + const wishLen = Math.hypot(wx, wz); + const wdx = wishLen > 1e-6 ? wx / wishLen : 0; + const wdz = wishLen > 1e-6 ? wz / wishLen : 0; + + // Edge-trigger jump BEFORE friction so a bhop landing keeps speed. + if (!this.#frozen && this.#SPACE && this.#onGround) { + this.#worldYVel = this.#jumpVelocity; + this.#onGround = false; + } + + // Friction (ground only). + if (this.#onGround && !this.#frozen) { + const sp = Math.hypot(wvx, wvz); + if (sp < 1e-4) { wvx = 0; wvz = 0; } + else { + const stop = Math.max(sp, this.#runSpeed * 0.5); + const drop = stop * this.#groundFriction * dt; + const k = Math.max(0, sp - drop) / sp; + wvx *= k; wvz *= k; + } + } + + // Accelerate. + if (wishLen > 1e-6 && !this.#frozen) { + const accel = this.#onGround ? this.#groundAccel : this.#airAccel; + const cap = this.#onGround ? wishLen : Math.min(wishLen, this.#airCapSpeed); + const cur = wvx * wdx + wvz * wdz; + const addspeed = cap - cur; + if (addspeed > 0) { + let mag = accel * dt * cap; + if (mag > addspeed) mag = addspeed; + wvx += wdx * mag; wvz += wdz * mag; + } + } + + // Write back as per-frame cam-coords for dolly.sim() to integrate. + this.#dolly.xVel = -wvx / this.#simHz; + this.#dolly.zVel = -wvz / this.#simHz; + } + + this.#dolly.sim(); + + // Static obstacle push-out (after horizontal integration). + if (this.#airAccel !== null && this.#obstacles) { + const ws = { + x: -this.cam.x, z: -this.cam.z, + y: -this.cam.y, + vx: -this.#dolly.xVel * this.#simHz, + vz: -this.#dolly.zVel * this.#simHz, + }; + resolveObstacles(ws, { + obstacles: this.#obstacles, + playerRadius: this.#playerRadius, + eyeHeight: this.#eyeHeight, + }); + this.cam.x = -ws.x; + this.cam.z = -ws.z; + this.#dolly.xVel = -ws.vx / this.#simHz; + this.#dolly.zVel = -ws.vz / this.#simHz; + } + + // 🌍 Grounded physics pass — only when opted-in via opts.gravity. + if (this.#physicsEnabled) { + const dt = 1 / this.#simHz; + + // Jump: SPACE while grounded. Holding SPACE re-jumps on landing. + if (!this.#frozen && this.#SPACE && this.#onGround) { + this.#worldYVel = this.#jumpVelocity; + this.#onGround = false; + } + + // Gravity + vertical integration. Always runs, even when frozen, so a + // dead player still falls into the pit at a constant rate. + if (!this.#onGround || this.#frozen) { + this.#worldYVel -= this.#gravity * dt; + // cam.y is inverted from world Y (see constructor): world-up is -cam.y, + // so a positive worldYVel (up) decreases cam.y. + this.cam.y -= this.#worldYVel * dt; + } + + // Crouch lerp (shift toggles between stand and crouch eye height). + const crouchTarget = (!this.#frozen && this.#SHIFT) ? 1 : 0; + this.#crouchT += (crouchTarget - this.#crouchT) * 0.25; + if (this.#crouchT < 0.0005 && crouchTarget === 0) this.#crouchT = 0; + if (this.#crouchT > 0.9995 && crouchTarget === 1) this.#crouchT = 1; + const effEyeHeight = + this.#eyeHeight + + (this.#crouchEyeHeight - this.#eyeHeight) * this.#crouchT; + + // Only floor-clamp when the player is within the solid ground bounds. + // Outside the rectangle, gravity continues uninterrupted → pit fall. + let onSolidGround = true; + if (this.#groundBounds) { + const px = -this.cam.x; + const pz = -this.cam.z; + const b = this.#groundBounds; + onSolidGround = + px >= b.xMin && px <= b.xMax && pz >= b.zMin && pz <= b.zMax; + } + + const floorCamY = -(this.#groundY + effEyeHeight); + if (onSolidGround && !this.#frozen) { + if (this.cam.y >= floorCamY) { + this.cam.y = floorCamY; + if (this.#worldYVel < 0) this.#worldYVel = 0; + this.#onGround = true; + } else if (this.#onGround) { + // If we're above the floor but onGround was set (e.g., eye height + // just shrank because of crouch release), keep the eye glued to the + // ground by snapping cam.y — no falling from a crouch-release. + this.cam.y = floorCamY; + } + } else { + // Over the edge (or frozen/dead): never grounded, just keep falling. + this.#onGround = false; + } + + // 💀 Death floor clamp — once frozen and the eye reaches the lava, + // stop falling so the player lies on top of the pit instead of + // descending forever. + if (this.#frozen && this.#deathFloorY !== null) { + const lavaCamY = -(this.#deathFloorY + this.#deathFloorEyeClearance); + if (this.cam.y >= lavaCamY) { + this.cam.y = lavaCamY; + this.#worldYVel = 0; + } + } + } + + // 🎥 Third-person lazy-follow. Compute the target offset (behind + above + // the player along the current look direction) and lerp the applied + // offset toward it. The offset is added to cam.x/y/z for rendering only + // and is undone at the start of the next tick. + if (this.#thirdPerson) { + const rx = this.cam.rotX * Math.PI / 180; + const ry = this.cam.rotY * Math.PI / 180; + const cp = Math.cos(rx); + const fx = Math.sin(ry) * cp; + const fy = Math.sin(rx); + const fz = Math.cos(ry) * cp; + const d = this.#thirdPersonDistance; + const h = this.#thirdPersonHeight; + // Target offset in cam-space coords. + const targetX = d * fx; + const targetZ = d * fz; + const targetY = d * fy - h; + + // Lazy lerp: on the first frame after enabling 3P, snap; otherwise ease. + if (!this.#tpCurrent) { + this.#tpCurrent = [targetX, targetY, targetZ]; + } else { + const k = this.#thirdPersonFollow; + this.#tpCurrent[0] += (targetX - this.#tpCurrent[0]) * k; + this.#tpCurrent[1] += (targetY - this.#tpCurrent[1]) * k; + this.#tpCurrent[2] += (targetZ - this.#tpCurrent[2]) * k; + } + this.#appliedOffset[0] = this.#tpCurrent[0]; + this.#appliedOffset[1] = this.#tpCurrent[1]; + this.#appliedOffset[2] = this.#tpCurrent[2]; + this.cam.x += this.#appliedOffset[0]; + this.cam.y += this.#appliedOffset[1]; + this.cam.z += this.#appliedOffset[2]; + } + } + + // TODO: Also add touch controls here. + act(e) { + // 🔇 Window lost focus — drop every held input so keys don't "stick" when + // the user alt-tabs / switches apps mid-movement. Same for visibility loss. + if (e.is("defocus")) { + this.clearHeldKeys(); + return; + } + + // 💻️ Keyboard: WASD, SPACE and SHIFT for movement, ARROW KEYS for looking. + if (e.is("keyboard:down:w")) this.#W = true; + if (e.is("keyboard:down:s")) this.#S = true; + if (e.is("keyboard:down:a")) this.#A = true; + if (e.is("keyboard:down:d")) this.#D = true; + + if (e.is("keyboard:up:w")) this.#W = false; + if (e.is("keyboard:up:s")) this.#S = false; + if (e.is("keyboard:up:a")) this.#A = false; + if (e.is("keyboard:up:d")) this.#D = false; + + if (e.is("keyboard:down:space")) this.#SPACE = true; + if (e.ctrl === false && e.is("keyboard:down:shift")) this.#SHIFT = true; + if (e.is("keyboard:up:space")) this.#SPACE = false; + if (e.is("keyboard:up:shift")) this.#SHIFT = false; + + if (e.is("keyboard:down:arrowup")) this.#UP = true; + if (e.is("keyboard:down:arrowdown")) this.#DOWN = true; + if (e.is("keyboard:down:arrowleft")) this.#LEFT = true; + if (e.is("keyboard:down:arrowright")) this.#RIGHT = true; + + if (e.is("keyboard:up:arrowup")) this.#UP = false; + if (e.is("keyboard:up:arrowdown")) this.#DOWN = false; + if (e.is("keyboard:up:arrowleft")) this.#LEFT = false; + if (e.is("keyboard:up:arrowright")) this.#RIGHT = false; + + if (e.is("pen:locked")) this.#penLocked = true; + if (e.is("pen:unlocked")) this.#penLocked = false; + + // Note: Sometimes multiple mouse buttons can be held... in which case + // e.button only holds the original (duplicate events are not sent). + // TODO: These rates should be adjusted on mobile. + if ( + (!this.#penLocked && e.is("draw")) || + (this.#penLocked && e.is("move")) + ) { + if (this.cam.type === "perspective") { + this.cam.rotX -= e.delta.y / 3.5; + this.cam.rotY += e.delta.x / 3.5; + + // Clamp pitch to prevent looking past straight up/down + const maxPitch = 89; + if (this.cam.rotX > maxPitch) this.cam.rotX = maxPitch; + if (this.cam.rotX < -maxPitch) this.cam.rotX = -maxPitch; + } + } + + // 🖖 Touch (disabled if piece handles custom mobile UI) + if (!this.#disableTouchControls) { + // Two fingers for move forward. + if (e.is("touch:2")) this.#W = true; + if (e.is("lift:2")) this.#W = false; + + // Three fingers for moving backward. + if (e.is("touch:3")) this.#S = true; + if (e.is("lift:3")) this.#S = false; + } + + // 🎮 Gamepad + + if (e.is("gamepad")) { + const deadzone = 0.05; + const moveDamp = this.#moveDamp; + const lookDamp = 1; + + // Detect controller type + const gamepadId = e.gamepadId || "standard"; + const is8BitDoMicro = gamepadId.includes("8BitDo Micro"); + const mapping = GAMEPAD_MAPPINGS[gamepadId] || GAMEPAD_MAPPINGS["standard"]; + + // 🕹️ D-PAD MOVEMENT (axes 0-1) + // Axis 1: Forward/Backward (up/down = W/S) + if (e.is("gamepad:0:axis:1")) { + if (abs(e.value) < deadzone) { + this.#ANALOG.move.z = 0; + } else { + this.#ANALOG.move.z = e.value * moveDamp; + } + } + + // Axis 0: Strafe Left/Right (A/D) + if (e.is("gamepad:0:axis:0")) { + if (abs(e.value) < deadzone) { + this.#ANALOG.move.x = 0; + } else { + // For 8BitDo Micro: left=-1 should move left (negative x), right=+1 should move right (positive x) + this.#ANALOG.move.x = -e.value * moveDamp; // Negated to fix direction + } + } + + // 🎮 Button-based looking (for controllers without right analog stick like 8BitDo Micro) + // Check for button events + if (e.button !== undefined && e.action) { + const btn = e.button; + const isPressed = e.action === "push"; + + // For 8BitDo Micro: use face buttons for looking + if (is8BitDoMicro) { + if (btn === 0) this.#BTN_LOOK_RIGHT = isPressed; // A (right) + if (btn === 1) this.#BTN_LOOK_DOWN = isPressed; // B (bottom) + if (btn === 3) this.#BTN_LOOK_UP = isPressed; // X (top) + if (btn === 4) this.#BTN_LOOK_LEFT = isPressed; // Y (left) + } + } + + // 🕹️ ANALOG STICK LOOKING (for standard controllers with right analog stick) + if (!is8BitDoMicro) { + // Axis 3: Look Up/Down (right stick vertical) → controls pitch (rotX) + if (e.is("gamepad:0:axis:3:move")) { + if (abs(e.value) < deadzone) { + this.#ANALOG.look.x = 0; + } else { + this.#ANALOG.look.x = -e.value * lookDamp; + } + } + + // Axis 2: Look Left/Right (right stick horizontal) → controls yaw (rotY) + if (e.is("gamepad:0:axis:2:move")) { + if (abs(e.value) < deadzone) { + this.#ANALOG.look.y = 0; + } else { + this.#ANALOG.look.y = e.value * lookDamp; + } + } + } + } + } +} diff --git a/easel/context/reference/lib/disk.mjs b/easel/context/reference/lib/disk.mjs new file mode 100644 index 000000000..629b8e176 --- /dev/null +++ b/easel/context/reference/lib/disk.mjs @@ -0,0 +1,17504 @@ +// Manages a piece and the transitions between pieces like a +// hypervisor or shell. + +/* #region 🏁 todo +#endregion */ + +// 📊 Worker timing telemetry - stores timing data to be sent to main thread +const diskTimings = { + sessionStarted: null, + preambleComplete: null, + loadStarted: null, + fetchComplete: null, + compileComplete: null, + bootStarted: null, + bootComplete: null, + firstPaint: null, + firstRenderSent: null, +}; +const diskTimingStart = performance.now(); + +// 🔬 Module load timing +const _importStart = performance.now(); + +import * as quat from "../dep/gl-matrix/quat.mjs"; +import * as mat3 from "../dep/gl-matrix/mat3.mjs"; +import * as mat4 from "../dep/gl-matrix/mat4.mjs"; +import * as vec2 from "../dep/gl-matrix/vec2.mjs"; +import * as vec3 from "../dep/gl-matrix/vec3.mjs"; +import * as vec4 from "../dep/gl-matrix/vec4.mjs"; + +import * as graph from "./graph.mjs"; +import { setAskTokenProvider } from "./ask.mjs"; +import { + splitColorCodes, + stripColorCodes as stripCodes, + hasColorCodes, + mapColorCodes, +} from "./color-codes.mjs"; + +import * as num from "./num.mjs"; +import * as text from "./text.mjs"; +import * as geo from "./geo.mjs"; +import * as gizmo from "./gizmo.mjs"; +import * as ui from "./ui.mjs"; +import * as help from "./help.mjs"; +import * as platform from "./platform.mjs"; + +import { signed as shop } from "./shop.mjs"; +import { parse, metadata, inferTitleDesc, updateCode } from "./parse.mjs"; +import { Socket } from "./socket.mjs"; // TODO: Eventually expand to `net.Socket` +import { Diagnostics } from "./diagnostics.mjs"; // Reports a piece's health to its author. +import { Chat } from "./chat.mjs"; // TODO: Eventually expand to `net.Socket` +import { + notArray, + defaultTemplateStringProcessor, + uint8ArrayToBase64, + base64ToUint8Array, +} from "./helpers.mjs"; +import { createRestrictedApi, shouldRestrictPiece } from "./restricted-api.mjs"; +import { clearPermissions, resolvePermissionRequest } from "./piece-permissions.mjs"; +const { pow, abs, round, sin, random, min, max, floor, cos } = Math; +const { keys } = Object; + +import { nopaint_boot, nopaint_act, nopaint_is, nopaint_cancelStroke, nopaint_renderPerfHUD, nopaint_triggerBakeFlash } from "../systems/nopaint.mjs"; +import { getPreserveFadeAlpha, setPreserveFadeAlpha } from "./fade-state.mjs"; +import * as prompt from "../systems/prompt-system.mjs"; +import * as world from "../systems/world.mjs"; +import { headers } from "./headers.mjs"; +import { logs, log } from "./logs.mjs"; +import { soundWhitelist } from "./sound/sound-whitelist.mjs"; + +import { CamDoll } from "./cam-doll.mjs"; + +import { TextFields, TextInput, Typeface } from "../lib/type.mjs"; + +import * as lisp from "./kidlisp.mjs"; +import { isKidlispSource, fetchCachedCode, fetchKidlispMetadata, getCachedCode, initPersistentCache, getCachedCodeMultiLevel, saveCodeToAllCaches, enableKidlispConsole, enableKidlispTrace, disableKidlispTrace, clearExecutionTrace, postExecutionTrace } from "./kidlisp.mjs"; // Add lisp evaluator. +import { makeP5WorkerModule } from "./p5-worker.mjs"; // .js pieces → real p5 in this worker (Option B). +import * as l5 from "./l5.mjs"; + +import { qrcode as qr, ErrorCorrectLevel } from "../dep/@akamfoad/qr/qr.mjs"; +import { microtype, MatrixChunky8 } from "../disks/common/fonts.mjs"; +import { + calculateVHSEffects, + getLeaderPixelColor, + blendColorWithVHS +} from "../disks/common/tape-player.mjs"; + +const _importEnd = performance.now(); +// Only log if imports took more than 50ms (slow network or first load) +if (_importEnd - _importStart > 50) { + console.log(`📦 [DISK] Imports: ${(_importEnd - _importStart).toFixed(0)}ms`); +} + +function matrixDebugEnabled() { + if (typeof window !== "undefined" && window?.acMatrixDebug) return true; + if (typeof globalThis !== "undefined" && globalThis?.acMatrixDebug) + return true; + return false; +} + +function inkFloodLoggingEnabled() { + if (typeof globalThis !== "undefined" && globalThis.AC_LOG_INK_COLORS) return true; + if (typeof process !== "undefined" && process.env?.AC_LOG_INK_COLORS === "1") return true; + return false; +} + +function inkFloodLogPrefix() { + let label = null; + if (typeof process !== "undefined" && process.env?.AC_LOG_INK_LABEL) { + label = process.env.AC_LOG_INK_LABEL; + } else if (typeof globalThis !== "undefined" && globalThis.AC_LOG_INK_LABEL) { + label = globalThis.AC_LOG_INK_LABEL; + } + return label ? `[${label}] ` : ""; +} + +function cloneColorForLog(color) { + if (Array.isArray(color)) return Array.from(color); + return color; +} + +function cloneArgsForLog(args) { + return Array.from(args).map((arg) => { + if (Array.isArray(arg)) return Array.from(arg); + if (arg && typeof arg === "object") { + try { + return JSON.parse(JSON.stringify(arg)); + } catch (err) { + return String(arg); + } + } + return arg; + }); +} + +if (typeof globalThis !== "undefined") { + if (globalThis.acMatrixDebug === undefined) { + globalThis.acMatrixDebug = true; + } + if (typeof window !== "undefined" && window.acMatrixDebug === undefined) { + window.acMatrixDebug = globalThis.acMatrixDebug; + } + + // 🖼️ Expose image cache utilities for debugging + if (typeof window !== "undefined") { + window.acClearImageCache = () => { + imageCache.clear(); + console.log("🗑️ Image cache cleared"); + }; + window.acImageCacheStats = () => { + console.log("🖼️ Image cache stats:", { + memorySize: imageCache.memory.size, + urls: Array.from(imageCache.memory.keys()) + }); + }; + } +} + +// Fetch piece metadata (trust level, author, etc.) from API +async function fetchPieceMetadata(pieceCode) { + try { + const { protocol, hostname } = getSafeUrlParts(); + const apiUrl = `${protocol}//${hostname}/api/piece-metadata?code=${encodeURIComponent(pieceCode)}`; + + const response = await fetch(apiUrl); + if (!response.ok) { + console.warn(`⚠️ Failed to fetch metadata for piece: ${pieceCode}`); + return { code: pieceCode, trustLevel: "untrusted", anonymous: true }; + } + + const metadata = await response.json(); + return metadata; + } catch (error) { + console.warn(`⚠️ Error fetching piece metadata:`, error); + // Default to untrusted on error + return { code: pieceCode, trustLevel: "untrusted", anonymous: true }; + } +} + +// Helper function to safely check for sandboxed environments in both main thread and worker contexts +function isSandboxed() { + try { + // Check for spider mode first + if (typeof window !== "undefined" && window.acSPIDER) { + return true; // Spider mode should behave like sandboxed for URL construction + } + + // In workers, window is undefined but we can still check self.origin or location + if (typeof window !== "undefined") { + return window.origin === "null"; + } else if (typeof self !== "undefined" && self.origin) { + return self.origin === "null"; + } else if (typeof location !== "undefined" && location.origin) { + return location.origin === "null"; + } else { + return false; // Default to not sandboxed if we can't determine + } + } catch (err) { + return false; // Default to not sandboxed if there's an error + } +} + +// Helper function to get safe protocol and hostname for URL construction +function getSafeUrlParts() { + try { + const sandboxed = isSandboxed(); + + if (sandboxed) { + return { + protocol: "https:", + hostname: "aesthetic.computer", + host: "aesthetic.computer" + }; + } else { + // Try to get location info from various contexts + let loc = null; + if (typeof location !== "undefined") { + loc = location; + } else if (typeof self !== "undefined" && self.location) { + loc = self.location; + } else if (typeof window !== "undefined" && window.location) { + loc = window.location; + } + + if (loc) { + return { + protocol: loc.protocol, + hostname: loc.hostname || loc.host, + // `host` keeps the port (localhost:8888) — hostname drops it, + // which breaks root-relative preloads on port-carrying dev hosts. + host: loc.host || loc.hostname + }; + } else { + // Fallback if no location available + return { + protocol: "https:", + hostname: "aesthetic.computer", + host: "aesthetic.computer" + }; + } + } + } catch (err) { + // Fallback to defaults if there's any error + return { + protocol: "https:", + hostname: "aesthetic.computer", + host: "aesthetic.computer" + }; + } +} + +const SAME_ORIGIN_BUILT_IN_PIECE_HOSTS = new Set([ + "aesthetic.computer", + "www.aesthetic.computer", + "kidlisp.com", + "www.kidlisp.com", + "notepat.com", + "www.notepat.com", + "nopaint.art", + "www.nopaint.art", + "oskiewar.com", + "www.oskiewar.com", + "laklok.com", + "www.laklok.com", + "p5.aesthetic.computer", + "sitemap.aesthetic.computer", +]); + +function isLocalDevelopmentHost(hostname) { + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + /^192\.168\./.test(hostname) || + /^10\./.test(hostname) || + /^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname) + ); +} + +function getBuiltInPieceBaseUrl() { + const { protocol, hostname } = getSafeUrlParts(); + + if (typeof window !== "undefined" && window.acSPIDER) { + return "https://aesthetic.computer"; + } + + if ( + isLocalDevelopmentHost(hostname) && + typeof location !== "undefined" && + location.port + ) { + return `${protocol}//${hostname}:${location.port}`; + } + + if (SAME_ORIGIN_BUILT_IN_PIECE_HOSTS.has(hostname)) { + return `${protocol}//${hostname}`; + } + + return "https://aesthetic.computer"; +} + +let tf; // Active typeface global. + +// Cache for loaded typefaces to avoid recreating them +const typefaceCache = new Map(); + +// 🔤 Dev helper: Clear all font caches (call from console: clearFontCaches()) +// This clears typeface instances, advance caches, and forces fonts.mjs to reload +async function clearFontCaches() { + console.log("🔤 Clearing font caches..."); + + // 1. Clear typeface instance cache + const clearedTypefaces = typefaceCache.size; + for (const [name, typeface] of typefaceCache) { + // Clear the advance cache on each typeface + if (typeface.advanceCache) { + typeface.advanceCache.clear(); + } + } + typefaceCache.clear(); + + // 2. Clear IndexedDB glyph cache + try { + const { clearGlyphCache } = await import("./type.mjs"); + if (clearGlyphCache) { + await clearGlyphCache(); + console.log("🔤 IndexedDB glyph cache cleared"); + } + } catch (e) { + console.log("🔤 IndexedDB cache clear skipped:", e.message); + } + + // 3. Force reimport of fonts.mjs by cache-busting + try { + const timestamp = Date.now(); + const fontsUrl = `../disks/common/fonts.mjs?t=${timestamp}`; + const fontsModule = await import(fontsUrl); + console.log("🔤 fonts.mjs reloaded, MatrixChunky8 * advance:", fontsModule.MatrixChunky8?.advances?.['*']); + } catch (e) { + console.log("🔤 fonts.mjs reload skipped:", e.message); + } + + console.log(`🔤 Cleared ${clearedTypefaces} typeface(s). Refresh page or load a piece to see changes.`); + return true; +} + +// Expose to window for dev console access +if (typeof window !== "undefined") { + window.clearFontCaches = clearFontCaches; + + // 🔤 Auto-clear font caches in dev mode on page load + const isDevMode = location.host === "localhost:8888" || + location.host === "aesthetic.local:8888" || + location.host === "local.aesthetic.computer"; + if (isDevMode) { + // Clear IndexedDB glyph cache on dev mode page loads + (async () => { + try { + const { clearGlyphCache } = await import("./type.mjs"); + if (clearGlyphCache) { + await clearGlyphCache(); + console.log("🔤 Dev mode: IndexedDB glyph cache auto-cleared"); + } + } catch (e) { + // Silently ignore if not available yet + } + })(); + } +} + +const DEFAULT_TYPEFACE_BLOCK_WIDTH = 6; +const DEFAULT_TYPEFACE_BLOCK_HEIGHT = 10; +const HUD_LABEL_TEXT_MARGIN = 0; +const KIDLISP_HUD_WRAP_CHARACTER_LIMIT = 28; +const MIN_HUD_WRAP_WIDTH = 120; + +// ✨ Corner label hover drift. Each character wanders on its own soft spring — +// a slow sideways sway plus an upward hop — so the word reads as alive and +// pressable while staying legible. Amplitudes are px, speeds are rad/ms +// (periods of 1.5–3s), and the whole thing scales by an eased 0..1 amount so +// it wakes up and settles instead of snapping. The hit box never moves. +const HUD_DRIFT_X = 1.5; +const HUD_DRIFT_Y = 2; +const HUD_DRIFT_EASE_IN_MS = 220; +const HUD_DRIFT_EASE_OUT_MS = 380; +// Golden-ratio scattered phases, so neighbouring letters never move in step. +const HUD_DRIFT_PHASES = Float32Array.from( + { length: 64 }, + (_, i) => ((i * 0.6180339887) % 1) * Math.PI * 2, +); + +function hudDriftEase(amount) { + return amount * amount * (3 - 2 * amount); // smoothstep +} + +function hudDriftX(i, t, ease) { + const p = HUD_DRIFT_PHASES[i & 63]; + return ( + HUD_DRIFT_X * + ease * + (0.65 * Math.sin(t * 0.0021 + p) + 0.35 * Math.sin(t * 0.0037 + p * 1.7)) + ); +} + +function hudDriftY(i, t, ease) { + const p = HUD_DRIFT_PHASES[(i + 17) & 63]; + // |sin| hops: sharp at the floor, soft at the top — the bounce. + return -HUD_DRIFT_Y * ease * Math.abs(Math.sin(t * 0.0028 + p)); +} + +// Resolve a `\code\` (or a shadow's "r,g,b") the way `write` does; `reset` +// falls back to the label's base color. +function hudCodeColor(code, base) { + if (!code) return base; + if (Array.isArray(code)) return code; + const s = code.trim(); + const lower = s.toLowerCase(); + if (lower === "reset" || lower === "default" || lower === "base") return base; + if (s.includes(",")) { + const parts = s.split(",").map((n) => parseInt(n.trim(), 10) || 0); + while (parts.length < 3) parts.push(0); + return parts.slice(0, 4); + } + return graph.findColor(s) ?? base; +} + +function resolveTypefaceInstance(typefaceRef) { + if (!typefaceRef) return undefined; + if (typefaceRef instanceof Typeface) return typefaceRef; + if (typeof typefaceRef === "string") { + if (typefaceCache.has(typefaceRef)) { + return typefaceCache.get(typefaceRef); + } + const instance = new Typeface(typefaceRef); + typefaceCache.set(typefaceRef, instance); + return instance; + } + if (typeof typefaceRef === "object" && typeof typefaceRef?.getGlyph === "function") { + return typefaceRef; + } + return undefined; +} + +function ensureTypefaceLoaded(typeface) { + if (!typeface || typeof typeface.load !== "function") return; + const requiresLoad = + typeface.data?.bdfFont || + typeface.name === "unifont" || + typeface.name === "MatrixChunky8"; + + if (!requiresLoad) return; + + if (!typeface.__loadPromise) { + typeface.__loadPromise = typeface.load($commonApi.net.preload, () => { + if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { + window.$activePaintApi.needsPaint(); + } + }); + } + + return typeface.__loadPromise; +} + +function getTypefaceForMeasurement(typefaceName) { + if (!typefaceName) return tf; + const resolved = resolveTypefaceInstance(typefaceName); + return resolved || tf; +} + +// Flag to skip $ replacement during shadow rendering +let _isRenderingShadow = false; + +function writeHudLabelText( + $, + text, + { + x = 0, + y = 0, + typefaceName, + preserveColors = true, + bounds, + wordWrap, + } = {}, +) { + if (text === undefined || text === null || text === "") return; + + const content = preserveColors ? text : stripColorCodes(text); + const effectiveBounds = typeof bounds === "number" && bounds > 0 ? bounds : undefined; + const shouldWrap = wordWrap === undefined ? effectiveBounds !== undefined : wordWrap; + + $.write( + content, + { x, y }, + undefined, + effectiveBounds, + shouldWrap, + typefaceName, + ); + + return $; +} + +// Helper function to determine if a color is dark (needs light shadow) or light (needs dark shadow) +function isColorDark(colorStr) { + + let rgb; + + // Handle RGB comma-separated format like "0,0,0" or "255,0,0" + if (typeof colorStr === 'string' && colorStr.includes(',')) { + const parts = colorStr.split(',').map(n => parseInt(n.trim(), 10)); + rgb = parts; + } + // Handle named colors + else if (typeof colorStr === 'string') { + const lower = colorStr.toLowerCase(); + const resolved = graph.findColor(lower); + if (Array.isArray(resolved)) { + rgb = resolved; + } else { + // For unknown colors, assume they're light + return false; + } + } + // Handle array format [r, g, b] + else if (Array.isArray(colorStr)) { + rgb = colorStr; + } + + if (!rgb || rgb.length < 3) return false; + + // Calculate relative luminance using the formula from WCAG + // https://www.w3.org/TR/WCAG20/#relativeluminancedef + const [r, g, b] = rgb; + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + // If luminance is less than 0.5, it's a dark color + return luminance < 0.5; +} + +// Helper function to get shadow color for a specific text color +const BRIGHT_SHADOW_RGB = "220,220,220"; +const DARK_SHADOW_RGB = "30,20,50"; // Dark purple-blue shadow for light mode + +function getShadowColorForText(colorStr) { + if (!colorStr) return "64,64,64"; // Default to dark gray shadow + + // Check if we're in light mode (need darker shadows for bright colors) + const isLightMode = $commonApi && !$commonApi.dark; + + let rgb; + let normalizedCommand = null; + if (typeof colorStr === "string") { + normalizedCommand = colorStr.trim().toLowerCase(); + // Treat complex command-based color strings (fade, scroll, random, etc.) as needing appropriate shadows + if (/[:(]/.test(normalizedCommand) || normalizedCommand.includes("?")) { + return isLightMode ? DARK_SHADOW_RGB : BRIGHT_SHADOW_RGB; + } + } + + // Parse the color string to get RGB values + if (typeof colorStr === 'string' && colorStr.includes(',')) { + const parts = colorStr.split(',').map(n => parseInt(n.trim(), 10)); + rgb = parts; + } + else if (typeof colorStr === 'string') { + const lower = colorStr.toLowerCase(); + const resolved = graph.findColor(lower); + if (Array.isArray(resolved)) { + rgb = resolved; + } else { + // Fallback to dark gray for unknown colors + return "64,64,64"; + } + } + else if (Array.isArray(colorStr)) { + rgb = colorStr; + } + + if (!rgb || rgb.length < 3) return "64,64,64"; + + const [r, g, b] = rgb; + + // Handle very dark colors (near black) before channel-specific logic + if (r <= 24 && g <= 24 && b <= 24) { + return BRIGHT_SHADOW_RGB; + } + + // Calculate luminance to check if color is bright + const luminance = (0.299 * r + 0.587 * g + 0.114 * b); + + // 🌞 LIGHT MODE: For bright colors on light background, use strong dark shadows + if (isLightMode && luminance > 140) { + // Use a consistent dark shadow for all bright colors in light mode + return DARK_SHADOW_RGB; + } + + // 🎨 Special case: Detect RGB channel colors and return channel-tinted shadows + // Red channel: R value, zero G and B + if (r >= 0 && g === 0 && b === 0) { + // Red channel shadow - darker red, stronger and more saturated + const shadowR = Math.max(32, Math.round(r * 0.4)); // Keep at least 40% intensity + return `${shadowR},0,0`; // Pure red shadow + } + + // Green channel: zero R, G value, zero B + if (r === 0 && g >= 0 && b === 0) { + // Green channel shadow - darker green + const shadowG = Math.max(32, Math.round(g * 0.4)); + return `0,${shadowG},0`; // Pure green shadow + } + + // Blue channel: zero R, G value (75% of B), B value (deepskyblue pattern: 0, 191, 255) + // Detects pattern where g = b * 0.75 (within rounding tolerance) + if (r === 0 && b >= 0 && g >= 0) { + const expectedG = Math.round(b * 0.75); + // Allow 1 pixel tolerance for rounding + if (Math.abs(g - expectedG) <= 1) { + // Blue/deepskyblue channel shadow - darker blue with cyan tint + const shadowG = Math.max(24, Math.round(g * 0.4)); + const shadowB = Math.max(32, Math.round(b * 0.4)); + return `0,${shadowG},${shadowB}`; // Cyan-blue shadow + } + } + + // Check if the color is dark - if so, lighten it for shadow + if (isColorDark(colorStr)) { + // Render near-white shadows under very dark text for readability + if (r <= 24 && g <= 24 && b <= 24) { + return "255,255,255"; + } + + // Lighten the color by mixing with white (stronger mix for dark colors) + const factor = 0.85; // Mix 85% with white for a much lighter shadow + const shadowR = Math.round(r + (255 - r) * factor); + const shadowG = Math.round(g + (255 - g) * factor); + const shadowB = Math.round(b + (255 - b) * factor); + return `${shadowR},${shadowG},${shadowB}`; + } + + // For light colors, darken by mixing with dark gray + const factor = 0.6; // Mix 60% darker for shadow + const shadowR = Math.round(r * (1 - factor)); + const shadowG = Math.round(g * (1 - factor)); + const shadowB = Math.round(b * (1 - factor)); + return `${shadowR},${shadowG},${shadowB}`; +} + +// Helper function to replace color codes in text with shadow colors +function replaceColorCodesWithShadows(text, defaultTextColor = "white") { + if (!text || !textContainsColorCodes(text)) return text; + + let currentTextColor = defaultTextColor; + + // Rewrite every code, leave the text (and its escapes) alone. + return mapColorCodes(text, (colorStr) => { + if (!colorStr) return colorStr; + + const normalized = colorStr.trim(); + const lower = normalized.toLowerCase(); + + if (lower === "reset" || lower === "default" || lower === "base") { + currentTextColor = defaultTextColor; + } else { + currentTextColor = normalized; + } + + // Get the appropriate shadow color for this text color. + // mapColorCodes re-wraps this in the `\…\` delimiters itself. + return getShadowColorForText(currentTextColor); + }); +} + +function drawHudLabelText( + $, + text, + { + x = 0, + y = 0, + typefaceName, + textColor = "white", + shadowColor, + shadowOffsetX = 1, + shadowOffsetY = 1, + preserveColors = true, + bounds, + wordWrap, + } = {}, +) { + if (!text) return; + + const containsColorCodes = textContainsColorCodes(text); + const shouldPreserveColors = preserveColors || ((typefaceName === "MatrixChunky8" || typefaceName === "unifont") && containsColorCodes); + + const effectiveBounds = typeof bounds === "number" && bounds > 0 ? bounds : undefined; + const shouldWrap = wordWrap === undefined ? effectiveBounds !== undefined : wordWrap; + + + let effectiveShadowColor = shadowColor; + if (!effectiveShadowColor && textColor) { + effectiveShadowColor = getShadowColorForText(textColor); + } + if (!effectiveShadowColor) { + effectiveShadowColor = "black"; + } + + + const shouldRenderShadow = effectiveShadowColor && !( + (typefaceName === "MatrixChunky8" || typefaceName === "unifont") && + matrixDebugEnabled() + ); + + if (shouldRenderShadow) { + _isRenderingShadow = true; + // If the text has color codes and we're preserving colors, use dynamic shadows + if (shouldPreserveColors && containsColorCodes) { + // Replace color codes with appropriate shadow colors + const shadowText = replaceColorCodesWithShadows(text, textColor); + + writeHudLabelText($, shadowText, { + x: x + shadowOffsetX, + y: y + shadowOffsetY, + typefaceName, + preserveColors: true, // Keep the shadow color codes + bounds: effectiveBounds, + wordWrap: shouldWrap, + }); + } else { + // Original behavior: single shadow color for entire text + $.ink(effectiveShadowColor); + writeHudLabelText($, text, { + x: x + shadowOffsetX, + y: y + shadowOffsetY, + typefaceName, + preserveColors: false, + bounds: effectiveBounds, + wordWrap: shouldWrap, + }); + } + _isRenderingShadow = false; + } + + if (textColor) { + $.ink(textColor); + } + + const content = shouldPreserveColors ? text : stripColorCodes(text); + $.write( + content, + { x, y }, + undefined, + effectiveBounds, + shouldWrap, + typefaceName, + ); +} + +// Hover twin of drawHudLabelText: one `write` per character so each can sit +// on its own drift offset. Two passes — all shadows first, then all glyphs — +// so no letter's shadow lands on a drifted neighbour. Color codes are walked +// with splitColorCodes, and `\n` starts a new line; wrapped text doesn't come +// here. `i` counts drawn characters and picks the phase. +function drawHudLabelDrift( + $, + text, + { x, y, typefaceName, typeface, textColor, lineStep, t, ease }, +) { + if (!text) return; + const parts = splitColorCodes(text); + const perCodeShadows = parts.length > 1; // plain labels keep a flat black shadow + const blockWidth = typeface?.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; + for (let pass = 0; pass < 2; pass++) { + const shadow = pass === 0; + let color = textColor; + let cx = x, + cy = y, + i = 0; + for (let p = 0; p < parts.length; p++) { + if (p % 2 === 1) { + color = hudCodeColor(parts[p], textColor); + continue; + } + const run = parts[p]; + for (let j = 0; j < run.length; j++) { + const ch = run[j]; + if (ch === "\n") { + cx = x; + cy += lineStep; + continue; + } + if (ch !== " ") { + const dx = Math.round(cx + hudDriftX(i, t, ease)); + const dy = Math.round(cy + hudDriftY(i, t, ease)); + if (shadow) { + $.ink( + perCodeShadows + ? hudCodeColor(getShadowColorForText(color), "black") + : "black", + ); + $.write(ch, { x: dx + 1, y: dy + 1 }, undefined, undefined, false, typefaceName); + } else { + $.ink(color); + $.write(ch, { x: dx, y: dy }, undefined, undefined, false, typefaceName); + } + } + cx += typeface?.getAdvance?.(ch) ?? blockWidth; + i++; + } + } + } +} + +export const noWorker = { onMessage: undefined, postMessage: undefined }; + +let ROOT_PIECE = "prompt"; // This gets set straight from the host html file for the ac. + +let USER; // A holder for the logged in user. (Defined in `boot`) +let sessionStarted = false; // A flag that waits to boot until a session was +// found or not. + +let LAN_HOST; // The IP address of the hosting machine on the local network. +let SHARE_SUPPORTED; // Whether navigator.share is supported. (For `dl`) +let PREVIEW_OR_ICON; // Whether we are in preview or icon mode. (From boot.) +let VSCODE; // Whether we are running the vscode extesion or not. (From boot.) +let TV_MODE = false; // Whether running in TV mode (disables touch/keyboard input) +let DEVICE_MODE = false; // Whether running in device mode (device.kidlisp.com - skip HUD overlays) +let SOLO_MODE = false; // Whether running in solo mode (prevents navigating away from piece) +let HIGHLIGHT_MODE = false; // Whether HUD highlighting is enabled +let HIGHLIGHT_COLOR = "64,64,64"; // Default highlight color (gray) +let PERF_MODE = false; // Whether to show KidLisp performance/FPS HUD +let AUTO_SCALE_MODE = false; // Whether to enable auto-density scaling to maintain target FPS +let SPOOF_AUDIO_MODE = false; // Whether to generate synthetic audio data (for headless captures) +let NOGAP_MODE = false; // Whether gap is disabled (nogap URL param) +let AUDIO_SAMPLE_RATE = 0; +let loopPaused = false; // Whether the main loop is paused (sent from bios) +let debug = false; // This can be overwritten on boot. +let nopaintPerf = false; // Performance panel for nopaint system debugging (disabled by default) +let visible = true; // Is aesthetic.computer visibly rendering or not? +let pieceBackground = false; // Does the current piece want background sim ticks? +// True screen buffer state — refreshed each frame after content.pixels transfer. +// _mainScreenObject tracks the actual screen object so page() can detect when a brush +// switches back to the main screen (vs a sub-buffer) by object identity. +let _trueScreenPixels = null; +let _trueScreenWidth = 0; +let _trueScreenHeight = 0; +let _mainScreenObject = null; + +// 🎯 Global KidLisp Instance - Single source of truth for all KidLisp operations +let globalKidLispInstance = null; + +// 🎨 Persistent first line color storage - survives instance resets +let persistentFirstLineColor = null; + +// 🎨 Global function to store background color from KidLisp (worker-safe) +function storePersistentFirstLineColor(color) { + persistentFirstLineColor = color; + // Also store in window if available + if (typeof window !== "undefined" && window.setPersistentFirstLineColor) { + window.setPersistentFirstLineColor(color); + } +} + +// 🎨 Global function to get background color for reframe (worker-safe) +function getPersistentFirstLineColor() { + + return persistentFirstLineColor; +} + +// 🎮 Create gamepad helper API for easy access to per-gamepad events +function createGamepadAPI(gamepadEvents) { + if (!gamepadEvents) return []; + + return gamepadEvents.map((events, index) => { + if (!events || events.length === 0) return null; + + return { + index, + events, + + // Helper to check if button was pushed/released + button(buttonIndex) { + const pushEvent = events.find(e => + e.button === buttonIndex && e.action === 'push' + ); + const releaseEvent = events.find(e => + e.button === buttonIndex && e.action === 'release' + ); + return { + pushed: !!pushEvent, + released: !!releaseEvent, + event: pushEvent || releaseEvent + }; + }, + + // Helper to get axis value (returns most recent value in frame) + axis(axisIndex) { + const axisEvent = events.findLast(e => e.axis === axisIndex); + return axisEvent ? axisEvent.value : 0; + }, + + // Check if this gamepad is connected (has events) + connected() { + return events && events.length > 0; + }, + + // Get the gamepad device ID/name + get id() { + return events[0]?.gamepadId || null; + } + }; + }); +} + +// 🎨 Make storage function globally available for KidLisp +if (typeof globalThis !== "undefined") { + globalThis.storePersistentFirstLineColor = storePersistentFirstLineColor; + globalThis.getPersistentFirstLineColor = getPersistentFirstLineColor; +} + +// Global function to set persistent first line color +if (typeof window !== "undefined") { + window.setPersistentFirstLineColor = function(color) { + persistentFirstLineColor = color; + }; + + // Global function to get persistent first line color + window.getPersistentFirstLineColor = function() { + return persistentFirstLineColor; + }; +} + +function resolveBackgroundFillSpec(colorLike) { + if (colorLike === undefined || colorLike === null) return null; + + let resolved; + try { + resolved = graph.findColor(colorLike); + } catch (err) { + return null; + } + + if (!Array.isArray(resolved) || resolved.length === 0) { + return null; + } + + const first = resolved[0]; + if (typeof first === "string" && first.startsWith("fade:")) { + const fadeInfo = graph.parseFadeColor(resolved); + if (fadeInfo) { + return { + type: "fade", + fadeInfo, + }; + } + return null; + } + + const clampChannel = (value, fallback = 0) => { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.max(0, Math.min(255, Math.round(value))); + }; + + const r = clampChannel(resolved[0], 0); + const g = clampChannel(resolved[1], r); + const b = clampChannel(resolved[2], r); + const a = clampChannel(resolved[3], 255); + + return { + type: "solid", + rgba: [r, g, b, a], + }; +} + +function computeFadePositionForPixel(x, y, width, height, fadeInfo) { + if (!fadeInfo || !width || !height) return 0; + + const maxX = Math.max(width - 1, 0); + const maxY = Math.max(height - 1, 0); + const direction = fadeInfo.direction; + + if (typeof direction === "number" && Number.isFinite(direction)) { + return graph.calculateAngleFadePosition( + x, + y, + 0, + 0, + maxX, + maxY, + direction, + ); + } + + const numeric = direction !== undefined ? parseFloat(direction) : NaN; + if (!Number.isNaN(numeric) && Number.isFinite(numeric)) { + return graph.calculateAngleFadePosition( + x, + y, + 0, + 0, + maxX, + maxY, + numeric, + ); + } + + const safeDiv = (value, denom) => (denom <= 0 ? 0 : value / denom); + + switch (direction) { + case "horizontal-reverse": + return safeDiv(maxX - x, maxX); + case "vertical": + return safeDiv(y, maxY); + case "vertical-reverse": + return safeDiv(maxY - y, maxY); + case "diagonal": { + const dx = safeDiv(x, maxX); + const dy = safeDiv(y, maxY); + return (dx + dy) / 2; + } + case "diagonal-reverse": { + const dx = safeDiv(maxX - x, maxX); + const dy = safeDiv(maxY - y, maxY); + return (dx + dy) / 2; + } + case "horizontal": + default: + return safeDiv(x, maxX); + } +} + +function fillExpandedWithSolidPixels(screen, width, height, oldWidth, oldHeight, rgba) { + const [r, g, b] = rgba; + + const applyPixel = (x, y) => { + const i = (y * width + x) * 4; + screen.pixels[i] = r; + screen.pixels[i + 1] = g; + screen.pixels[i + 2] = b; + screen.pixels[i + 3] = 255; + }; + + if (width > oldWidth) { + for (let y = 0; y < height; y++) { + for (let x = oldWidth; x < width; x++) { + applyPixel(x, y); + } + } + } + + if (height > oldHeight) { + const bottomLimit = width > oldWidth ? oldWidth : width; + for (let y = oldHeight; y < height; y++) { + for (let x = 0; x < bottomLimit; x++) { + applyPixel(x, y); + } + } + } +} + +function fillExpandedWithFadePixels(screen, width, height, oldWidth, oldHeight, fadeInfo) { + const applyPixel = (x, y) => { + const t = computeFadePositionForPixel(x, y, width, height, fadeInfo); + const [r = 0, g = 0, b = 0, a = 255] = graph.getLocalFadeColor(t, x, y, fadeInfo); + const i = (y * width + x) * 4; + screen.pixels[i] = Math.max(0, Math.min(255, Math.round(r))); + screen.pixels[i + 1] = Math.max(0, Math.min(255, Math.round(g))); + screen.pixels[i + 2] = Math.max(0, Math.min(255, Math.round(b))); + screen.pixels[i + 3] = Math.max(0, Math.min(255, Math.round(a || 255))); + }; + + if (width > oldWidth) { + for (let y = 0; y < height; y++) { + for (let x = oldWidth; x < width; x++) { + applyPixel(x, y); + } + } + } + + if (height > oldHeight) { + const bottomLimit = width > oldWidth ? oldWidth : width; + for (let y = oldHeight; y < height; y++) { + for (let x = 0; x < bottomLimit; x++) { + applyPixel(x, y); + } + } + } +} + +const projectionMode = location.search.indexOf("nolabel") > -1; // Skip loading noise. +// 🐚 shellhtml mode — the runtime is hosted by an HTML shell (prompt.ac) that +// owns the prompt + return-to-prompt chrome in crisp DOM above the pixel +// buffer. The composited typable prompt and corner label stand down here. +const shellHTMLMode = location.search.indexOf("shellhtml") > -1; + +// Mirror the prompt TextInput out to the shell whenever it changes, so LLM +// replies, command confirmations, and the thinking state render in DOM even +// though the composited prompt never paints. Runs from the prompt system's +// sim in place of prompt_sim. +let shellPromptLast = null; +function shellPromptSync($) { + const input = $.system?.prompt?.input; + if (!input) return; + const thinking = !!$.system.prompt.thinking; + const state = (input.text || "") + "\0" + thinking; + if (state === shellPromptLast) return; + shellPromptLast = state; + send({ + type: "prompt:text:shell", + content: { text: input.text || "", thinking }, + }); +} + +import { setDebug } from "../disks/common/debug.mjs"; +import { customAlphabet } from "../dep/nanoid/nanoid.js"; +import { setPackMode, getPackMode, checkPackMode } from "./pack-mode.mjs"; +// import { update } from "./glaze.mjs"; + +// 🧾 Piece run telemetry → /api/piece-log (collection: piece-runs) +// Captures console output during a piece's lifetime. Each piece load +// gets a fresh pieceId; events are batched and flushed every 2s. +const pieceRuns = (() => { + let current = null; + const startPerf = performance.now(); + const origConsole = { + log: console.log.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + info: console.info.bind(console), + }; + + function serialize(v) { + if (v === undefined) return "undefined"; + if (v === null) return "null"; + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + if (v instanceof Error) return `${v.name}: ${v.message}\n${v.stack || ""}`; + try { return JSON.stringify(v); } catch { return String(v); } + } + + function post(phase, pieceId, body = {}) { + try { + fetch("/api/piece-log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pieceId, phase, ...body }), + keepalive: true, + }).catch(() => {}); + } catch {} + } + + function patch(level) { + console[level] = function (...args) { + origConsole[level](...args); + if (!current) return; + current.events.push({ + level, + at: Date.now(), + elapsed: Math.round(performance.now() - current.startPerf), + message: args.map(serialize).join(" "), + }); + if (current.events.length >= 25) current.flush(); + else if (!current.flushTimer) current.flushTimer = setTimeout(current.flush, 2000); + }; + } + ["log", "warn", "error", "info"].forEach(patch); + + function complete(run, summary = {}) { + if (!run) return; + if (run.flushTimer) { clearTimeout(run.flushTimer); run.flushTimer = null; } + if (run.events.length) { + const events = run.events.splice(0, run.events.length); + post("log", run.pieceId, { data: { events } }); + } + post("complete", run.pieceId, { + data: { duration: Date.now() - run.startedAt, ...summary }, + }); + } + + return { + start({ slug, params, colon, host, user }) { + const prev = current; + const pieceId = + (typeof crypto !== "undefined" && crypto.randomUUID?.()) || + `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const run = { + pieceId, + events: [], + startPerf: performance.now(), + startedAt: Date.now(), + flushTimer: null, + flush: null, + }; + run.flush = () => { + if (run.flushTimer) { clearTimeout(run.flushTimer); run.flushTimer = null; } + if (!run.events.length) return; + const events = run.events.splice(0, run.events.length); + post("log", run.pieceId, { data: { events } }); + }; + current = run; + post("start", pieceId, { + meta: { + slug, + params, + colon, + host, + user, + bootId: typeof self !== "undefined" ? self.acBOOT_ID || null : null, + startedAt: new Date().toISOString(), + userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null, + }, + }); + // Sentinel event so we can tell console-capture works even if the + // piece itself emits nothing. + run.events.push({ + level: "info", + at: Date.now(), + elapsed: 0, + message: `▶ piece-run started: ${slug}`, + }); + run.flushTimer = setTimeout(run.flush, 2000); + // Flush the prior run asynchronously so it doesn't block the new piece. + if (prev) setTimeout(() => complete(prev), 0); + return pieceId; + }, + error(err) { + if (!current) return; + post("error", current.pieceId, { + data: { message: err?.message || String(err), stack: err?.stack || null }, + }); + }, + flush() { current?.flush?.(); }, + get pieceId() { return current?.pieceId || null; }, + }; +})(); + +const alphabet = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const nanoid = customAlphabet(alphabet, 4); + +const defaults = { + boot: ({ cursor, screen: { width, height }, resolution, api }) => { + if (location.host.indexOf("botce") > -1) resolution(width, height, 0); + if (platform.AestheticExtension) resolution(width, height, 0); + cursor("native"); + }, // aka Setup + sim: () => false, // A framerate independent of rendering. + paint: ({ noise16Aesthetic, noise16Sotce, slug, wipe, ink, write, screen, net }) => { + // In PACK mode (exported bundles), skip noise16 — just show black + if (typeof window !== "undefined" && window.acPACK_MODE) { wipe("black"); return; } + // TODO: Make this a boot choice via the index.html file? + if (!projectionMode) { + if (slug?.indexOf("wipppps") > -1) { + wipe("black"); + } else if (slug?.indexOf("botce") > -1) { + noise16Sotce(); + } else { + noise16Aesthetic(); + if (net.motd) { + ink(255, 255, 255, 200).write( + net.motd, + { center: "x", y: Math.floor(screen.height / 2) }, + undefined, + screen.width - 18, + ); + } + if (net.loadFailureText) { + ink("white").write( + net.loadFailureText, + { x: 6, y: 6 }, + [64, 64], + screen.width - 6, + ); + } + } + } + }, + beat: () => false, // Runs every bpm. + act: () => false, // All user interaction. + leave: () => false, // Before unload. + receive: () => false, // Handle messages from BIOS (file drops, etc.) + preview: ({ wipe, slug }) => { + wipe(64).ink(255).write(slug, { center: "xy", size: 1 }); + }, + icon: ({ glaze, wipe, screen }) => { + glaze({ on: false }); + // console.log(screen.width, screen.height); + wipe(70, 50, 100) + .ink(200, 30, 100) + .box(screen.width / 2, screen.height / 2, 48, 72, "*center"); + }, +}; + +let loadAfterPreamble = null; +let hotSwap = null; + +// 🔎 NoPaint +// Inheritable via `export const system = "nopaint"` from any piece. +// Boilerplate for a distributed raster editor. +const nopaint = { + leave: function leave($) { + const { store, system, page, screen, flatten } = $; + if (NPnoOnLeave === false) { + // ^ This is set when reloading a brush without storing changes. (`N` key) + + if (system.nopaint.bakeOnLeave) { + // Add bake commands. + page(system.painting); + //$activePaintApi = $; // In case of recursive paint functions like `write`. + bake($); + // page(screen); // TODO: This should work but it doesnt... + // Seems to have something to do with + // the $activePaintAPI being set + // and it's specific to the `write` + // implementation. + flatten(); // Force-run the above painting commands. + } + // Bake any changes into the existing painting using the screen buffer. + // Note... this may not include the full API that `paint` has... + // Which could lead to inconsistencies in `bake` calls. 23.05.04.13.01 + + // const p = system.nopaint.translation; // Offset by the pan position. + // page(system.painting).paste(screen, -p.x, -p.y); // TODO: Why the + 1 offset here... + // painting.paint(); // TODO: Why is this here? + // page(screen); + //} + + addUndoPainting(system.painting, $.slug); + + // Idea: Check to see if anything actually got painted by doing a diff on + // the pixels? + + store["painting"] = { + width: system.painting.width, + height: system.painting.height, + pixels: system.painting.pixels, + }; //system.painting; // Remember the painting data. + + // console.log("System painting:", system.painting); + + store.persist("painting", "local:db"); + + system.nopaint.syncWip($).catch((error) => console.warn("Painting WIP:", error.message)); + + // 🎨 Broadcast painting update to other tabs + $commonApi.broadcastPaintingUpdate("updated", { + source: "leave", + slug: $.slug + }); + + // And its transform. + store["painting:transform"] = { + translation: system.nopaint.translation, + zoom: system.nopaint.zoomLevel, + }; + store.persist("painting:transform", "local:db"); + } else { + // Restore a painting if `no`ing. + const paintings = system.nopaint.undo.paintings; + page(system.painting) + .paste(paintings[paintings.length - 1]) + .page(screen); + } + + NPnoOnLeave = false; + }, + // 🥞 Bake (to the painting) + bake: function bake({ paste, system }) { + paste(system.nopaint.buffer); + }, +}; + +const undoPaintings = []; // Stores the last two paintings. +let undoPosition = 0; + +function addUndoPainting(painting, step = "unspecified") { + if (!painting) return; // If there is no painting present, silently pass. + const op = painting.pixels; + const pixels = new Uint8ClampedArray(op.length); + pixels.set(op); + + if (undoPaintings.length > undoPosition + 1) { + undoPaintings.length = undoPosition + 1; + } + + if (undoPaintings.length > 0) { + const lastPainting = undoPaintings[undoPaintings.length - 1]; + + // Check for equality in the two states. + // TODO: How long does this take? + const eq = + painting.width === lastPainting.width && + painting.height === lastPainting.height && + pixels.every((value, index) => value === lastPainting.pixels[index]); + + if (eq) { + // console.log("💩 The undo stack was not changed:", undoPaintings.length); + return; + } + } + + undoPaintings.push({ + pixels, + width: painting.width, + height: painting.height, + }); + + if ($commonApi.system.nopaint.recording) { + $commonApi.system.nopaint.addToRecord({ + label: step, + painting: { + pixels, + width: painting.width, + height: painting.height, + }, + }); + } + + undoPosition = undoPaintings.length - 1; + if ($commonApi.system.nopaint.wipSync) { + clearTimeout($commonApi.system.nopaint.wipTimer); + const sync = $commonApi.system.nopaint.wipSync; + $commonApi.system.nopaint.wipTimer = setTimeout(() => { + if ($commonApi.system.nopaint.wipSync === sync) { + $commonApi.system.nopaint.syncWip().catch((error) => console.warn("Painting WIP:", error.message)); + } + }, 120); + } + + // Note: This could be extended to increase the size of the + // undo stack, and images could be diffed? 23.01.31.01.30 + const maxUndoSteps = 32; + if (undoPaintings.length > maxUndoSteps) undoPaintings.shift(); + + if (debug && logs.painting) + console.log("💩 Added undo painting...", undoPaintings.length); +} + +let system = null; // Used to add built-in templated behaviors like `nopaint`. + +// 🎞️ Piece-level FPS control (timing-based, doesn't affect global render loop) +let pieceFPS = null; // Target FPS for the current piece (null = no limit) +let lastPaintTime = 0; // Last time paint() was executed +let lastPaintOut = undefined; // Store last paintOut to preserve correct noPaint behavior +let shouldSkipPaint = false; // Whether to skip this frame's paint call +let pieceFrameCount = 0; // Frame counter that only increments when piece actually paints + +// 🎬 Piece Transition System +// Two transition types available: "drip" (default, fast) and "bubble-wrap" (organic, slower) +// Set to "none" to disable transitions entirely (fast cut) +const TRANSITION_TYPE = "none"; // "none", "drip" or "bubble-wrap" + +// 🧬 Piece Transition State: overlay pixels → fades to reveal piece underneath +// Captures the OUTGOING piece's pixels when transitioning between pieces +let pieceTransition = { + active: false, + phase: "loading", // "loading" (slow, reach ~50%) or "revealing" (fast, finish) + overlayPixels: null, // The captured frame that overlays on top (from outgoing piece) + generation: 0, + maxGenerations: 60, // Total frames for full transition + width: 0, + height: 0, + // Drip-specific state (per-column y-offset) - BLINDS STYLE + dripOffsets: null, // Int16Array of column y-offsets (positive = distance moved) + dripSpeeds: null, // Float32Array of per-column speeds + dripDirections: null, // Int8Array: 1 = down, -1 = up (alternating blinds) + targetOffset: 0, // Target offset for loading phase (~50% of height) + // Bubble-wrap-specific state + blockData: null, // Per-block data for bubble-wrap + blockSize: 8, + blocksX: 0, + blocksY: 0, +}; + +// 🧬 Legacy alias for backwards compatibility +const golTransition = pieceTransition; + +// 🧬 Scale overlay pixels to match new resolution (nearest-neighbor) +function scaleOverlayPixels(srcPixels, srcWidth, srcHeight, dstWidth, dstHeight) { + const dstPixels = new Uint8ClampedArray(dstWidth * dstHeight * 4); + const xRatio = srcWidth / dstWidth; + const yRatio = srcHeight / dstHeight; + + for (let y = 0; y < dstHeight; y++) { + for (let x = 0; x < dstWidth; x++) { + const srcX = Math.floor(x * xRatio); + const srcY = Math.floor(y * yRatio); + const srcIdx = (srcY * srcWidth + srcX) * 4; + const dstIdx = (y * dstWidth + x) * 4; + + dstPixels[dstIdx] = srcPixels[srcIdx]; + dstPixels[dstIdx + 1] = srcPixels[srcIdx + 1]; + dstPixels[dstIdx + 2] = srcPixels[srcIdx + 2]; + dstPixels[dstIdx + 3] = srcPixels[srcIdx + 3]; + } + } + + return dstPixels; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 🪟 BLINDS TRANSITION - Alternating up/down strips (FAST & PERFORMANT) +// ═══════════════════════════════════════════════════════════════════════════ + +// Initialize blinds transition - alternating columns slide up/down +function initDripTransition(width, height) { + pieceTransition.dripOffsets = new Int16Array(width); + pieceTransition.dripSpeeds = new Float32Array(width); + pieceTransition.dripDirections = new Int8Array(width); + pieceTransition.maxGenerations = 30; // Faster! + pieceTransition.phase = "loading"; + pieceTransition.targetOffset = Math.floor(height * 0.35); // ~35% during loading (less wait) + + // Initialize per-column state - alternating directions like blinds + for (let x = 0; x < width; x++) { + pieceTransition.dripOffsets[x] = 0; // Start at 0 (overlay fully in place) + // Alternate: even columns slide DOWN, odd columns slide UP + pieceTransition.dripDirections[x] = (x % 2 === 0) ? 1 : -1; + // Faster speed during loading phase + pieceTransition.dripSpeeds[x] = 4 + Math.random() * 3; // 4-7 pixels per frame + } +} + +// Signal that piece is loaded - switch to fast reveal phase +function transitionPieceLoaded() { + if (pieceTransition.active && pieceTransition.phase === "loading" && pieceTransition.dripSpeeds) { + pieceTransition.phase = "revealing"; + // Boost speeds for the reveal phase - FAST! + for (let x = 0; x < pieceTransition.dripSpeeds.length; x++) { + pieceTransition.dripSpeeds[x] = 10 + Math.random() * 8; // 10-18 pixels per frame + } + console.log("🎬 Transition: Piece loaded! Switching to reveal phase"); + } +} + +// Step blinds transition - advance each column's offset +function dripStep() { + pieceTransition.generation++; + const { dripOffsets, dripSpeeds, dripDirections, height, phase, targetOffset } = pieceTransition; + + let allDone = true; + + for (let x = 0; x < dripOffsets.length; x++) { + const currentOffset = dripOffsets[x]; + + if (phase === "loading") { + // Loading phase: slowly move toward target (~45%) + if (currentOffset < targetOffset) { + dripOffsets[x] += dripSpeeds[x]; + // Clamp to target during loading + if (dripOffsets[x] > targetOffset) { + dripOffsets[x] = targetOffset; + } + allDone = false; + } + } else { + // Revealing phase: accelerate to completion + if (currentOffset < height) { + dripOffsets[x] += dripSpeeds[x]; + // Add acceleration (gravity/momentum) + dripSpeeds[x] += 0.3; + allDone = false; + } + } + } + + // During loading phase, never report "done" - wait for reveal + if (phase === "loading") { + return true; // Keep running + } + + return !allDone && pieceTransition.generation < pieceTransition.maxGenerations; +} + +// Apply blinds overlay - alternating columns slide up/down to reveal new frame +function applyDripOverlay(screenPixels) { + const { overlayPixels, dripOffsets, dripDirections, width, height } = pieceTransition; + if (!overlayPixels || !screenPixels || !dripOffsets || !dripDirections) return; + + for (let x = 0; x < width; x++) { + const offset = Math.max(0, dripOffsets[x] | 0); + const direction = dripDirections[x]; + + if (offset >= height) continue; // Column fully slid off + + const visibleRows = height - offset; + + if (direction > 0) { + // SLIDE DOWN - overlay moves down, reveals new frame from TOP + for (let y = 0; y < visibleRows; y++) { + const srcY = y; // Read from top of overlay + const dstY = y + offset; // Write shifted down + const srcIdx = (srcY * width + x) * 4; + const dstIdx = (dstY * width + x) * 4; + + screenPixels[dstIdx] = overlayPixels[srcIdx]; + screenPixels[dstIdx + 1] = overlayPixels[srcIdx + 1]; + screenPixels[dstIdx + 2] = overlayPixels[srcIdx + 2]; + screenPixels[dstIdx + 3] = overlayPixels[srcIdx + 3]; + } + } else { + // SLIDE UP - overlay moves up, reveals new frame from BOTTOM + for (let y = 0; y < visibleRows; y++) { + const srcY = offset + y; // Read from bottom portion of overlay + const dstY = y; // Write shifted up + const srcIdx = (srcY * width + x) * 4; + const dstIdx = (dstY * width + x) * 4; + + screenPixels[dstIdx] = overlayPixels[srcIdx]; + screenPixels[dstIdx + 1] = overlayPixels[srcIdx + 1]; + screenPixels[dstIdx + 2] = overlayPixels[srcIdx + 2]; + screenPixels[dstIdx + 3] = overlayPixels[srcIdx + 3]; + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 🫧 BUBBLE-WRAP TRANSITION - Organic morphing dissolve (DEPRECATED: slower) +// ═══════════════════════════════════════════════════════════════════════════ + +// Initialize bubble-wrap transition - per-pixel dissolve with organic noise +function initBubbleWrapCells(width, height) { + pieceTransition.blockSize = 1; // Per-pixel + pieceTransition.blocksX = width; + pieceTransition.blocksY = height; + pieceTransition.blockData = null; + pieceTransition.maxGenerations = 30; // Slower for organic feel +} + +// Step bubble-wrap transition +function bubbleWrapStep(screenPixels) { + pieceTransition.generation++; + return pieceTransition.generation < pieceTransition.maxGenerations; +} + +// Apply bubble-wrap overlay - chunky biological smoothie morph +function applyBubbleWrapOverlay(screenPixels) { + const { overlayPixels, width, height, generation, maxGenerations } = pieceTransition; + if (!overlayPixels || !screenPixels) return; + + // Progress 0-1 + const progress = generation / maxGenerations; + // Eased for smooth start/end + const eased = progress < 0.5 + ? 2 * progress * progress + : 1 - Math.pow(-2 * progress + 2, 2) / 2; + + // Time offset for animated noise + const t = generation * 0.1; + + // Displacement peaks mid-transition - CHUNKY + const dispPeak = Math.sin(progress * Math.PI); + const maxDisp = dispPeak * 16; + + // Block size for chunky feel + const bs = 4; + + for (let y = 0; y < height; y++) { + const rowOff = y * width; + // Quantize y for chunky blocks + const qy = (y / bs | 0) * bs; + + for (let x = 0; x < width; x++) { + const i = (rowOff + x) * 4; + // Quantize x for chunky blocks + const qx = (x / bs | 0) * bs; + + // Organic noise at block resolution for chunky dissolve + const n1 = Math.sin(qx * 0.05 + t) * Math.cos(qy * 0.04 + t * 0.7); + const n2 = Math.sin(qx * 0.09 + qy * 0.07 - t * 0.6) * 0.5; + const n3 = Math.cos(qx * 0.02 - qy * 0.03 + t * 0.4) * 0.4; + const noise = (n1 + n2 + n3) * 0.5 + 0.5; + + // Per-block blend with organic variation + const threshold = eased * 1.5 - 0.25; + const edge = 0.3; + let blend = (threshold - noise * 0.85) / edge; + blend = blend < 0 ? 0 : blend > 1 ? 1 : blend; + + // SMOOTHIE: chaotic displacement that samples from BOTH frames + const swirl = Math.sin(qx * 0.06 + qy * 0.04 + t * 1.5) * maxDisp; + const churn = Math.cos(qx * 0.05 - qy * 0.07 + t * 1.2) * maxDisp; + + // Sample old frame with swirling displacement + let ox = (x + swirl) | 0; + let oy = (y + churn) | 0; + ox = ox < 0 ? 0 : ox >= width ? width - 1 : ox; + oy = oy < 0 ? 0 : oy >= height ? height - 1 : oy; + const oi = (oy * width + ox) * 4; + + // Sample new frame with OPPOSITE swirl - mixing! + let nx = (x - churn * 0.7) | 0; + let ny = (y + swirl * 0.6) | 0; + nx = nx < 0 ? 0 : nx >= width ? width - 1 : nx; + ny = ny < 0 ? 0 : ny >= height ? height - 1 : ny; + const ni = (ny * width + nx) * 4; + + // Get swirled colors from both + const oldR = overlayPixels[oi]; + const oldG = overlayPixels[oi + 1]; + const oldB = overlayPixels[oi + 2]; + const newR = screenPixels[ni]; + const newG = screenPixels[ni + 1]; + const newB = screenPixels[ni + 2]; + + // Chunky biological blend - smoothie mix! + const inv = 1 - blend; + let r = oldR * inv + newR * blend; + let g = oldG * inv + newG * blend; + let b = oldB * inv + newB * blend; + + // Extra chaos: sometimes swap channels mid-transition + if (dispPeak > 0.5 && ((qx + qy + generation) & 7) === 0) { + const tmp = r; + r = g; + g = b; + b = tmp; + } + + screenPixels[i] = r; + screenPixels[i + 1] = g; + screenPixels[i + 2] = b; + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 🎬 UNIFIED TRANSITION API - Dispatches to drip, bubble-wrap, or none +// ═══════════════════════════════════════════════════════════════════════════ + +// Initialize transition based on current type +function initGOLCells(width, height) { + if (TRANSITION_TYPE === "none") return; // No transition + if (TRANSITION_TYPE === "drip") { + initDripTransition(width, height); + } else { + initBubbleWrapCells(width, height); + } +} + +// Step transition +function golStep(screenPixels) { + if (TRANSITION_TYPE === "none") return false; // Immediately done + if (TRANSITION_TYPE === "drip") { + return dripStep(); + } else { + return bubbleWrapStep(screenPixels); + } +} + +// Apply transition overlay +function applyGOLOverlay(screenPixels) { + if (TRANSITION_TYPE === "drip") { + applyDripOverlay(screenPixels); + } else { + applyBubbleWrapOverlay(screenPixels); + } +} + +let boot = defaults.boot; +let sim = defaults.sim; +let paint = defaults.paint; +let beat = defaults.beat; +let brush, lift, filter; // Only set in the `nopaint` system. +let act = defaults.act; +let leave = defaults.leave; +let receive = defaults.receive; // Handle messages from BIOS +let preview = defaults.preview; +let icon = defaults.icon; +let bake; // Currently only used by the `nopaint` system. + +let leaving = false; // Set to true on first piece. +let leaveLoad; // A callback for loading the next disk after leaving. + +let previewMode = false; // Detects ?preview on a piece and yields its +// preview function if it exists. +let firstPreviewOrIcon = true; +let iconMode = false; // Detects ?icon on a piece and yields its +// icon function if it exists. +let previewOrIconMode; +let hideLabel = false; +// Whether this frame ever asked to be rid of the corner label. `hideLabel` +// is recomputed from the incoming slug on every load, and a live `code` +// reload carries no query at all — so a piece that arrived with `?nolabel` +// grew its label back the moment it was edited, which is the one moment a +// preview card is being watched. Suppression belongs to the view, not to the +// piece: boot already treats `nolabel` that way, preserving it across every +// navigation, and this worker serves exactly one view for its whole life. +let hideLabelForFrame = false; +let hideLabelViaTab = false; // Track if label is hidden via tab key toggle + +// Helper function to toggle HUD visibility (used by Tab key and center tap) +function toggleHUDVisibility(isDoubleTap = false, skipSound = false) { + const currentTime = performance.now(); + + if (hudAnimationState.animating) { + // Animation in progress: reverse direction and continue from current position + const elapsed = currentTime - hudAnimationState.startTime; + const progress = Math.min(elapsed / hudAnimationState.duration, 1.0); + + // Flip the target state + hudAnimationState.visible = !hudAnimationState.visible; + + // Update the remembered state for when QR fullscreen is turned off + if (hudAnimationState.qrFullscreen) { + hudAnimationState.cornersVisibleBeforeFullscreen = hudAnimationState.visible; + } + + // Restart animation from current position by adjusting the start time + // If we were 30% through a hide animation, start the show animation at 70% progress + const remainingProgress = 1.0 - progress; + hudAnimationState.startTime = currentTime - (remainingProgress * hudAnimationState.duration); + + // Special double-tap: immediately show HUD if hiding + if (isDoubleTap && !hudAnimationState.visible) { + hudAnimationState.animating = false; + hudAnimationState.visible = true; + hudAnimationState.opacity = 1.0; + hudAnimationState.slideOffset = { x: 0, y: 0 }; + hudAnimationState.qrSlideOffset = { x: 0, y: 0 }; + + // Update remembered state + if (hudAnimationState.qrFullscreen) { + hudAnimationState.cornersVisibleBeforeFullscreen = true; + } + } + + if (!skipSound) { + $commonApi.sound.synth({ + type: "sine", + tone: hudAnimationState.visible ? 600 : 400, + duration: 0.15, + attack: 0.1, + decay: 0.8, + volume: 0.2, + }); + } + } else { + // No animation in progress: start new animation + hudAnimationState.animating = true; + hudAnimationState.startTime = currentTime; + hudAnimationState.visible = !hudAnimationState.visible; + + // Update the remembered state for when QR fullscreen is turned off + if (hudAnimationState.qrFullscreen) { + hudAnimationState.cornersVisibleBeforeFullscreen = hudAnimationState.visible; + } + + if (!skipSound) { + $commonApi.sound.synth({ + type: "sine", + tone: hudAnimationState.visible ? 600 : 400, + duration: 0.15, + attack: 0.1, + decay: 0.8, + volume: 0.2, + }); + } + } +} + +// Helper function to toggle QR fullscreen mode for KidLisp pieces +function toggleQRFullscreen() { + try { + // Only allow QR fullscreen for KidLisp pieces that have QR codes + const sourceCode = currentText || currentHUDTxt; + console.log("🔍 [QR Toggle] sourceCode:", sourceCode, "currentPath:", currentPath); + + // Use the centralized KidLisp detection from kidlisp.mjs + const isInlineKidlispPiece = (currentPath && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || + currentPath === "(...)" || + (sourceCode && sourceCode.startsWith("$")) || + (currentPath && currentPath.includes("/disks/$")) || + (sourceCode && lisp.isKidlispSource(sourceCode)); + + console.log("🔍 [QR Toggle] isInlineKidlispPiece:", isInlineKidlispPiece); + + if (!isInlineKidlispPiece) { + console.log("⚠️ [QR Toggle] Not a KidLisp piece, skipping QR fullscreen toggle"); + return; + } + + if (!hudAnimationState.qrFullscreen) { + // Turning ON fullscreen QR + console.log("✅ [QR Toggle] Enabling fullscreen mode"); + + // Remember current corner visibility state + hudAnimationState.cornersVisibleBeforeFullscreen = hudAnimationState.visible; + + // If corners are currently visible, animate them out for fullscreen QR + if (hudAnimationState.visible) { + // Check if animation is already in progress + if (hudAnimationState.animating) { + // Animation in progress: reverse it + const currentTime = performance.now(); + const elapsed = currentTime - hudAnimationState.startTime; + const remaining = Math.max(0, hudAnimationState.duration - elapsed); + hudAnimationState.startTime = currentTime - remaining; + } else { + // No animation in progress: start new animation + hudAnimationState.animating = true; + hudAnimationState.startTime = performance.now(); + } + hudAnimationState.visible = false; + } + + hudAnimationState.qrFullscreen = true; + + // Remove corner QR hitbox when going fullscreen + send({ type: "button:hitbox:remove", content: "qr-corner" }); + + } else { + // Turning OFF fullscreen QR + console.log("✅ [QR Toggle] Disabling fullscreen mode"); + hudAnimationState.qrFullscreen = false; + + // Remove fullscreen QR hitbox + send({ type: "button:hitbox:remove", content: "qr-fullscreen" }); + + // Restore corner visibility to what it was before fullscreen QR was activated + if (hudAnimationState.cornersVisibleBeforeFullscreen && !hudAnimationState.visible) { + // Check if animation is already in progress + if (hudAnimationState.animating) { + // Animation in progress: reverse it + const currentTime = performance.now(); + const elapsed = currentTime - hudAnimationState.startTime; + const remaining = Math.max(0, hudAnimationState.duration - elapsed); + hudAnimationState.startTime = currentTime - remaining; + } else { + // No animation in progress: start new animation + hudAnimationState.animating = true; + hudAnimationState.startTime = performance.now(); + } + hudAnimationState.visible = true; + } + } + + // Play sound feedback + if ($commonApi?.sound?.synth) { + $commonApi.sound.synth({ + type: "sine", + tone: hudAnimationState.qrFullscreen ? 500 : 250, + duration: 0.12, + attack: 0.1, + decay: 0.9, + volume: 0.25, + }); + } + } catch (err) { + console.error("❌ [QR Toggle] Error toggling QR fullscreen:", err); + } +} + +// In PACK mode, show HUD for non-KidLisp pieces (JS bundles) but hide for KidLisp +const _packModeHudVisible = getPackMode() + ? (typeof window !== 'undefined' && window.acPACK_COLOPHON?.piece?.isKidLisp === false) + : true; +let hudAnimationState = { + visible: _packModeHudVisible, + animating: false, + startTime: 0, + duration: 500, // 500ms animation + opacity: 1.0, + slideOffset: { x: 0, y: 0 }, // HUD label offset (slides to top-left) + qrSlideOffset: { x: 0, y: 0 }, // QR overlay offset (slides to bottom-right) + labelWidth: 120, // HUD label width - updated when HUD is drawn + labelHeight: 40, // HUD label height - updated when HUD is drawn + qrSize: 80, // QR overlay size for bounding box animations + lastTabTime: 0, // Track last tab press for double-tap detection + qrFullscreen: false, // Track if QR code is in fullscreen mode + cornersVisibleBeforeFullscreen: true // Remember corner state before fullscreen QR +}; + +// Clock melody cached code for QR display (received from clock.mjs) +let cachedClockCode = null; + +let module, loadedModule; // Currently loaded piece module code with an extra reference for `hotSwap`. + +// 🎨 Enhanced painting change detection with immediate pixel monitoring +let lastPaintingHash = null; +let paintingChangeCheckInterval = null; +let frameBasedMonitoring = false; + +// Performance optimization: Use requestAnimationFrame for more efficient monitoring +function enableFrameBasedMonitoring() { + if (frameBasedMonitoring) return; + frameBasedMonitoring = true; + + let lastDimensions = null; + + function checkPaintingChanges() { + if ($commonApi.system?.painting && !$commonApi._processingBroadcast) { + const currentHash = generatePaintingHash($commonApi.system.painting); + const currentDimensions = { + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height + }; + + // Add detailed logging for nopaint interference detection + const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); + const nopaintBuffer = $commonApi.system?.nopaint?.buffer; + + if (lastPaintingHash !== null && currentHash !== lastPaintingHash) { + // Check if this is a dimension change (resize operation) + const isDimensionChange = lastDimensions && + (lastDimensions.width !== currentDimensions.width || + lastDimensions.height !== currentDimensions.height); + + // Skip broadcasting during active nopaint operations to prevent interference + if (isNopaintActive) { + console.log(`🚫 SKIPPING broadcast during nopaint operation to prevent live preview interference`); + } else { + // Immediate broadcast with appropriate source + $commonApi.broadcastPaintingUpdateImmediate("frame_update", { + source: isDimensionChange ? "resize" : "frame_monitor", + hash: currentHash.substr(0,8) + }); + } + } + + lastPaintingHash = currentHash; + lastDimensions = currentDimensions; + } + + if (frameBasedMonitoring) { + requestAnimationFrame(checkPaintingChanges); + } + } + + requestAnimationFrame(checkPaintingChanges); +} + +// 🎨 Broadcast throttling to prevent infinite loops +let lastBroadcastTime = 0; +let broadcastThrottleDelay = 100; // Minimum ms between broadcasts +let pieceMetadata = null; // Metadata for current piece (trustLevel, etc.) +let currentPath, + currentHost, + currentSearch, + currentColon, + currentParams, + currentHash, + currentText, + currentCode, + currentHUDTxt, + currentHUDPlainTxt, // Plain text version without color codes + currentOriginalCodeId, // Track original $code identifier for sharing + currentHUDTextColor, + currentHUDStatusColor = "red", + currentHUDButton, + currentHUDButtonActive = false, // Global flag to block other button interactions when HUD is active + currentHUDButtonDirectTouch = false, // Track if HUD button was directly tapped (not rolled over) + currentHUDHovered = false, // Pointer is over the corner label (passive, no press) + currentHUDHoverAmount = 0, // 0..1 — how far the hover drift has eased in + currentHUDHoverClock = 0, // performance.now() of the last ease step + currentHUDScrub = 0, + currentHUDScrubReadyState = null, // null | "share" | "edit" + currentHUDLabelFontName, + currentHUDLabelBlockWidth = tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH, + currentHUDLabelBlockHeight = tf?.blockHeight ?? DEFAULT_TYPEFACE_BLOCK_HEIGHT, + currentHUDShareWidth = (tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH) * "share ".length, + currentHUDEditWidth = tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH, + currentHUDLabelMeasuredWidth = 0, + currentHUDTextHeight = 0, + currentHUDTextBoxWidth = 0, + currentHUDTextBoxHeight = 0, + currentHUDLeftPad = 0, + currentHUDOffset, + currentHUDQR = null, // QR code URL to show to the left of label (set via hud.qr()) + currentHUDQRCells = null, // Cached QR code cells + currentHUDAuthor = null, // @handle of the author for KidLisp pieces + currentHUDHits = null, // Hit count for KidLisp pieces + currentHUDSuperscript = null, // Custom superscript text (e.g. ".com") shown after label in cyan + forceTinyHudLabel = false, // Force MatrixChunky8 font for HUD label (set via hud.tinyLabel()) + qrOverlayCache = new Map(), // Cache for QR overlays to prevent regeneration every frame + hudLabelCache = null; // Cache for HUD label to prevent regeneration every frame + +// 📦 Global update state — polled once per worker lifetime so a fresh build is +// visible from any piece (not just `prompt`, which used to own polling and +// aborted it on leave). Exposed via `$commonApi.system.update`. +let globalUpdateReady = false; +let globalVersionInfo = null; // { deployed, latest, status, behindBy, recentCommits } +let globalRecentCommits = []; +let updatePollStarted = false; +let updatePollController = null; +let updateBadgeBoxScreen = null; // {x, y, w, h} of the rendered badge for hitbox bookkeeping +let updateBadgeHitboxRegistered = false; +let globalAutoReload = false; +// Whether this frame wants updates taken rather than offered. Same shape, +// and same reason, as `hideLabelForFrame`: it describes the view, it arrives +// once in the URL, and a live reload carries no query to restate it with. +let autoUpdateForFrame = false; + +// 📱 LAN Dev mode identity (set by session server in dev mode) +let devIdentity = null; // { name, host, hostIp, mode, connectionId } + +// 📡 Remote log forwarding for LAN Dev mode +let remoteLogQueue = []; +let remoteLogSocket = null; +const MAX_REMOTE_LOG_QUEUE = 50; + +// 🩺 What this piece looks like from in here, for whoever is authoring it. +// Silent until `channel()` is given a code channel — see `diagnostics.mjs`. +const diagnostics = new Diagnostics({ + send: (type, content) => socket?.send(type, content), +}); +// The worker has no `window`, so this is the only listener that ever sees an +// uncaught error thrown by a piece. +diagnostics.watch(globalThis); + +function setupRemoteLogging() { + const originalConsole = { + log: console.log.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + function sendRemoteLog(level, args) { + // 🩺 The author's copy. Independent of LAN dev mode, which is what makes + // this work in production at all. + diagnostics.note(level, serializeArgs(args)); + if (!devIdentity || !remoteLogSocket?.connected) { + // Queue logs until connected + if (remoteLogQueue.length < MAX_REMOTE_LOG_QUEUE) { + remoteLogQueue.push({ level, args: serializeArgs(args), time: Date.now() }); + } + return; + } + + try { + const logData = { + level, + args: serializeArgs(args), + deviceName: devIdentity.name, + connectionId: devIdentity.connectionId, + time: Date.now(), + }; + remoteLogSocket.send("dev:log", logData); + } catch (e) { + // Silently fail to avoid infinite loop + } + } + + function serializeArgs(args) { + return args.map(arg => { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (typeof arg === 'string') return arg; + if (typeof arg === 'number' || typeof arg === 'boolean') return String(arg); + if (arg instanceof Error) return `${arg.name}: ${arg.message}\n${arg.stack}`; + try { + return JSON.stringify(arg, null, 2); + } catch (e) { + return String(arg); + } + }); + } + + // Intercept console methods + console.log = (...args) => { + originalConsole.log(...args); + sendRemoteLog('log', args); + }; + + console.warn = (...args) => { + originalConsole.warn(...args); + sendRemoteLog('warn', args); + }; + + console.error = (...args) => { + originalConsole.error(...args); + sendRemoteLog('error', args); + }; + + // Capture unhandled errors (only in main thread with window) + if (typeof window !== 'undefined') { + window.addEventListener('error', (event) => { + sendRemoteLog('error', [`Uncaught: ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`]); + }); + + window.addEventListener('unhandledrejection', (event) => { + sendRemoteLog('error', [`Unhandled Promise: ${event.reason}`]); + }); + } +} + +function flushRemoteLogQueue() { + if (!devIdentity || !remoteLogSocket?.connected) return; + + while (remoteLogQueue.length > 0) { + const log = remoteLogQueue.shift(); + try { + remoteLogSocket.send("dev:log", { + level: log.level, + args: log.args, + deviceName: devIdentity.name, + connectionId: devIdentity.connectionId, + time: log.time, + queued: true, + }); + } catch (e) { + break; + } + } +} + +let lastMatrixChunkyHighlightLog = null; +let lastMatrixChunkyWriteDiagnosticLog = null; +let lastMatrixChunkyPixelLog = null; + + + +// Helper functions to safely access window flags in both main thread and worker contexts +function isQROverlayCacheDisabled() { + try { + return typeof window !== 'undefined' && window.acDISABLE_QR_OVERLAY_CACHE; + } catch (e) { + return false; // Default to enabled (caching on) + } +} + +function isHUDLabelCacheDisabled() { + try { + return typeof window !== 'undefined' && window.acDISABLE_HUD_LABEL_CACHE; + } catch (e) { + return false; // Default to enabled if we can't check + } +} + +// Make cache globally accessible for character loading system +if (typeof window !== 'undefined') { + window.qrOverlayCache = qrOverlayCache; + + // Clear caches if they are disabled from BIOS + if (isQROverlayCacheDisabled()) { + qrOverlayCache.clear(); + // console.log("🚫 QR overlay cache disabled and cleared from BIOS"); + } + + // if (isHUDLabelCacheDisabled()) { + // console.log("🚫 HUD label cache disabled from BIOS"); + // } +} +//currentPromptButton; + +// Color codes are scanned by lib/color-codes.mjs, which knows that `\\` is an +// escaped backslash — the producers escape text they didn't author, so a user +// who types `\red\` gets those characters instead of red text. + +// Utility function to strip color codes from text +function stripColorCodes(str) { + return stripCodes(str); +} + +function textContainsColorCodes(str) { + return hasColorCodes(str); +} + +function hasKidLispMarkers(text) { + if (!text) return false; + return ( + text.includes("ink ") || + text.includes("line ") || + text.includes("box ") || + text.includes("circle ") || + text.includes("spin ") || + text.includes("(") || + textContainsColorCodes(text) || + /\d+s\.\.\./.test(text) || + /\?\s/.test(text) + ); +} + +function detectKidLispPiece({ currentPath, currentHUDTxt, currentText, cleanText }) { + const sourceCode = currentText || currentHUDTxt; + + return ( + (currentPath && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || + currentPath === "(...)" || + (sourceCode && sourceCode.startsWith("$")) || + (currentPath && (currentPath.includes("/disks/$") || currentPath.includes("$"))) || + (sourceCode && lisp.isKidlispSource && lisp.isKidlispSource(sourceCode)) || + hasKidLispMarkers(currentHUDTxt) || + textContainsColorCodes(cleanText) || + hasKidLispMarkers(sourceCode) + ); +} + +// Detect embedded/inline KidLisp specifically (kidlisp.com editor, PJ KidLisp) +// Does NOT include .lisp file pieces - only inline/embedded code +function detectEmbeddedKidLisp({ currentPath, currentHUDTxt, currentText }) { + const sourceCode = currentText || currentHUDTxt; + return ( + currentPath === "(...)" || + (sourceCode && sourceCode.startsWith("$")) || + (currentPath && currentPath.includes("/disks/$")) || + (currentPath && currentPath.includes("$") && !currentPath.endsWith('.lisp')) + ); +} + +function updateHUDStatus() { + if (udp.connected && socket?.connected) { + currentHUDStatusColor = "lime"; + } else if (udp.connected || socket?.connected) { + currentHUDStatusColor = "orange"; + } else { + currentHUDStatusColor = "red"; + } +} + +let loading = false; +let loadingStartTime = null; // Track when loading started for timeout safety +const LOADING_TIMEOUT_MS = 10000; // 10 second max loading time + +// Safety check for stuck loading state +function checkLoadingTimeout() { + if (loading && loadingStartTime && (Date.now() - loadingStartTime > LOADING_TIMEOUT_MS)) { + console.warn("🔴 Loading timeout exceeded - forcing loading reset"); + loading = false; + loadingStartTime = null; + return true; // Was stuck + } + return false; +} + +let reframe; +let pendingPieceReload = null; // Queue for piece-reload messages before $commonApi.reload is ready + +const sfxProgressReceivers = {}, + sfxSampleReceivers = {}, + sfxKillReceivers = {}, + sfxDurationReceivers = {}; +const sfxDurations = {}; // Lengths BIOS reported when each sound finished decoding. +let $sampleCount = 0n; + +const signals = []; // Easy messages from embedded DOM content. +const actAlerts = []; // Messages that get put into act and cleared after +// every frame. +let reframed = false; +let formReframing = false; // Just for 3D camera updates. + +let paintings = {}; // Cached bitmaps from a piece. + +// Expose pending image count for oven/grabber to poll (available immediately) +if (typeof window !== "undefined") { + window.acPendingImages = () => + Object.values(paintings).filter((v) => v === "fetching").length; +} + +// 🖼️ Image cache system for paste/stamp - persists across refreshes +// Similar to $kidlisp code cache but for image data +const imageCache = { + // In-memory cache for fast access (RAM) + memory: new Map(), // url -> ImageBitmap + + // IndexedDB persistence reference (set during boot) + store: null, + + // Initialize with store reference for persistent caching + init(storeRef) { + this.store = storeRef; + }, + + // Get from memory cache + get(url) { + return this.memory.get(url); + }, + + // Check if image is cached in memory + has(url) { + return this.memory.has(url); + }, + + // Save to both memory and persistent storage + async set(url, imageBitmap) { + // Save to memory + this.memory.set(url, imageBitmap); + + // Save to IndexedDB for persistence across refreshes + if (this.store && imageBitmap) { + try { + let pixelData, imgWidth, imgHeight; + + if (imageBitmap.pixels) { + // Already a pixel buffer { width, height, pixels } + imgWidth = imageBitmap.width; + imgHeight = imageBitmap.height; + pixelData = imageBitmap.pixels; + } else { + // ImageBitmap — convert to pixel data via canvas drawImage + const canvas = typeof OffscreenCanvas !== 'undefined' + ? new OffscreenCanvas(imageBitmap.width, imageBitmap.height) + : document.createElement('canvas'); + canvas.width = imageBitmap.width; + canvas.height = imageBitmap.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(imageBitmap, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + imgWidth = imageData.width; + imgHeight = imageData.height; + pixelData = imageData.data; + } + + // Store the pixel data along with metadata + this.store[`image-cache:${url}`] = { + width: imgWidth, + height: imgHeight, + data: Array.from(pixelData), + cached: Date.now(), + version: 1 + }; + this.store.persist(`image-cache:${url}`, "local:db"); + } catch (error) { + console.warn(`Failed to save image to persistent cache: ${url}`, error); + } + } + }, + + // Load from persistent storage (IndexedDB) + async loadFromPersistent(url) { + if (!this.store) return null; + + try { + const data = await this.store.retrieve(`image-cache:${url}`, "local:db"); + if (data && data.data && data.width && data.height) { + // Reconstruct ImageData from stored array + const imageData = new ImageData( + new Uint8ClampedArray(data.data), + data.width, + data.height + ); + + // Convert ImageData back to ImageBitmap + const imageBitmap = await createImageBitmap(imageData); + + // Cache in memory for future access + this.memory.set(url, imageBitmap); + + return imageBitmap; + } + } catch (error) { + console.warn(`Failed to load image from persistent cache: ${url}`, error); + } + return null; + }, + + // Clear all caches + clear() { + this.memory.clear(); + }, + + // Check if URL is from aesthetic.computer media backend (safe to cache) + isCacheable(url) { + if (!url) return false; + const urlStr = url.toString().toLowerCase(); + return ( + urlStr.includes('aesthetic.computer') || + urlStr.includes('art.aesthetic.computer') || + urlStr.includes('/media/@') || // User paintings + urlStr.match(/\/@[\w-]+\/\d+/) // @handle/timestamp format + ); + } +}; + +let screen; +let currentDisplay; // TODO: Remove this? 22.09.29.11.38 + +// 🖼️ HD layer — an OffscreenCanvas 2D surface at native device resolution, +// composited by bios above the pixel canvas (below glaze / corner labels / +// ui). Pieces acquire it with `hd()` inside `paint`; disk owns the buffer: +// it (re)allocates on resize, pre-scales the context so logical screen +// coordinates stay valid, ships an ImageBitmap to bios after each painted +// frame, and clears the bios layer when the piece changes. The HD layer is +// NOT captured in tapes — pieces should keep painting the pixel buffer +// underneath as the recorded / fallback view. +let hdCanvas = null; // OffscreenCanvas backing store. +let hdContext = null; // Its 2d context, pre-scaled to device resolution. +let hdPainted = false; // A piece drew this frame → send a bitmap in `render`. +let hdVisible = false; // bios's layer is currently showing one of our bitmaps. +let hdPixelRatio = 1; // devicePixelRatio from init-from-bios (reframed updates it). +const HD_MAX_PIXELS = 4096 * 2304; // Backing-store ceiling (≈4K) for perf. + +function hd() { + if (!screen || typeof OffscreenCanvas === "undefined") return null; + // Previews/icons capture the PIXEL buffer, so hand that mode back to the + // piece's low-res path by refusing the layer — an hd piece treats a null + // return as "paint the pixel buffer this frame instead". Tapes stay hd: + // bios live-encodes the hd bitmaps into the recording (see bios hdTape*). + if (PREVIEW_OR_ICON) return null; + const sub = currentDisplay?.subdivisions || 2; + const dpr = currentDisplay?.pixelRatio || hdPixelRatio; + // Native device pixels per logical screen pixel, capped so giant displays + // never allocate an unbounded backing store. + const maxScale = Math.sqrt(HD_MAX_PIXELS / (screen.width * screen.height)); + const scale = Math.max(1, Math.min(sub * dpr, maxScale)); + const w = Math.round(screen.width * scale); + const h = Math.round(screen.height * scale); + if (!hdCanvas || hdCanvas.width !== w || hdCanvas.height !== h) { + hdCanvas = new OffscreenCanvas(w, h); + hdContext = hdCanvas.getContext("2d"); + } + hdContext.setTransform(scale, 0, 0, scale, 0, 0); + hdPainted = true; + return { ctx: hdContext, width: screen.width, height: screen.height, scale }; +} + +// Tear down between pieces: drop the buffer and hide bios's layer right away +// (out-of-band, so the old piece's last frame never floats over the next). +function hdUnload() { + if (!hdCanvas) return; + hdCanvas = hdContext = null; + hdPainted = false; + hdVisible = false; + send({ type: "hd:clear" }); +} + +let cursorCode; +let pieceHistoryIndex = -1; // Gets incremented to 0 when first piece loads. +let paintCount = 0n; +let simCount = 0n; +let booted = false; +// let initialSim = true; +let noPaint = false; +let labelBack = false; +let hiccupTimeout; // Prevent multiple hiccups from being triggered at once. + +// 🌐 Check if running on a branded .com domain (e.g. notepat.com) and redirect to aesthetic.computer +function redirectIfBrandedDomain() { + const { hostname } = getSafeUrlParts(); + if (!hostname) return false; + // Match notepat.com or www.notepat.com (add more branded domains here as needed) + if (hostname === "notepat.com" || hostname === "www.notepat.com") { + send({ type: "web", content: { url: "https://aesthetic.computer", blank: false } }); + return true; + } + if (hostname === "laklok.com" || hostname === "www.laklok.com") { + send({ type: "web", content: { url: "https://aesthetic.computer", blank: false } }); + return true; + } + return false; +} + +let storeRetrievalResolutions = {}, + storeDeletionResolutions = {}; + +// USB flash support (Electron only) +let usbDeviceListResolution = null; +let usbFlashResolution = null; + +// There are two instances of Socket that run in parallel... +let socket, socketStartDelay; // Socket server for each piece. + +// ❤️‍🔥 TODO: Explose these somehow to the $commonApi. + +// TODO: Extract `chat` into an external class. + +const chatDebug = + location.host === "local.aesthetic.computer" || + location.host === "localhost:8888" || + location.host === "aesthetic.local:8888"; +const chatClient = new Chat(chatDebug, send); + +let udp = { + send: (type, content) => { + send({ type: "udp:send", content: { type, content } }); + }, + receive: ({ type, content }) => { + // console.log("🩰 Received `piece` message from UDP:", type, content); + + // 🧚 Ambient cursor (fairies) support. Disabled for KidLisp pieces. + if (type === "fairy:point" /*&& socket?.id !== id*/ && visible) { + const isKidlisp = detectKidLispPiece({ currentPath, currentHUDTxt, currentText }) || + (currentPath && currentPath.endsWith('.lisp')); + if (!isKidlisp) { + fairies.push({ x: content.x, y: content.y }); + } + return; + } + + udpReceive?.(type, content); + }, + kill: (outageSeconds) => { + udp.connected = false; + send({ type: "udp:disconnect", content: { outageSeconds } }); + }, + connected: false, + }, + udpReceive = undefined; + +let scream = null; // 😱 Allow priviledged users to send alerts to everyone. +// (A great end<->end socket + redis test.) +let screaming = false; +let screamingTimer; // Keep track of scream duration. + +const fairies = []; // Render cursor points of other active users, +// dumped each frame. + +let glazeEnabled = false; // Keep track of whether glaze is on or off. + +// *** Dark Mode *** +// (By @tarighian) +// Pass `true` or `false` to override or `default` to the system setting. +function darkMode(enabled) { + if (enabled === "default") { + darkMode($commonApi.dark || false); + store.delete("dark-mode"); + actAlerts.push($commonApi.dark ? "dark-mode" : "light-mode"); + return $commonApi.dark; + } else { + // true or false + store["dark-mode"] = enabled; + store.persist("dark-mode"); + $commonApi.dark = enabled; + actAlerts.push($commonApi.dark ? "dark-mode" : "light-mode"); + return enabled; + } +} + +// *** Store *** +// This object is used to store and retrieve data across disks +// The `local` method encodes everything as a JSON string. + +// Note: It strangely also contains an API which could be redefined +// unintentionally. 22.10.15.01.57 +// 22.11.25.11.07: The methods should be refactored out of the storage object / not share keys. +const store = { + persist: function (key, method = "local") { + // Send data over the thread through a key in this object. + send({ + type: "store:persist", + content: { + key, + data: this[key], + method, + }, + }); + // TODO: Turn the existing key into a retrieval function / promise? + }, + retrieve: function (key, method = "local") { + const promise = new Promise((resolve) => { + storeRetrievalResolutions[key] = resolve; + }); + + send({ type: "store:retrieve", content: { key, method } }); + + return promise; + }, + delete: function (key, method = "local") { + // Remove the key from the ram store, no matter what the method. + delete store[key]; + + const promise = new Promise((resolve) => { + storeDeletionResolutions[key] = resolve; + }); + + send({ + type: "store:delete", + content: { + key, + method, + }, + }); + return promise; + }, +}; + +// Promise based API calls (through `bios` and back) +let fileImport; +let serverUpload, serverUploadProgressReporter; +let zipCreation; +// Pending authorize() calls, oldest first. Bios answers them in order, so a +// queue keeps two requests in flight (a piece's inbox fetch and its census, +// say) from clobbering each other — with a single slot the first promise +// never settled and an awaiting boot hung on the noise forever. +let authorizationRequests = []; +let fileOpenRequest; +let fileEncodeRequest; +let gpuResponse; +let web3Response; +let tezosConnectResponse; +let tezosDisconnectResponse; +let tezosAddressResponse; +let tezosSignResponse; +let tezosCallResponse; + +// Other +let activeVideo; // TODO: Eventually this can be a bank to store video textures. +let videoDeviceCount = 0; +let lastActiveVideo; +let videoSwitching = false; +let preloadPromises = {}; +let inFocus; +let loadFailure; + +// 🎄 Piece code cache for merry preloading +// Maps piece slug -> { code, blobUrl, type: 'mjs' | 'lisp' } +const pieceCodeCache = new Map(); + +// Duration tracking for playlist progress bar (similar to tape system) +let durationStartTime = null; // When the piece started (null if no duration) - using performance.now() +let durationTotal = null; // Total duration in seconds (null if no duration) +let durationProgress = 0; // Current progress (0-1) +let durationCompleted = false; // Whether duration has completed +let durationBlinkState = false; // For blinking the completed bar +let pageLoadTime = performance.now(); // Time when the page first loaded - using performance.now() + +// 1. ✔ API + +// TODO: Eventually add a wiggle bank so all wiggles are indexed +// and start at random angles. +// let wiggler = 0; +let wiggleAngle = 0; + +// TODO; Change this to true and update all brushes. +// let NPdontPaintOnLeave = false; +let NPnoOnLeave = false; + +// 🔴 Recorder (Singleton) +class Recorder { + printProgress = 0; + presentProgress = 0; + printing = false; // Set by a callback from `bios`. + printed = false; // " + recording = false; // " + rollingCallback; + recorded = false; // " + presenting = false; // " + playing = false; // " + cutCallback; + printCallback; + framesCallback; + loadCallback; + + tapeTimerStart; + tapeProgress = 0; + tapeTimerDuration; + tapeFrameMode = false; // Whether we're recording based on frames instead of time + tapeFrameStart = 0; // Starting frame count + tapeFrameTarget = 0; // Target number of frames to record + + videoOnLeave = false; + + constructor() {} + + tapeTimerSet(durationOrFrames, time, isFrameMode = false) { + if (isFrameMode) { + // Frame-based recording + this.tapeFrameMode = true; + this.tapeFrameStart = Number($commonApi.paintCount || 0n); + this.tapeFrameTarget = durationOrFrames; + this.tapeTimerStart = null; + this.tapeTimerDuration = null; + + log.tape.log(`Starting frame-based recording: ${durationOrFrames} frames`); + + // Set a longer failsafe for frame-based recording (frames could take a while) + if (this.failsafeTimeout) { + clearTimeout(this.failsafeTimeout); + } + + this.failsafeTimeout = setTimeout(() => { + log.tape.warn("Frame-based failsafe triggered! Recording may have stalled."); + this.tapeProgress = 0; + this.tapeFrameMode = false; + this.tapeFrameStart = 0; + this.tapeFrameTarget = 0; + + if (typeof this.cut === 'function') { + this.cut(() => { + $commonApi.jump("video"); + }); + } else { + log.tape.warn("Cut function not available in failsafe"); + $commonApi.jump("video"); + } + this.failsafeTimeout = null; + }, Math.max(30000, durationOrFrames * 1000)); // At least 30 seconds, or 1 second per frame + } else { + // Time-based recording (existing behavior) + this.tapeFrameMode = false; + this.tapeTimerStart = time; + this.tapeTimerDuration = durationOrFrames; + + // Add a failsafe timer as backup in case tapeTimerStep stops being called + if (this.failsafeTimeout) { + clearTimeout(this.failsafeTimeout); + } + + // Set a failsafe that will trigger cut if the normal timer fails + this.failsafeTimeout = setTimeout(() => { + log.tape.warn("Failsafe timer triggered! Normal timer may have failed."); + if (this.tapeTimerDuration && this.tapeTimerStart) { + this.tapeProgress = 0; + this.tapeTimerStart = null; + this.tapeTimerDuration = null; + + if (typeof this.cut === 'function') { + this.cut(() => { + $commonApi.jump("video"); + }); + } else { + console.warn(`🎬 ⚠️ Cut function not available in failsafe, manual jump to video`); + $commonApi.jump("video"); + } + } + this.failsafeTimeout = null; + }, (durationOrFrames + 1) * 1000); // Add 1 second buffer to the failsafe + } + } + + tapeTimerStep({ needsPaint, sound: { time } }) { + if (this.tapeFrameMode) { + // Frame-based recording + if (!this.tapeFrameTarget) return; + + const currentFrame = Number($commonApi.paintCount || 0n); + const framesPassed = currentFrame - this.tapeFrameStart; + + this.tapeProgress = framesPassed / this.tapeFrameTarget; + needsPaint(); + + // Check if we've reached or exceeded the target frame count + if (framesPassed >= this.tapeFrameTarget) { + // Clear failsafe since normal timer completed + if (this.failsafeTimeout) { + clearTimeout(this.failsafeTimeout); + this.failsafeTimeout = null; + } + + this.tapeProgress = 0; + this.tapeFrameMode = false; + this.tapeFrameStart = 0; + this.tapeFrameTarget = 0; + + log.tape.success(`Frame-based recording complete: ${framesPassed} frames`); + + // Add safety check for callback existence + if (typeof this.cut === 'function') { + this.cut(() => { + $commonApi.jump("video"); + }); + } else { + log.tape.warn("Cut function not available"); + $commonApi.jump("video"); + } + } + } else { + // Time-based recording (existing behavior) + if (!this.tapeTimerDuration) return; + + // Enhanced timing with race condition protection + this.tapeProgress = (time - this.tapeTimerStart) / this.tapeTimerDuration; + needsPaint(); + + const secondsOver = + this.tapeProgress * this.tapeTimerDuration - this.tapeTimerDuration; + + // Run for an extra 150 milliseconds. + if (this.tapeProgress >= 1 && secondsOver > 0.15) { + // Clear failsafe since normal timer completed + if (this.failsafeTimeout) { + clearTimeout(this.failsafeTimeout); + this.failsafeTimeout = null; + } + + this.tapeProgress = 0; + this.tapeTimerStart = null; + this.tapeTimerDuration = null; + + // Add safety check for callback existence + if (typeof this.cut === 'function') { + this.cut(() => { + $commonApi.jump("video"); + }); + } else { + log.tape.warn("Cut function not available"); + $commonApi.jump("video"); + } + } + } + } + + slate() { + send({ type: "recorder:slate" }); // Kill the MediaRecorder instance. + // TODO: Should printing and playing also be set to false? + //$commonApi.rec.printing = false; // " + $commonApi.rec.recording = false; // Reset this singleton. + $commonApi.rec.recorded = false; // + $commonApi.rec.printed = false; // " + $commonApi.rec.printProgress = 0; // " + $commonApi.rec.cleanMode = false; // Reset clean mode flag + this.tapeFrameMode = false; + this.tapeFrameStart = 0; + this.tapeFrameTarget = 0; + } + + rolling(opts, cb) { + send({ type: "recorder:rolling", content: opts }); + this.rollingCallback = cb; + } + + cut(cb) { + $commonApi.rec.cutCallback = cb; + this.tapeProgress = 0; + this.tapeTimerStart = null; + this.tapeTimerDuration = null; + this.tapeFrameMode = false; + this.tapeFrameStart = 0; + this.tapeFrameTarget = 0; + send({ type: "signal", content: "recorder:cut" }); + } + + print(cb) { + $commonApi.rec.printCallback = cb; + send({ type: "recorder:print" }); + } + + present(noplay = false) { + this.presentProgress = 0; + send({ type: "recorder:present", content: { noplay } }); + } + + unpresent() { + send({ type: "recorder:unpresent" }); + } + + play() { + send({ type: "recorder:present:play" }); + } + + pause() { + send({ type: "recorder:present:pause" }); + } + + requestFrames(cb) { + $commonApi.rec.framesCallback = cb; + send({ type: "recorder:request-frames" }); + } +} + +let cachedAPI; // 🪢 This is a bit hacky. 23.04.21.14.59 + +const hourGlasses = []; + +async function uploadPainting(picture, progress, handle, filename) { + if (typeof picture === "string") { + return { url: picture }; // Assume a URL and just return it. + } else { + // Assume picture is a buffer with `{ pixels, width, height }` + // Upload it to the temporary bucket. + filename ||= `painting-${num.timestamp()}.png`; + + try { + const data = await $commonApi.upload( + filename, + { + pixels: picture.pixels, + width: picture.width, + height: picture.height, + }, + (p) => { + console.log("Painting upload progress:", p); + progress?.(p); + }, + !handle ? "art" : undefined, // Store in temporary if no HANDLE. + ); + console.log("🪄 Painting uploaded:", data.slug, data.ext, data.url); + return data; + } catch (err) { + console.error("🪄 Painting upload failed:", err); + } + } +} + +// Returns whether leaving a piece and also can overwrite the value. +function isLeaving(set) { + if (set === true || set === false) leaving = set; + return leaving; +} + +let docs; // Memorized by `requestDocs`. + +let baseTime = Date.now(); // Virtual clock base +let baseReal = Date.now(); // Real time at last baseTime +let clockFetching = false; +let lastServerTime = undefined; +let clockOffset = 0; // Smoothed offset from server + +// 🤖 Robo Class - For sending synthetic events through the act system +// 🤖 Robo: synthetic pen/event dispatcher decoupled from the hardware pen. +// Maintains its own virtual pen state so the real mouse/touch doesn't +// interfere with robot-driven gestures. +class Robo { + constructor() { + this.currentAPI = null; + // Virtual pen state — independent of $api.pen (which tracks the real mouse/touch). + this.pen = { x: 0, y: 0, px: 0, py: 0, pressure: 0.5, delta: { x: 0, y: 0 }, device: "robot" }; + } + + setAPI(api) { + this.currentAPI = api; + } + + // Send a synthetic event. The event payload carries its own coordinates — + // downstream handlers (nopaint updateBrush etc.) should prefer these over + // $api.pen for events with device === "robot". + sendEvent(eventType, coordinates = {}) { + if (!this.currentAPI) { + console.warn("🤖 Robo: No API context available"); + return; + } + + const x = coordinates.x ?? 0; + const y = coordinates.y ?? 0; + const px = coordinates.px ?? this.pen.x ?? 0; + const py = coordinates.py ?? this.pen.y ?? 0; + const pressure = coordinates.pressure ?? 0.5; + + // Update the robo's own virtual pen (NOT $api.pen). + this.pen.px = this.pen.x; + this.pen.py = this.pen.y; + this.pen.x = x; + this.pen.y = y; + this.pen.pressure = pressure; + this.pen.delta.x = x - px; + this.pen.delta.y = y - py; + this.pen.device = "robot"; + + const eventPayload = { + device: "robot", + type: eventType, + x, + y, + px, + py, + pressure, + delta: { x: x - px, y: y - py }, + }; + + try { + this.currentAPI.act(eventType, eventPayload); + } catch (error) { + console.error("🤖 Robo: Error dispatching event:", error); + } + } + + touch(x, y) { this.sendEvent("touch:1", { x, y, pressure: 0.5 }); } + draw(x, y, px, py) { this.sendEvent("draw:1", { x, y, px, py, pressure: 0.5 }); } + lift(x, y) { this.sendEvent("lift:1", { x, y, pressure: 0.5 }); } + act(eventType, coordinates) { this.sendEvent(eventType, coordinates); } +} + +// 🎹 Persistent DAW state - survives frame updates since $commonApi.sound gets recreated each frame +const persistentDawState = { + bpm: null, + playing: null, + time: null, + sampleRate: null, +}; + +// 🎛️ Persistent spreadnob state +const persistentSpreadnobState = { + note: null, + target: null, + value: null, + active: null, + min: null, + max: null, +}; + +const $commonApi = { + lisp, // A global reference to the `kidlisp` evalurator. + undef: undefined, // A global api shorthand for undefined. + hd, // 🖼️ Native-resolution Canvas2D layer (see the hd() definition above). + clock: { + offset: function () { + if (clockFetching) return; + + // Skip API calls in OBJKT mode or SPIDER mode + if (getPackMode() || (typeof window !== 'undefined' && window.acSPIDER)) { + clockFetching = false; + return; + } + + clockFetching = true; + const t0 = Date.now(); + + fetch("/api/clock") + .then((response) => { + if (!response.ok) { + return response.text().then((err) => { + clockFetching = false; + throw new Error( + `Failed to fetch offset: ${response.status} ${err}`, + ); + }); + } + + return response.text().then((serverTimeISO) => { + const t1 = Date.now(); + const serverTime = new Date(serverTimeISO).getTime(); + + // Assume serverTime is the midpoint of request + const rtt = t1 - t0; + const approxClientMidpoint = t0 + rtt / 2; + const targetOffset = serverTime - approxClientMidpoint; + + // Blend the clock offset gradually (e.g. 10% of the way each resync) + const blendFactor = 0.25; + clockOffset += (targetOffset - clockOffset) * blendFactor; + + // Recompute base time to keep virtual time in sync + baseTime = Date.now() + clockOffset; + baseReal = Date.now(); + + // console.log('synced') + + lastServerTime = serverTime; + clockFetching = false; + }); + }) + .catch((err) => { + console.error("Clock:", err); + clockFetching = false; + }); + }, + + resync: function () { + $commonApi.clock.offset(); + }, + + time: function () { + return new Date(baseTime + (Date.now() - baseReal)); + }, + }, + + // Enable Pointer Lock + penLock: () => { + send({ type: "pen:lock" }); + }, + // Send a message to BIOS (for effect control, etc.) + send: (msg) => { + send(msg); + }, + chat: chatClient.system, + dark: undefined, // If we are in dark mode. + theme: { + light: { + wipeBG: 150, + wipeNum: 200, + }, + dark: { + wipeBG: 32, + wipeNum: 64, + }, + }, + glaze: function (content) { + if (glazeEnabled === content.on) return; // Prevent glaze from being fired twice... + glazeEnabled = content.on; + // Send glaze message immediately when turning ON (don't wait for paint cycle) + // This ensures glaze activates even when returning from a non-glaze piece + if (content.on) { + send({ type: "glaze", content }); + } else { + glazeAfterReframe = { type: "glaze", content }; + } + }, + + // Toggle HUD visibility (same as Tab key functionality) + // skipSound: true for KidLisp pieces to avoid beep/bop sounds on tap + toggleHUD: function (isDoubleTap = false, skipSound = false) { + toggleHUDVisibility(isDoubleTap, skipSound); + }, + + jump: function jump(to, ahistorical = false, alias = false) { + console.log("🧭 jump() called:", to, "SOLO_MODE:", SOLO_MODE, "leaving:", leaving, "loading:", loading); + + if (SOLO_MODE) { + console.log("🔒 Jump blocked: solo mode active"); + return; + } + + if (leaving) { + console.log("🚪🐴 Jump cancelled, already leaving..."); + return; + } + const jumpOut = + to.startsWith("out:") || (to.startsWith("http") && platform.Aesthetic); + + if (shop.indexOf(to) > -1) to = "/" + to; // Jump out for shop products. + + if ( + ((to.startsWith("http") || to.startsWith("/")) && !to.endsWith(".mjs")) || + jumpOut + ) { + to = to.replace("out:", ""); + try { + // url = new URL(to); + console.log("🐎 Jumping to web URL:", to); + $commonApi.net.web(to, jumpOut); + return; + } catch (e) { + // Could not construct a valid url from the jump, so we will be + // running a local aesthetic.computer piece. + console.log("🐎 URL construction failed, treating as local piece:", e.message); + return; + } + } else { + leaving = true; + graph.unmask(); // Clear any active mask when leaving a piece + } + + // Preserve resolution parameters across jumps (device mode, TV mode, etc.) + // This ensures HUD visibility and other display settings persist when navigating + { + const params = []; + if (DEVICE_MODE) params.push("device=true"); + if (TV_MODE) params.push("tv=true"); + if (SOLO_MODE) params.push("solo=true"); + if (HIGHLIGHT_MODE) { + if (HIGHLIGHT_COLOR && HIGHLIGHT_COLOR !== "64,64,64") { + params.push(`highlight=${encodeURIComponent(HIGHLIGHT_COLOR)}`); + } else { + params.push("highlight=true"); + } + } + if (PERF_MODE) params.push("perf=true"); + if (AUTO_SCALE_MODE) params.push("autoScale=true"); + if (hideLabel) params.push("nolabel=true"); + if (NOGAP_MODE) params.push("nogap=true"); + if (SPOOF_AUDIO_MODE) params.push("spoofaudio=true"); + + if (params.length > 0) { + // Query params must land BEFORE any #fragment, otherwise they get + // glued into the hash (e.g. `painting#qwfV8wDk?nogap=true`) and the + // fragment-based code lookup — like painting short codes from `#eou` + // — resolves the corrupted string instead of the real code. + const hashIdx = to.indexOf("#"); + const base = hashIdx >= 0 ? to.slice(0, hashIdx) : to; + const frag = hashIdx >= 0 ? to.slice(hashIdx) : ""; + const separator = base.includes("?") ? "&" : "?"; + to = base + separator + params.join("&") + frag; + console.log("🧭 Preserving resolution params:", to); + } + } + + function loadLine() { + load(parse(to), ahistorical, alias, false, callback); + } + + let callback; + leaveLoad = () => { + // Intercept returns to the prompt when taping from a piece directly. + if ($commonApi.rec.videoOnLeave && to.split("~")[0] === "prompt") { + to = "video"; + $commonApi.rec.videoOnLeave = false; + $commonApi.rec.cut(loadLine); + } else { + if (to.split("~")[0] === "prompt" && + globalKidLispInstance?.clearBakedLayers) { + globalKidLispInstance.clearBakedLayers(); + graph.unmask(); // Clear mask when returning to prompt from KidLisp + } + loadLine(); // Or just load normally. + } + }; + return (cb) => (callback = cb); + }, + + // 🎄 Preload piece modules for merry pipelines (prevents network latency during fast cycling) + preloadPieces: async function preloadPieces(pieceNames, onProgress) { + if (!pieceNames || pieceNames.length === 0) return; + + const baseUrl = getBuiltInPieceBaseUrl(); + // Report fraction-complete (0..1) as each piece settles, so callers can + // drive a real loading spinner (e.g. `merryo ^pads` preloading 47 pieces). + let done = 0; + const total = pieceNames.length; + const bump = () => { + done += 1; + try { onProgress?.(done / total); } catch (e) {} + }; + + const fetchPromises = pieceNames.map(async (piece) => { + // Skip if already cached + if (pieceCodeCache.has(piece)) { + console.log(`🎄 Piece already cached: ${piece}`); + bump(); + return; + } + + try { + // Try .mjs first, then .lua, then .lisp + const mjsUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.mjs?v=${Date.now()}`; + let response = await fetch(mjsUrl, { cache: 'no-store' }); + + if (response.ok) { + const code = await response.text(); + pieceCodeCache.set(piece, { code, type: 'mjs' }); + console.log(`🎄 Preloaded mjs: ${piece}`); + return; + } + + // Try .lua + const luaUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.lua?v=${Date.now()}`; + response = await fetch(luaUrl, { cache: 'no-store' }); + + if (response.ok) { + const code = await response.text(); + pieceCodeCache.set(piece, { code, type: 'lua' }); + console.log(`🎄 Preloaded lua: ${piece}`); + return; + } + + // Try .lisp + const lispUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.lisp?v=${Date.now()}`; + response = await fetch(lispUrl, { cache: 'no-store' }); + + if (response.ok) { + const code = await response.text(); + pieceCodeCache.set(piece, { code, type: 'lisp' }); + console.log(`🎄 Preloaded lisp: ${piece}`); + return; + } + + console.warn(`🎄 Could not preload piece: ${piece}`); + } catch (err) { + console.warn(`🎄 Error preloading ${piece}:`, err); + } finally { + bump(); + } + }); + + await Promise.all(fetchPromises); + console.log(`🎄 Preloaded ${pieceCodeCache.size} pieces`); + }, + + // Get cached piece code (returns undefined if not cached) + getCachedPieceCode: function (pieceName) { + return pieceCodeCache.get(pieceName); + }, + + canShare: false, // Whether navigator.share is enabled for mobile devices. + leaving: isLeaving, + handle: () => { + return HANDLE; + }, + notice: (msg, color = ["white", "green"], opts) => { + notice = msg; + noticeColor = color; + noticeOpts = opts; + const sound = {}; + if (color[0] === "yellow" && color[1] === "red") sound.tone = 300; + noticeBell(cachedAPI, sound); + // The host shell (prompt.ac) covers the pixel buffer with its DOM, so + // notices also travel up to render as crisp DOM flashes. + if (shellHTMLMode) { + send({ type: "notice:shell", content: { text: msg, color } }); + } + }, + // 🪟 A crisp vector overlay, drawn on the native UI canvas (uiCtx) above the pixel + // buffer. Hand it a display-list of ops each frame and bios replays them with + // anti-aliased Canvas 2D — rounded rects, text, lines. For UI that wants to look + // like an app, not pixels. Set null / omit to draw nothing. + // overlay([["rr", x,y,w,h,r, [r,g,b,a]], ["text","W", x,y, size, [r,g,b,a], "center"], ...]) + overlay: (list) => { + overlay2D = list || null; + }, + // ⌛ Delay a function by `time` number of sim steps. + delay: (fun, time) => { + hourGlasses.push(new gizmo.Hourglass(time, { completed: () => fun() })); + }, + // Different syntax than `delay` but the same with looped behavior. + blink: (time, fun) => { + hourGlasses.push( + new gizmo.Hourglass(time, { completed: () => fun(), autoFlip: true }), + ); + }, + // 🎟️ Open a ticketed paywall on the page. + ticket: (name) => { + send({ type: "ticket-wall", content: name }); + }, + // 🪙 Mint a url or the `pixels` that get passed into the argument to a + // network of choice. + mint: async (picture, progress, params) => { + console.log("🪙 Minting...", picture); + // Determine if picture is a string or an object. + + // A record will be attached if one exists via the prompt and the user is + // logged in. + let filename; + let zipped; + if (picture.record && HANDLE) { + const record = picture.record; + filename = `painting-${record[record.length - 1].timestamp}.png`; + // Set filename based on record. + + zipped = await $commonApi.zip( + { destination: "upload", painting: { record } }, + (p) => { + console.log("🤐 Zip progress:", p); + progress?.(p); + }, + ); + + console.log("🤐 Zipped:", zipped); + } + + const data = await uploadPainting(picture, progress, HANDLE, filename); + + let description; + + if (picture.width && picture.height) { + description = `A ${picture.width}x${picture.height} pixel painting made on [aesthetic computer](https://aesthetic.computer).`; + } else { + description = `A painting made on [aesthetic computer](https://aesthetic.computer).`; + } + + if (data) { + // Redirect to Zora. + if (HANDLE && zipped) { + description = `[\` ${HANDLE}/${data.slug}\`](https://aesthetic.computer/painting~${HANDLE}/${data.slug})\n\n${description}`; + } + + if (HANDLE) data.slug = `${HANDLE}/painting/${data.slug}`; + + const pixels = `https://aesthetic.computer/api/pixel/2048:contain/${data.slug}.${data.ext}`; + + // TODO: Also add the `zip` here? + // Look into writing custom chunks into PNGS: https://chat.openai.com/c/0ed77e57-c5d2-4827-a733-c024da4bebd7 + + $commonApi.jump( + encodeURI( + `https://zora.co/create/single-edition?image=${pixels}&name=${ + params[0] || "Untitled Painting" + }&symbol=$${data.slug}&description=${description}`, + ), + ); + } + }, + // 🖨️ Print either a url or the `pixels` that get passed into + // the argument, with N quantity. + print: async (picture, quantity = 1, progress) => { + console.log("🖨️ Printing:", picture, "Quantity:", quantity); + const data = await uploadPainting(picture, progress); + let pixels; + if (data && data.code) { + // Use the short code (2-6 chars) which pixel.js can look up in the database + pixels = `${data.code}.${data.ext}`; + } else if (data && data.slug) { + // Fallback to slug if code is not available + pixels = `${data.slug}.${data.ext}`; + } else if (data) { + pixels = data.url; + } else { + $commonApi.notice("UPLOAD ERROR", ["red", "yellow"]); + return; + } + + try { + const headers = { "Content-Type": "application/json" }; + try { + // Include authorization token if logged in. + const token = await $commonApi.authorize(); // Get user token. + if (token) headers.Authorization = `Bearer ${token}`; + } catch (err) {} // Handled up-stream. + + const res = await fetch(`/api/print?new=true&pixels=${pixels}`, { + method: "POST", + headers, + body: JSON.stringify({ quantity, slug: $commonApi.slug }), + // TODO: Add order info here. ^ + }); + + const data = await res.json(); + if (!res.ok) + throw new Error( + `🖨️ Print: HTTP error! Status: ${JSON.stringify(data)}`, + ); + console.log("🖨️ Print order:", data); + $commonApi.jump(data.location); // Redirect to checkout. + } catch (error) { + console.error("🖨️ Print order error:", error); + } + }, + // ☕ Mug - Print a painting on a ceramic mug with optional color. + // `picture` can be a painting object OR a code string like "abc" or "#abc" + mug: async (picture, color = "white", quantity = 1, progress) => { + console.log("☕ Mug:", picture, "Color:", color, "Quantity:", quantity); + let pixels; + + // Check if picture is a code string (e.g., "abc" or "#abc") + if (typeof picture === "string") { + const code = picture.startsWith("#") ? picture.slice(1) : picture; + pixels = `${code}.png`; // Assume PNG for existing paintings + console.log("☕ Using existing painting code:", code); + } else { + // Upload the painting + const data = await uploadPainting(picture, progress); + if (data && data.code) { + pixels = `${data.code}.${data.ext}`; + } else if (data && data.slug) { + pixels = `${data.slug}.${data.ext}`; + } else if (data) { + pixels = data.url; + } else { + $commonApi.notice("UPLOAD ERROR", ["red", "yellow"]); + return; + } + } + + try { + const headers = { "Content-Type": "application/json" }; + try { + const token = await $commonApi.authorize(); + if (token) headers.Authorization = `Bearer ${token}`; + } catch (err) {} + + const res = await fetch(`/api/mug?new=true&pixels=${pixels}&color=${color}`, { + method: "POST", + headers, + body: JSON.stringify({ quantity, slug: $commonApi.slug }), + }); + + const data = await res.json(); + if (!res.ok) + throw new Error( + `☕ Mug: HTTP error! Status: ${JSON.stringify(data)}`, + ); + console.log("☕ Mug order:", data); + $commonApi.jump(data.location); + } catch (error) { + console.error("☕ Mug order error:", error); + } + }, + // Create a zip file of specified content. (Used for storing painting data.) + zip: (content, progress) => { + const prom = new Promise((resolve, reject) => { + zipCreation = { resolve, reject }; + }); + + if (content.destination === "upload") { + serverUploadProgressReporter = progress; + serverUploadProgressReporter?.(0); + } + + send({ type: "zip", content }); + return prom; + }, + // Track device motion. + motion: { + on: false, + start: () => { + send({ type: "motion:start" }); + }, + stop: () => { + send({ type: "motion:stop" }); + // TODO: Automatically stop when changing a disk? + }, + current: {}, // Will get replaced by an update event. + }, + // 🔷 Tezos wallet management (via bios.mjs) + wallet: { + _state: { + connected: false, + address: null, + balance: null, + network: "ghostnet", + domain: null, // .tez domain if resolved + }, + _stateRequested: false, // Track if we've requested state from bios + // Get current wallet state (requests from bios if not yet synced) + get: function () { + // Request fresh state from bios on first call + if (!$commonApi.wallet._stateRequested) { + $commonApi.wallet._stateRequested = true; + send({ type: "wallet:get-state" }); + } + return { ...$commonApi.wallet._state }; + }, + // Connect wallet (with optional address for manual entry) + connect: function (options = {}) { + // Support both connect(network) and connect({ address, network }) + if (typeof options === "string") { + send({ type: "wallet:connect", content: { network: options } }); + } else { + send({ type: "wallet:connect", content: options }); + } + }, + // Disconnect wallet + disconnect: function () { + send({ type: "wallet:disconnect" }); + }, + // Refresh balance from RPC + refreshBalance: function () { + send({ type: "wallet:refresh-balance" }); + }, + // Sign a message with the connected wallet + sign: function (message, callback) { + if (callback) $commonApi.wallet._signCallback = callback; + send({ type: "wallet:sign", content: { message } }); + }, + // Get pairing URI for mobile wallet QR code + getPairingUri: function (network = "mainnet", callback) { + if (callback) $commonApi.wallet._pairingCallback = callback; + send({ type: "wallet:get-pairing-uri", content: { network } }); + }, + // Internal callbacks + _pairingCallback: null, + _signCallback: null, + // Check if connected + isConnected: function () { + return $commonApi.wallet._state.connected; + }, + // Request state sync from bios + sync: function () { + send({ type: "wallet:get-state" }); + }, + }, + // Speak an `utterance` aloud. + speak: function speak(utterance, voice = "female:18", mode = "cloud", opts) { + return send({ type: "speak", content: { utterance, voice, mode, opts } }); + }, + // Broadcast an event through the entire act system. + act: (event, data = {}) => { + // console.log("Acted:", event); Show the synthetic event. + data.is = (e) => e === event; + cachedAPI.event = data; + try { + act(cachedAPI); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }, + // 🚥 `Get` api + // Retrieve media assets from a user account. + get: { + picture: (url) => { + return $commonApi.net.preload( + encodeURI(url), + true, + undefined, + // byOpts, + ); + }, + painting: (code, opts) => { + return { + by: async function (handle = "anon", byOpts) { + const extension = opts?.record ? "zip" : "png"; + + // Use the same origin-aware URL construction logic as module loading + const { protocol, hostname } = getSafeUrlParts(); + + let baseUrl; + // Check if we're in a development environment (localhost with port) + const isDevelopment = hostname === 'localhost' && typeof location !== 'undefined' && location.port; + if (isDevelopment) { + // Use the local development server + baseUrl = `${protocol}//${hostname}:${location.port}`; + } else { + // Use the production server for sandboxed iframes or production + baseUrl = `https://aesthetic.computer`; + } + + // Detect timestamp format (YYYY.M.D.H.M.S.mmm or YYYY.MM.DD.HH.MM.SS.mmm) + const isTimestamp = code.match(/^\d{4}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,3}$/); + + let mediaUrl; + if (isTimestamp && handle && handle !== "anon") { + // Legacy user painting with timestamp - use old format + const handleWithAt = handle.startsWith('@') ? handle : `@${handle}`; + mediaUrl = `${baseUrl}/media/${handleWithAt}/painting/${code}.${extension}`; + } else { + // 3-letter code or anonymous painting - use new endpoint + mediaUrl = `${baseUrl}/media/paintings/${code}.${extension}`; + } + + return $commonApi.net.preload( + mediaUrl, + true, + undefined, + byOpts, + ); + }, + }; + }, + }, + // ***Actually*** upload a file to the server. + // 📓 The file name can have `media-` which will sort it on the server into + // a directory via `presigned-url.js`. + upload: async (filename, data, progress, bucket, recordingSlug, metadata) => { + const prom = new Promise((resolve, reject) => { + serverUpload = { resolve, reject }; + }); + serverUploadProgressReporter = progress; + serverUploadProgressReporter?.(0); + + console.log("Uploading:", filename, { width: data?.width, height: data?.height }); + + send({ type: "upload", content: { filename, data, bucket, recordingSlug, metadata } }); + return prom; + }, + code: { + channel: (chan) => { + codeChannel = chan; // Set the current `codeChannel`. + store["code-channel"] = codeChannel; // Store and keep it in the browser. + store.persist("code-channel"); + if (!codeChannel || codeChannel?.length === 0) { + console.log("📭 Code channel cleared!"); + } else { + console.log("📬 Code channel set to:", codeChannel); + } + socket.send("code-channel:sub", codeChannel); + // Tell any parent iframes that the channel has been updated. + send({ + type: "post-to-parent", + content: { type: "setCode", value: codeChannel }, + }); + // ❤️‍🔥 + // TODO: Should return a promise here, and wait for a `code-channel:subbed` + // event, that way users get better confirmation if the socket + // doesn't go through / there is a server issue. 23.07.04.18.01 + }, + // Store a painting code mapping (timestamp/slug -> 3-letter code) + store: (slug, handle, code) => { + codeCache.storeCode(slug, handle, code); + }, + // Get a painting code from timestamp/slug + get: (slug, handle) => { + return codeCache.getCode(slug, handle); + }, + }, + encode: async (file) => { + // File should be { type, data } where type is "png", "webp", "jef", etc. + const prom = new Promise((resolve, reject) => { + fileEncodeRequest = { resolve, reject }; + }); + send({ type: "file-encode:request", content: file }); + return prom; + }, + // Open a local file picker. Pass { mode: "video" } (and optionally + // { accept }) to pick a clip and resolve with + // { kind:"video", data:ArrayBuffer, mime, name, duration }; the default + // resolves with a bitmap (image picker). + file: async (opts = {}) => { + const prom = new Promise((resolve, reject) => { + fileOpenRequest = { resolve, reject }; + }); + send({ type: "file-open:request", content: opts }); + return prom; + }, + // Authorize a user. + authorize: async () => { + // TODO: This should always fail while running user code. + // console.log("Sending auth request..."); + const prom = new Promise((resolve, reject) => { + authorizationRequests.push({ resolve, reject }); + }); + send({ type: "authorization:request" }); + return prom; + }, // Get a token for a logged in user. + // Hand-tracking. 23.04.27.10.19 TODO: Move eventually. + hand: { mediapipe: { screen: [], world: [], hand: "None" } }, + hud: { + label: (text, color, offset, plainTextOverride) => { + currentHUDTxt = text; + // Use plainTextOverride if provided, otherwise strip color codes from text + currentHUDPlainTxt = plainTextOverride || stripColorCodes(text); // Store plain text version + + if (!color) { + currentHUDTextColor = currentHUDTextColor || graph.findColor(color); + } else { + currentHUDTextColor = graph.findColor(color); + } + currentHUDOffset = offset; + + // Calculate and store dimensions for animation when directly called + // (This ensures kidlisp pieces have proper animation geometry) + if (currentHUDTxt && currentHUDTxt.length > 0) { + // Use plain text (without color codes) for dimension calculations + const textForMeasurement = currentHUDPlainTxt || currentHUDTxt; + + // Detect if this is a KidLisp piece for more generous width allowance + const sourceCode = currentText || currentHUDTxt; + const isKidlispPiece = (currentPath && lisp?.isKidlispSource && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || + currentPath === "(...)" || + (sourceCode && sourceCode.startsWith("$")) || + (currentPath && currentPath.includes("/disks/$")) || + (sourceCode && lisp?.isKidlispSource && lisp.isKidlispSource(sourceCode)); + + // Use full screen width for text wrapping + const maxWidth = cachedAPI.screen.width; + + const labelBounds = cachedAPI.text.box( + textForMeasurement, + undefined, + maxWidth, + 1, // scale + true, // wordWrap + // TODO: This should check useTinyHudLabel like the main corner label, but that's not accessible here + // For now, use default font to maintain consistency with current behavior + undefined // Use default font - will need to be updated when useTinyHudLabel is globally accessible + ); + + // Use the actual computed width from text.box instead of character count + const measuredWidth = labelBounds.box.width; + const fallbackShareWidth = (currentHUDLabelBlockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH) * "share ".length; + const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); + currentHUDLeftPad = shareWidth; + const baseLabelWidth = measuredWidth + shareWidth; + const scrubExtension = Math.max(0, currentHUDScrub); + const h = labelBounds.box.height + cachedAPI.typeface.blockHeight; + + currentHUDLabelMeasuredWidth = baseLabelWidth; + hudAnimationState.labelWidth = baseLabelWidth + scrubExtension; + hudAnimationState.labelHeight = h; + } + }, + // 📱 Set a QR code to display to the LEFT of the HUD label + qr: (url) => { + console.log("📱 HUD QR called with:", JSON.stringify(url), "current:", JSON.stringify(currentHUDQR)); + if (url !== currentHUDQR) { + currentHUDQR = url; + currentHUDQRCells = null; // Clear cache to regenerate + console.log("📱 HUD QR set to:", url); + } + }, + // 🔤 Force MatrixChunky8 (tiny) font for HUD label + tinyLabel: (enabled = true) => { + forceTinyHudLabel = enabled; + }, + // 🌐 Set a compact suffix (e.g. ".com") after the HUD label. + suffix: (text) => { + currentHUDSuperscript = text || null; + }, + // Backward-compatible name for older pieces. + superscript: (text) => { + currentHUDSuperscript = text || null; + }, + currentStatusColor: () => currentHUDStatusColor, + currentLabel: () => ({ + text: currentHUDTxt, + plainText: currentHUDPlainTxt, // Include plain text version + btn: currentHUDButton, + qrSize: currentHUDQRCells?.length || hudAnimationState?.qrSize || 0, + leftPad: currentHUDLeftPad || 0, + }), + labelBack: () => { + labelBack = true; + // Store the source piece for special handling (e.g., chat returning to prompt) + send({ type: "labelBack:source", content: { sourcePiece: $commonApi.piece } }); + }, + }, + // 📱 LAN Dev mode identity (from session server) + get devIdentity() { return devIdentity; }, + send, + platform, + // USB flash support (Electron only) + usb: { + listDevices: () => new Promise((resolve) => { + usbDeviceListResolution = resolve; + send({ type: "usb:list-devices" }); + }), + flashImage: (opts) => new Promise((resolve) => { + usbFlashResolution = resolve; + send({ type: "usb:flash-image", content: opts }); + }), + }, + history: [], // Populated when a disk loads and sets the former piece. + // Trigger background music. + // Eventually add an "@" style parameter similar to what a stamp system would have. + bgm: { + set: function (trackNumber, volume) { + send({ type: "bgm-change", content: { trackNumber, volume } }); + }, + stop: () => send({ type: "bgm-stop" }), + data: {}, + }, + system: { + // 🐚 True when an HTML shell (prompt.ac) hosts the runtime and owns the + // prompt / corner chrome in DOM — pieces skip their own low-res chrome. + shellhtml: shellHTMLMode, + // prompt: { input: undefined }, Gets set in `prompt_boot`. + // 🎵 ac-electron drag-drop: set when a file is dropped onto the app + // (Dock icon or running window). Pieces (notably `play`) read this on + // boot; a `dropped:file` act event also fires for already-running + // pieces. Cleared by the consumer. + droppedFile: null, + // 📦 Global update poll state — `ready` flips true when /api/version + // long-poll detects a new deployment. Pieces can read this to render + // their own update UI; the corner ↑ badge in disk.mjs is automatic. + update: { + get ready() { return globalUpdateReady; }, + get versionInfo() { return globalVersionInfo; }, + get recentCommits() { return globalRecentCommits; }, + reload: () => send({ type: "window:reload" }), + }, + world: { + // Populated in `world_boot` of `world.mjs`. + teleported: false, + telepos: undefined, + teleport: (to, telepos) => { + $commonApi.system.world.teleported = true; + $commonApi.system.world.telepos = telepos; + $commonApi.jump(to); + }, + }, + nopaint: { + //boot: nopaint_boot, // TODO: Why are these in the commonApi? 23.02.12.14.26 + // act: nopaint_act, + buffer: null, // An overlapping brush buffer that gets drawn on top of the + // painting. + piece: null, // Canonical code + pixel layer stack for No Paint paintings. + syncWip: async (api = cachedAPI) => { + if (!api) return null; + const { syncACPaintingWip } = await import("./painting-wip.mjs"); + return syncACPaintingWip(api); + }, + recording: false, + record: [], // Store a recording here. + gestureRecord: [], // Store the active gesture. + startRecord: function (fullText) { + const sys = $commonApi.system; + sys.nopaint.record = []; // Clear any existing recording. + sys.nopaint.recording = true; + sys.nopaint.addToRecord({ + label: fullText || "start", + painting: { + pixels: new Uint8ClampedArray(sys.painting.pixels), + width: sys.painting.width, + height: sys.painting.height, + }, + }); + // console.log("🖌️🔴 Now recording:", sys.nopaint.record); + }, + addToRecord: function (record) { + record.timestamp = num.timestamp(); // Insert the timestamp data. + record.gesture = $commonApi.system.nopaint.gestureRecord.slice(); + if (record.gesture.length === 0) delete record.gesture; + $commonApi.system.nopaint.gestureRecord = []; + $commonApi.system.nopaint.record.push(record); + store["painting:record"] = $commonApi.system.nopaint.record; + store.persist("painting:record", "local:db"); + // console.log("🖌️🟠 Recorded a step:", record.label); + }, + is: nopaint_is, + cancelStroke: nopaint_cancelStroke, + undo: { paintings: undoPaintings }, + needsBake: false, + needsPresent: false, + bakeOnLeave: false, + addUndoPainting, + // Regresses the system painting to a previous state. + // Or the reverse... ("yes") + no: ({ system, store, needsPaint }, yes = false) => { + const paintings = system.nopaint.undo.paintings; + + let dontRecord = false; + + if (yes) { + // ⏩ Fast-forward mode. + undoPosition += 1; + if (undoPosition > paintings.length - 1) { + undoPosition = paintings.length - 1; + dontRecord = true; + } + } else { + // ⏪ Rewind mode. + undoPosition -= 1; + if (undoPosition < 0) { + undoPosition = 0; + dontRecord = true; + } + } + + if (paintings.length > 1) { + // Copy over the old picture here... + const p = paintings[undoPosition]; + + const op = p.pixels; + const pixels = new Uint8ClampedArray(op.length); + pixels.set(op); + + store["painting"] = { + width: p.width, + height: p.height, + pixels, + }; + + const resolutionChange = + paintings[0].width !== paintings[1].width || + paintings[0].height !== paintings[1].height; + + // 🦢 Swap mode. + // 'no' should swap... + // const temp = paintings[0]; + // paintings[0] = paintings[1]; + // paintings[1] = temp; + + store.persist("painting", "local:db"); + + // 🎨 Broadcast painting update to other tabs + $commonApi.broadcastPaintingUpdate("updated", { + source: "yes-no-decision" + }); + + system.painting = store["painting"]; + + if (system.nopaint.recording && dontRecord === false) { + const label = yes ? "yes" : "no"; + system.nopaint.addToRecord({ + label, //, + // painting: { + // width: system.painting.width, + // height: system.painting.height, + // pixels: new Uint8Array(system.painting.pixels), + // }, + }); + } + + if (resolutionChange) { + system.nopaint.resetTransform({ system }); + system.nopaint.storeTransform(store, system); + } + + needsPaint(); + } + }, + // Center the picture within the screen / default translation. + resetTransform: ({ system: sys }) => { + sys.nopaint.zoomLevel = 1; + + if (!sys.painting) { + sys.nopaint.translation = { x: 0, y: 0 }; + return; + } + + sys.nopaint.translation.x = floor( + screen.width / 2 - sys.painting.width / 2, + ); + sys.nopaint.translation.y = floor( + screen.height / 2 - sys.painting.height / 2, + ); + }, + storeTransform: (store, sys) => { + store["painting:transform"] = { + translation: sys.nopaint.translation, + zoom: sys.nopaint.zoomLevel, + }; + store.persist("painting:transform", "local:db"); + }, + translation: { x: 0, y: 0 }, + zoomLevel: 1, + translate: ({ system }, x, y) => { + system.nopaint.translation.x += x; + system.nopaint.translation.y += y; + }, + // zoom: ({ system }, dir) => { + // system.nopaint.zoomLevel += dir === "in" ? 1 : -1; + // console.log("🔭 Zoom level:", system.nopaint.zoomLevel); + // if (system.nopaint.zoomLevel <= 0) system.nopaint.zoomLevel = 1; + // // TODO: Adjust the translation based on system.nopaint.brush.x and y + // // Which would serve as the zoom origin point. + // }, + zoom: ({ system }, dir, cursor) => { + // Store old zoom level + const oldZoomLevel = system.nopaint.zoomLevel; + + // Adjust the zoom level + system.nopaint.zoomLevel += dir === "in" ? 1 : -1; + + // Ensure zoom level is at least 1 + if (system.nopaint.zoomLevel <= 0) system.nopaint.zoomLevel = 1; + + // Calculate the scaling factor based on the change in zoom levels + const scale = system.nopaint.zoomLevel / oldZoomLevel; + + // Adjust the translation based on the scaling factor and the cursor's position + system.nopaint.translation.x = floor( + cursor.x + (system.nopaint.translation.x - cursor.x) * scale, + ); + system.nopaint.translation.y = floor( + cursor.y + (system.nopaint.translation.y - cursor.y) * scale, + ); + }, + brush: { x: 0, y: 0, dragBox: undefined }, + transform: (p) => { + return { + x: (p.x - nopaintAPI.translation.x) / nopaintAPI.zoomLevel, + y: (p.y - nopaintAPI.translation.y) / nopaintAPI.zoomLevel, + }; + }, + // Similar to `updateBrush` but for arbitrary points, + // with no change to `system.nopaint.brush`. + pointToPainting: ({ system, pen }) => { + const zoom = system.nopaint.zoomLevel; + const x = floor(((pen?.x || 0) - system.nopaint.translation.x) / zoom); + const y = floor(((pen?.y || 0) - system.nopaint.translation.y) / zoom); + + return { x, y }; + }, + updateBrush: ({ pen, event, system }, act) => { + // Prefer the event's coordinates over $api.pen for robot-driven events + // so the synthetic pen path doesn't get corrupted by hardware mouse moves. + const source = (event && event.device === "robot") ? event : pen; + // TODO: Use `pointToPainting` above. 23.10.11.08.49 + const zoom = system.nopaint.zoomLevel; + const x = floor(((source?.x || 0) - system.nopaint.translation.x) / zoom); + const y = floor(((source?.y || 0) - system.nopaint.translation.y) / zoom); + + if (act === "touch") { + system.nopaint.startDrag = { x, y }; + } + + // Ensure startDrag exists before creating dragBox + if (!system.nopaint.startDrag) { + system.nopaint.startDrag = { x, y }; + } + + const dragBox = new geo.Box( + system.nopaint.startDrag.x, + system.nopaint.startDrag.y, + x - system.nopaint.startDrag.x, + y - system.nopaint.startDrag.y, + ); + + system.nopaint.brush = { x, y, dragBox, pressure: source?.pressure || 0.5 }; + + // Call needsPaint during any pen movement to ensure continuous painting and HUD updates + $commonApi.needsPaint(); + }, + + // Helper to display the existing painting on the screen, with an + // optional pan amount, that returns an adjusted pen pointer as `brush`. + + // TODO: - [] Add Zoom + // - [] And Rotation! + + present: ( + { system, screen, wipe, paste, ink, slug, dark, theme, blend }, + tx, + ty, + ) => { + system.nopaint.needsPresent = false; + + // Guard: system.painting must exist for present to work + if (!system.painting) { + console.warn("🖼️ present: system.painting is undefined, skipping"); + return; + } + + const x = tx || system.nopaint.translation.x; + const y = ty || system.nopaint.translation.y; + + system.nopaint.translation = { x, y }; + + const fullbleed = + x === 0 && + y === 0 && + screen.width <= system.painting.width && + screen.height <= system.painting.height; + + if (fullbleed) { + // If we are not panned and the painting fills the screen. + paste(system.painting); + paste(system.nopaint.buffer); + } else { + // If we are panned or the painting is a custom resolution. + + wipe(theme[dark ? "dark" : "light"].wipeBG) + .paste(system.painting, x, y, system.nopaint.zoomLevel); + + // Normal alpha blending for overlay buffer + paste(system.nopaint.buffer, x, y, system.nopaint.zoomLevel); + + ink(128) + .box( + x, + y, + system.painting.width * system.nopaint.zoomLevel, + system.painting.height * system.nopaint.zoomLevel, + "outline", + ); + } + + // 🎯 DEBUG: Draw dragBox overlay if one exists (COMMENTED OUT) + /* + if (system.nopaint.brush?.dragBox) { + const dragBox = system.nopaint.brush.dragBox; + + // Apply zoom and translation to the dragBox coordinates + const overlayX = (dragBox.x * system.nopaint.zoomLevel) + x; + const overlayY = (dragBox.y * system.nopaint.zoomLevel) + y; + const overlayW = dragBox.w * system.nopaint.zoomLevel; + const overlayH = dragBox.h * system.nopaint.zoomLevel; + + // Draw the dragBox outline in bright green with some transparency + ink(0, 255, 0, 180).box(overlayX, overlayY, overlayW, overlayH, "outline"); + + // Draw corner indicators + const cornerSize = 4; + ink(0, 255, 0, 220); + // Top-left corner + ink().box(overlayX - cornerSize/2, overlayY - cornerSize/2, cornerSize, cornerSize); + // Top-right corner + ink().box(overlayX + overlayW - cornerSize/2, overlayY - cornerSize/2, cornerSize, cornerSize); + // Bottom-left corner + ink().box(overlayX - cornerSize/2, overlayY + overlayH - cornerSize/2, cornerSize, cornerSize); + // Bottom-right corner + ink().box(overlayX + overlayW - cornerSize/2, overlayY + overlayH - cornerSize/2, cornerSize, cornerSize); + + // Display coordinate info + const coordText = `${dragBox.x},${dragBox.y} ${dragBox.w}x${dragBox.h}`; + ink(0, 255, 0, 200).write(coordText, { + x: overlayX, + y: Math.max(overlayY - 15, 5) // Position above the box, but not off screen + }, undefined, undefined, false, "MatrixChunky8"); + } + */ + + // Graph `zoomLevel` + if (system.nopaint.zoomLevel !== 1 && slug !== "prompt") + ink(255, 127).write(`${system.nopaint.zoomLevel}x`, { x: 6, y: 18 }); + + return { + x, + y, //, + //brush: { x: (pen?.x || 0) - x, y: (pen?.y || 0) - y }, + }; + }, + // Kill an existing painting. + noBang: async ( + { system, store, needsPaint, painting, theme, dark }, + res = { w: screen.width, h: screen.height }, + ) => { + // console.log("deleting..."); + const deleted = await store.delete("painting", "local:db"); + await store.delete("painting:piece", "local:db"); + await store.delete("painting:resolution-lock", "local:db"); + await store.delete("painting:transform", "local:db"); + system.nopaint.undo.paintings.length = 0; // Reset undo stack. + system.painting = null; + system.nopaint.piece = null; + system.nopaint.resetTransform({ system, screen }); // Reset transform. + needsPaint(); + + // Make a blank painting. + // I don't like that these getters will not re-associate. + system.painting = painting(res.w, res.h, ($) => { + $.wipe(theme[dark ? "dark" : "light"].wipeNum); + }); + + // Store only the pixel data, not the full painting object with functions + store["painting"] = { + width: system.painting.width, + height: system.painting.height, + pixels: system.painting.pixels, + }; + + // Store the new painting BEFORE broadcasting + store.persist("painting", "local:db"); + + // 🎨 Broadcast painting cleared to other tabs + $commonApi.broadcastPaintingUpdate("cleared", { + source: "clear", + width: res.w, + height: res.h, + resetTransform: true // Flag that receiving tabs should reset their transform + }); + + // Clear any existing painting recording in RAM and + // storage. + await store.delete("painting:record", "local:db"); + if (system.nopaint.recording) { + system.nopaint.recording = false; + system.nopaint.record.length = 0; + } + + return deleted; + }, + // Replace a painting entirely, remembering the last one. + // (This will always enable fixed resolution mode.) + replace: ( + { system, screen, store, needsPaint }, + painting, + message = "(replace)", + ) => { + store.delete("painting:piece", "local:db"); + system.nopaint.piece = null; + system.painting = painting; // Update references. + + // Store only the pixel data, not the full painting object + store["painting"] = { + width: system.painting.width, + height: system.painting.height, + pixels: system.painting.pixels, + }; + + store.persist("painting", "local:db"); // Persist to storage. + + // 🎨 Broadcast painting replacement to other tabs + $commonApi.broadcastPaintingUpdate("replaced", { + source: "replace", + message: message + }); + + store["painting:resolution-lock"] = true; + store.persist("painting:resolution-lock", "local:db"); + system.nopaint.resetTransform({ system, screen }); // Reset transform. + system.nopaint.storeTransform(store, system); + system.nopaint.addUndoPainting(system.painting, message); + system.nopaint.needsPresent = true; + needsPaint(); + }, + abort: () => (NPnoOnLeave = true), + }, + }, + // Paint all queued rendering commands immediately. + flatten: () => { + return painting.paint(true); + }, + connect: () => { + const p = new Promise((resolve, reject) => { + web3Response = { resolve, reject }; + }); + send({ type: "web3-connect" }); + return p; + }, + // Tezos Wallet API + tezos: { + // Connect to a Tezos wallet (opens Beacon popup) + connect: (network = "mainnet") => { + const p = new Promise((resolve, reject) => { + tezosConnectResponse = { resolve, reject }; + }); + send({ type: "tezos-connect", content: { network } }); + return p; + }, + // Disconnect from Tezos wallet + disconnect: () => { + const p = new Promise((resolve, reject) => { + tezosDisconnectResponse = { resolve, reject }; + }); + send({ type: "tezos-disconnect" }); + return p; + }, + // Get current wallet address (null if not connected) + address: () => { + const p = new Promise((resolve, reject) => { + tezosAddressResponse = { resolve, reject }; + }); + send({ type: "tezos-address" }); + return p; + }, + // Sign a message with the connected wallet + sign: (message) => { + const p = new Promise((resolve, reject) => { + tezosSignResponse = { resolve, reject }; + }); + send({ type: "tezos-sign", content: { message } }); + return p; + }, + // Call a contract method + call: (contractAddress, method, args, amount = 0) => { + const p = new Promise((resolve, reject) => { + tezosCallResponse = { resolve, reject }; + }); + send({ type: "tezos-call", content: { contractAddress, method, args, amount } }); + return p; + }, + }, + wiggle: function (n, level = 0.2, speed = 6) { + wiggleAngle = (wiggleAngle + 1 * speed) % 360; + const osc = sin(num.radians(wiggleAngle)); + return n + n * level * osc; + }, + dark: true, // Dark mode. (Gets set on startup and on any change.) + darkMode, // Toggle dark mode or set to `true` or `false`. + // content: added programmatically: see Content class + gpuReady: false, + gpu: { + message: (content) => { + const p = new Promise((resolve, reject) => { + gpuResponse = { resolve, reject }; + }); + send({ type: "gpu-event", content }); + return p; + }, + }, + // WebGPU 2D Renderer API + webgpu: { + enabled: false, // Flag to disable CPU renderer when true + backend: null, // Preferred backend: "webgpu", "webgl2", "canvas2d", "vello", etc. + // 🛑 Disable WebGPU and restore normal CPU rendering + disable: () => { + $commonApi.webgpu.enabled = false; + $commonApi.webgpu.backend = null; + send({ type: "webgpu-command", content: { type: "disable" } }); + }, + clear: (r = 0, g = 0, b = 0, a = 255) => { + send({ + type: "webgpu-command", + content: { + type: "clear", + color: [r, g, b, a] + } + }); + }, + line: (x1, y1, x2, y2, r = 255, g = 255, b = 255, a = 255) => { + send({ + type: "webgpu-command", + content: { + type: "line", + x1, y1, x2, y2, + color: [r, g, b, a] + } + }); + }, + render: () => { + send({ + type: "webgpu-command", + content: { type: "render" } + }); + }, + // 📊 Toggle performance overlay + perf: (enabled) => { + send({ + type: "webgpu-command", + content: { type: "perf-overlay", enabled } + }); + }, + }, + // 📊 DOM-based stats overlay (always on top of everything, even GPU canvases) + stats: { + show: (data = {}) => { + send({ type: "stats-overlay", content: { enabled: true, data } }); + }, + hide: () => { + send({ type: "stats-overlay", content: { enabled: false } }); + }, + update: (data) => { + send({ type: "stats-overlay", content: { data } }); + }, + }, + // Deprecated in favor of `bios` -> `hitboxes`. (To support iOS) + // clipboard: { + // copy: (text) => { + // send({ type: "copy", content: text }); + // }, + // }, + text: { + capitalize: text.capitalize, + reverse: text.reverse, + // Get the pixel width of a string of characters. + width: (text, fontName) => { + if (Array.isArray(text)) text = text.join(" "); + + // Use specified font or fall back to current typeface + const useTypeface = getTypefaceForMeasurement(fontName) || tf; + if (!useTypeface) { + return text.length * DEFAULT_TYPEFACE_BLOCK_WIDTH; + } + + const isProportional = + useTypeface?.data?.proportional === true || + !!useTypeface?.data?.advances || + !!useTypeface?.data?.bdfFont; + + if (isProportional && typeof useTypeface.getAdvance === "function") { + let totalWidth = 0; + for (const char of text) { + if (char === "\n" || char === "\r") continue; + const advance = useTypeface.getAdvance(char); + totalWidth += typeof advance === "number" ? advance : useTypeface.blockWidth; + } + return totalWidth; + } else { + // For monospace fonts, use the typeface's block width + const blockWidth = useTypeface.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; + return text.length * blockWidth; + } + }, + height: (text) => { + // Get the pixel height of a string of characters. + return 10; + }, + // Return a text's bounding box. + box: (text, pos = { x: 0, y: 0 }, bounds, scale = 1, wordWrap = true, fontName) => { + + if (!text) { + console.warn("⚠️ No text for `box`."); + return; + } + + pos = { ...pos }; + const absScale = abs(scale ?? 1); + + let useTypeface = getTypefaceForMeasurement(fontName) || tf; + if (!useTypeface) { + useTypeface = tf; + } + + const baseBlockWidth = + useTypeface?.blockWidth ?? tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH; + const baseBlockHeight = + useTypeface?.blockHeight ?? tf?.blockHeight ?? DEFAULT_TYPEFACE_BLOCK_HEIGHT; + + const blockHeight = baseBlockHeight * absScale; + + const isProportional = + useTypeface?.data?.proportional === true || + !!useTypeface?.data?.advances || + !!useTypeface?.data?.bdfFont; + + const getAdvanceWidth = (char) => { + if (!char) return baseBlockWidth * absScale; + if (isProportional && typeof useTypeface?.getAdvance === "function") { + const raw = useTypeface.getAdvance(char); + if (typeof raw === "number") { + return raw * absScale; + } + } + return baseBlockWidth * absScale; + }; + + const getCharWidth = (char) => { + if (!char || char === "\n" || char === "\r") return 0; + if (char === "\t") { + return getAdvanceWidth(" ") * 4; + } + return getAdvanceWidth(char); + }; + + if (bounds === undefined) { + const sampleWidth = getAdvanceWidth("0"); + bounds = (text.length + 2) * sampleWidth; + } + if (!(bounds > 0)) { + bounds = Number.POSITIVE_INFINITY; + } + + const lines = [""]; + const charMap = [[]]; + let line = 0; + let run = 0; + let maxWidth = 0; + + const commitLineWidth = () => { + if (run > maxWidth) { + maxWidth = run; + } + }; + + const newLine = () => { + commitLineWidth(); + line += 1; + lines[line] = ""; + charMap[line] = []; + run = 0; + }; + + const appendChar = (char, sourceIndex) => { + const width = getCharWidth(char); + const renderedChar = char === "\t" ? " " : char; + lines[line] += renderedChar; + charMap[line].push(sourceIndex); + run += width; + if (run > maxWidth) { + maxWidth = run; + } + }; + + if (!wordWrap) { + for (let idx = 0; idx < text.length; idx += 1) { + const char = text[idx]; + if (char === "\r") continue; + if (char === "\n") { + newLine(); + continue; + } + appendChar(char, idx); + } + } else { + const tokens = []; + let idx = 0; + while (idx < text.length) { + const char = text[idx]; + if (char === "\r") { + idx += 1; + continue; + } + if (char === "\n") { + tokens.push({ type: "newline", indices: [idx] }); + idx += 1; + continue; + } + if (char === " " || char === "\t") { + let textBuffer = ""; + const indices = []; + while (idx < text.length) { + const c = text[idx]; + if (c !== " " && c !== "\t") break; + textBuffer += c; + indices.push(idx); + idx += 1; + } + tokens.push({ type: "whitespace", text: textBuffer, indices }); + continue; + } + let textBuffer = ""; + const indices = []; + while (idx < text.length) { + const c = text[idx]; + if (c === "\n" || c === " " || c === "\t" || c === "\r") break; + textBuffer += c; + indices.push(idx); + idx += 1; + } + if (textBuffer.length > 0) { + tokens.push({ type: "word", text: textBuffer, indices }); + } + } + + tokens.forEach((token) => { + if (token.type === "newline") { + newLine(); + return; + } + + if (token.type === "whitespace") { + for (let i = 0; i < token.text.length; i += 1) { + const char = token.text[i]; + const sourceIndex = token.indices[i]; + const width = getCharWidth(char); + if (run > 0 && run + width > bounds) { + newLine(); + } + appendChar(char, sourceIndex); + } + return; + } + + if (token.type === "word") { + const chars = token.text.split(""); + const widths = chars.map((char) => getCharWidth(char)); + const wordWidth = widths.reduce((acc, value) => acc + value, 0); + + if (bounds > 0 && wordWidth > bounds) { + chars.forEach((char, charIdx) => { + const width = widths[charIdx]; + if (run > 0 && run + width > bounds) { + newLine(); + } + appendChar(char, token.indices[charIdx]); + }); + return; + } + + if (run > 0 && run + wordWidth > bounds) { + newLine(); + } + + chars.forEach((char, charIdx) => { + appendChar(char, token.indices[charIdx]); + }); + } + }); + } + + commitLineWidth(); + + const lineHeightGap = 1 * absScale; + const finalBlockHeight = blockHeight + lineHeightGap; + + if (lines.length >= 1 && pos.center && pos.center.indexOf("y") !== -1) { + pos.y = + $activePaintApi.screen.height / 2 - + (lines.length * finalBlockHeight) / 2 + + finalBlockHeight / 2 + + (pos.y || 0); + } + + const height = lines.length * finalBlockHeight; + + const lineWidths = lines.map((lineText, index) => { + if (!lineText) return 0; + const indices = charMap[index] || []; + let width = 0; + for (let i = 0; i < lineText.length; i += 1) { + const sourceIndex = indices[i]; + if (typeof sourceIndex === "number" && sourceIndex >= 0 && sourceIndex < text.length) { + width += getCharWidth(text[sourceIndex]); + } else { + width += getCharWidth(lineText[i]); + } + } + return width; + }); + + const maxLineWidth = lineWidths.reduce((acc, value) => Math.max(acc, value), 0); + + const box = { x: pos.x, y: pos.y, width: maxLineWidth, height }; + + return { pos, box, lines, lineWidths, lineHeight: finalBlockHeight, charMap }; + }, + }, + num: { + add: num.add, + wrap: num.wrap, + even: num.even, + odd: num.odd, + clamp: num.clamp, + wave: num.wave, + rand: num.rand, + randInt: num.randInt, + randInd: num.randInd, + randIntArr: num.randIntArr, + randIntRange: num.randIntRange, + rangedInts: num.rangedInts, + multiply: num.multiply, + perlin: num.perlin, + dist: num.dist, + dist3d: num.dist3d, + radians: num.radians, + degrees: num.degrees, + lerp: num.lerp, + map: num.map, + arrMax: num.arrMax, + arrCompress: num.arrCompress, + Track: num.Track, + timestamp: num.timestamp, + p2: num.p2, + midp: num.midp, + number: num.number, + intersects: num.intersects, + signedCeil: num.signedCeil, + signedFloor: num.signedFloor, + vec2: vec2, + vec3: vec3, + vec4: vec4, + mat3: mat3, + mat4: mat4, + quat: quat, + parseColor: num.parseColor, + findColor: num.findColor, + saturate: num.saturate, + desaturate: num.desaturate, + shiftRGB: num.shiftRGB, + rgbToHexStr: num.rgbToHexStr, + hexToRgb: num.hexToRgb, + blend: num.blend, + rgbToHsl: num.rgbToHsl, + hslToRgb: num.hslToRgb, + rainbow: num.rainbow, + zebra: num.zebra, + resetZebraCache: num.resetZebraCache, + }, + geo: { + Box: geo.Box, + DirtyBox: geo.DirtyBox, + Grid: geo.Grid, + Circle: geo.Circle, + linePointsFromAngle: geo.linePointsFromAngle, + pointFrom: geo.pointFrom, + Race: geo.Race, + Quantizer: geo.Quantizer, + }, + ui: { + Button: ui.Button, + Slider: ui.Slider, + TextButton: ui.TextButton, + TextButtonSmall: ui.TextButtonSmall, + TextInput: TextInput, + TextFields: TextFields, + }, + help: { + choose: help.choose, + flip: help.flip, + repeat: help.repeat, + every: help.every, + any: help.any, + anyIndex: help.anyIndex, + anyKey: help.anyKey, + resampleArray: help.resampleArray, + each: help.each, + shuffleInPlace: help.shuffleInPlace, + serializePainting: (painting) => { + if (!painting) return; + const pixels = uint8ArrayToBase64(painting.pixels); + return { width: painting.width, height: painting.height, pixels }; + }, + deserializePainting: (painting) => { + if (!painting) return; + const pixels = base64ToUint8Array(painting.pixels); + return { width: painting.width, height: painting.height, pixels }; + }, + }, + gizmo: { Hourglass: gizmo.Hourglass, EllipsisTicker: gizmo.EllipsisTicker, Ticker: gizmo.Ticker }, + rec: new Recorder(), + robo: new Robo(), + net: { + signup: () => { + send({ type: "signup" }); + }, + login: () => { + store.delete("handle"); + send({ type: "login" }); + }, // { email } + logout: () => { + store.delete("handle"); + send({ type: "logout" }); + $commonApi.broadcast("logout:success"); + chatClient.system?.server?.send("logout"); + // Send a "logout" message here to the chat server. + + // TODO: And probably the session server as well in + // the future. 24.05.23.21.27 + }, + pieces: `${(() => { + // In spider mode, always use main aesthetic.computer domain + if (typeof window !== 'undefined' && window.acSPIDER) { + return 'https://aesthetic.computer'; + } + const { protocol, hostname } = getSafeUrlParts(); + return `${protocol}//${hostname}`; + })()}/aesthetic.computer/disks`, + parse, // Parse a piece slug. + // lan: // Set dynamically. + // host: // Set dynamically. + // loadFailureText: // Set dynamically. + // Make a user authorized / signed request to the api. + // Used both in `motd` and `handle`. + requestDocs: async () => { + if (typeof docs === "object") return Promise.resolve(docs); + return fetch("/docs.json") + .then((response) => { + if (response.status !== 200) { + throw new Error("Network failure: " + response.status); + } + return response.json(); + }) + .then((d) => { + docs = d; + return docs; + }) + .catch((err) => console.error("🔴 📚 Couldn't get docs:", err)); + }, + // Get the authorization token for the current user (for streaming/custom requests) + getToken: async () => { + try { + return await $commonApi.authorize(); + } catch (error) { + // Only log actual errors, not null (which just means not logged in) + if (error) console.warn("🔑 getToken:", error); + return null; + } + }, + userRequest: async (method, endpoint, body) => { + try { + const token = await $commonApi.authorize(); // Get user token. + if (!token) throw new Error("🧖 Not logged in."); + + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + + const options = { method, headers }; + if (body) options.body = JSON.stringify(body); + const response = await fetch(endpoint, options); + + if (response.status === 500) { + try { + const json = await response.json(); + return { status: response.status, ...json }; + } catch (e) { + return { status: response.status, message: response.statusText }; + } + } else { + const clonedResponse = response.clone(); + try { + return { + ...(await clonedResponse.json()), + status: response.status, + }; + } catch { + return { status: response.status, body: await response.text() }; + } + } + } catch (error) { + // Mail request errors can contain submitted letters. Piece telemetry + // captures this console, so discard provider text for these endpoints. + if (/^\/api\/(?:mail|mail-status|tell)(?:[/?#]|$)/.test(endpoint)) { + console.error("mail.request.failed"); + } else { + console.error("🚫 Error:", error); + } + return { message: "unauthorized" }; + } + }, + // Loosely connect the UDP receiver. + udp: (receive) => { + udpReceive = receive; + return udp; + }, + hiccup: (hiccupIn = 5, outageSeconds = 5) => { + console.log("😵 Hiccuping in:", hiccupIn, "seconds."); + clearTimeout(hiccupTimeout); + hiccupTimeout = setTimeout(() => { + console.log("😶‍🌫️ Hiccup!"); + chatClient.system?.server.kill(outageSeconds); // Disconnect from chat. + socket?.kill(outageSeconds); // Diconnect from socket session. + udp?.kill(outageSeconds); // Disconnect from UDP. + }, hiccupIn * 1000); + }, + // Remote debugging: Send log messages to session server for debugging on any device + log: function(levelOrFilename, ...args) { + // Handle both old API (filename, content) and new API (level, ...args) + if (levelOrFilename.startsWith('/tmp/') || levelOrFilename.includes('.log')) { + // Old API: filename, content + const filename = levelOrFilename; + const content = args[0]; + + // Send to session server if socket is available (removed verbose console log) + if (socket && socket.send) { + socket.send("dev-log", { + level: "INFO", + message: `${filename}: ${content}`, + device: navigator.userAgent || "unknown", + timestamp: Date.now() + }); + } + } else { + // New API: level, ...args + const level = levelOrFilename; + + // Serialize objects and create single-line message + const serializedArgs = args.map(arg => { + if (typeof arg === 'object' && arg !== null) { + try { + return JSON.stringify(arg); + } catch (e) { + return String(arg); + } + } + return String(arg); + }); + + const message = serializedArgs.join(" "); + + // Always log locally first + if (level === "warn") { + console.warn(...args); + } else if (level === "error") { + console.error(...args); + } else { + console.log(...args); + } + + // Send to session server if socket is available + if (socket && socket.send) { + socket.send("dev-log", { + level: level.toUpperCase(), + message: message, + device: navigator.userAgent || "unknown", + timestamp: Date.now() + }); + } + } + }, + }, + needsPaint: () => { + noPaint = false; + if (system === "nopaint") { + $commonApi.system.nopaint.needsPresent = true; + } + }, // TODO: Does "paint" needs this? + + store, + pieceCount: -1, // Incs to 0 when the first piece (usually the prompt) loads. + // Increments by 1 each time a new piece loads. + debug, + nopaintPerf, + + // 🎯 Cursor control API - pieces can request custom cursors + cursor: (cursorStyle) => { + send({ type: "cursor:set", content: { style: cursorStyle } }); + }, +}; + +// Convenience methods for different log levels +$commonApi.net.log.info = (...args) => $commonApi.net.log("info", ...args); +$commonApi.net.log.warn = (...args) => $commonApi.net.log("warn", ...args); +$commonApi.net.log.error = (...args) => $commonApi.net.log("error", ...args); + +// 🎬 Recording UI overlays - UI elements drawn on screen but NOT captured in tape recordings +// These are stored per-frame and cleared after sending to the main thread +let pendingRecordingUIOverlays = {}; + +// Add recording UI API to $commonApi +$commonApi.recordingUI = { + // Add an overlay that will be visible on screen but not recorded in tapes + // name: unique identifier for the overlay + // painting: a painting buffer created with $api.painting() + // x, y: position to draw the overlay + // opacity: optional opacity (0-1) + add: function(name, painting, x, y, opacity = 1) { + if (!painting || !painting.pixels) return; + pendingRecordingUIOverlays[name] = { + x: x, + y: y, + opacity: opacity, + img: { + width: painting.width, + height: painting.height, + pixels: painting.pixels, + }, + }; + }, + // Clear all recording UI overlays + clear: function() { + pendingRecordingUIOverlays = {}; + }, + // Get pending overlays (used internally when sending frame data) + _getPending: function() { + return pendingRecordingUIOverlays; + }, + // Clear pending overlays (used internally after sending) + _clearPending: function() { + pendingRecordingUIOverlays = {}; + }, +}; + +chatClient.$commonApi = $commonApi; // Add a reference to the `Chat` module. + +const nopaintAPI = $commonApi.system.nopaint; + +// 🎨 Start painting change monitoring for cross-tab sync +startPaintingChangeMonitoring(); + +// Broadcast to other tabs in the same origin. +const channel = new BroadcastChannel("aesthetic.computer"); + +channel.onmessage = (event) => { + processMessage(event.data); +}; + +async function processMessage(msg) { + if (logs.messaging) console.log(`🗼 Processing broadcast: ${msg}`); + + // 🎨 Handle painting updates (both new JSON format and legacy string format) + if (msg.startsWith("painting:") || (msg.startsWith("{") && msg.includes('"type":"painting:updated"'))) { + const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); + if (isNopaintActive) { + console.log(`🚫 CROSS-TAB INTERFERENCE: Receiving painting update during nopaint operation - this may disrupt live preview!`, { + msgPreview: msg.substring(0, 100) + '...', + nopaintState: 'painting' + }); + } + await handlePaintingUpdate(msg); + return; + } + + if (msg.startsWith("handle:updated")) { + // 👰‍♀️ Update the user handle if it changed. + const newHandle = msg.split(":").pop(); + HANDLE = "@" + newHandle; + if (typeof window !== 'undefined') window.acHANDLE = HANDLE; // Expose for UDP identity + if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); + send({ type: "handle", content: HANDLE }); + store["handle:received"] = true; + store["handle"] = newHandle; + // store.persist("handle"); + return; + } + + // Refresh the window if we logged in or out from another tab. + if (msg === "login:success" && !USER) { + $commonApi.net.refresh(); + return; + } + + if (msg === "logout:success" && USER) { + $commonApi.net.refresh(); // 🗒️ This should always be fast enough? + return; + } +} + +// 🎨 Handle painting update messages from other tabs +async function handlePaintingUpdate(msg) { + try { + // Parse the JSON message + const data = JSON.parse(msg); + + if (data.type !== "painting:updated") return; + + // Skip if this message is from the current tab + if (data.tabId === $commonApi._tabId) { + console.log(`🎨 SKIPPED: Own message (${data.action})`); + return; + } + + if (!$commonApi.system) { + console.log(`🎨 SKIPPED: No system available`); + return; + } + + // 📐 Handle resize events specifically + if ((data.action === "resized" && data.metadata?.source === "screen_resize") || + (data.action === "updated" && (data.source === "resize" || data.source === "crop"))) { + const width = data.width || data.metadata?.width; + const height = data.height || data.metadata?.height; + console.log(`📐 DIMENSION CHANGE EVENT: ${width}x${height} (source: ${data.source || data.metadata?.source})`); + + // For dimension change events, we need to wait for a "storage_complete" event + $commonApi._processingResize = true; + $commonApi._awaitingResizeStorage = { width, height, timestamp: data.timestamp }; + + console.log(`📐 Waiting for storage completion for ${data.source} ${width}x${height}...`); + return; // Don't continue with regular painting sync for dimension change events + } + + // 💾 Handle storage completion events + if (data.action === "storage_complete" && (data.source === "resize" || data.source === "crop")) { + const awaitingResize = $commonApi._awaitingResizeStorage; + console.log(`🪝 RECEIVED storage_complete:`, { + source: data.source, + timestamp: data.timestamp, + awaitingResize: awaitingResize, + timestampMatch: awaitingResize && data.timestamp === awaitingResize.timestamp + }); + + if (awaitingResize && data.timestamp === awaitingResize.timestamp) { + console.log(`📐 Storage completed for ${data.source} ${awaitingResize.width}x${awaitingResize.height}, applying...`); + + try { + const storedPainting = await store.retrieve("painting", "local:db"); + if (storedPainting) { + // Update system painting with the stored version + $commonApi.system.painting = { + width: storedPainting.width, + height: storedPainting.height, + pixels: new Uint8ClampedArray(storedPainting.pixels) + }; + + // Store the painting data + store["painting"] = { + width: storedPainting.width, + height: storedPainting.height, + pixels: storedPainting.pixels + }; + + // Update painting hash + lastPaintingHash = generatePaintingHash($commonApi.system.painting); + + // Update KidLisp with new painting reference + const kidlispInstance = getGlobalKidLisp(); + if (kidlispInstance) { + kidlispInstance.setAPI($commonApi); + console.log(`🎯 KidLisp API refreshed with ${data.source} painting: ${storedPainting.width}x${storedPainting.height}`); + } + + // Force repaint with new dimensions + if ($commonApi.system.nopaint) { + $commonApi.system.nopaint.needsPresent = true; + } + $commonApi.needsPaint(); + + console.log(`📐 Screen updated after ${data.source}: ${storedPainting.width}x${storedPainting.height}`); + } + } catch (error) { + console.error(`📐 ERROR applying ${data.source} after storage completion:`, error); + } + + // Clean up + delete $commonApi._awaitingResizeStorage; + $commonApi._processingResize = false; + return; + } + } + + // Set processing flag to prevent feedback loops + $commonApi._processingBroadcast = true; + + try { + // Load painting from storage (lightweight messages don't contain pixel data) + const storedPainting = await store.retrieve("painting", "local:db"); + if (storedPainting) { + $commonApi.system.painting = { + width: storedPainting.width, + height: storedPainting.height, + pixels: new Uint8ClampedArray(storedPainting.pixels) + }; + + // Store only the pixel data, not the full painting object + store["painting"] = { + width: storedPainting.width, + height: storedPainting.height, + pixels: storedPainting.pixels + }; + + // Update painting hash to prevent false positives + lastPaintingHash = generatePaintingHash($commonApi.system.painting); + + // 🎯 Update KidLisp with the new painting reference + const kidlispInstance = getGlobalKidLisp(); + if (kidlispInstance) { + kidlispInstance.setAPI($commonApi); + console.log(`🎯 KidLisp API refreshed with updated painting`); + } + + // Force repaint + if ($commonApi.system.nopaint) { + $commonApi.system.nopaint.needsPresent = true; + } + $commonApi.needsPaint(); + + // 🆕 Force nopan (reset transform) for clear/new actions or explicit flag + if (((data.action === "cleared" || data.action === "new") || data.resetTransform) && $commonApi.system.nopaint) { + console.log(`🎯 ${data.action?.toUpperCase() || 'TRANSFORM_RESET'}: Forcing nopan (reset transform) on receiver`); + $commonApi.system.nopaint.resetTransform({ + system: $commonApi.system, + screen: $commonApi.screen + }); + } + + console.log(`🎨 PAINTING SYNCED: ${storedPainting.width}x${storedPainting.height}`, { + hash: generatePaintingHash($commonApi.system.painting)?.substr(0, 8), + fromHash: data.hash || 'unknown', + firstPixels: Array.from($commonApi.system.painting.pixels.slice(0, 12)) // First 3 pixels (RGBA) + }); + } else { + // No painting found in storage + } + } catch (storageError) { + console.error(`🎨 ERROR loading from storage:`, storageError); + } + + } catch (error) { + console.error(`🎨 ERROR handling painting update:`, error); + + // Fallback to old string-based format + if (typeof msg === "string" && msg.startsWith("painting:")) { + const action = msg.split(":")[1]; + console.log(`🎨 FALLBACK: Processing legacy format ${action}`); + + // Legacy handling - load from storage + try { + const storedPainting = await store.retrieve("painting", "local:db"); + if (storedPainting) { + $commonApi.system.painting = { + width: storedPainting.width, + height: storedPainting.height, + pixels: new Uint8ClampedArray(storedPainting.pixels) + }; + + // Store only the pixel data, not the full painting object + store["painting"] = { + width: storedPainting.width, + height: storedPainting.height, + pixels: storedPainting.pixels + }; + + const kidlispInstance = getGlobalKidLisp(); + if (kidlispInstance) { + kidlispInstance.setAPI($commonApi); + } + + if ($commonApi.system.nopaint) { + $commonApi.system.nopaint.needsPresent = true; + } + $commonApi.needsPaint(); + } + } catch (storageError) { + console.error(`🎨 ERROR in fallback storage load:`, storageError); + } + } + } finally { + // Clear processing flag regardless of success or error + setTimeout(() => { + $commonApi._processingBroadcast = false; + }, 50); + } +} + +$commonApi.broadcast = (msg) => { + processMessage(msg); // Process locally. + channel.postMessage(msg); +}; + +// 🎨 Broadcast painting updates to other tabs +$commonApi.broadcastPaintingUpdate = (action, data = {}) => { + if (!$commonApi.system?.painting) return; + + // Check if we're in a nopaint operation and log it + const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); + if (isNopaintActive) { + console.log(`🚫 NOPAINT INTERFERENCE: Attempting to broadcast "${action}" during nopaint operation - this may disrupt live preview!`, { + action, + source: data.source, + nopaintState: 'painting' + }); + } + + // Throttle broadcasts to prevent excessive messages + const now = Date.now(); + if (now - lastBroadcastTime < broadcastThrottleDelay) { + return; + } + lastBroadcastTime = now; + + // Generate unique tab ID to prevent self-processing + if (!$commonApi._tabId) { + $commonApi._tabId = Math.random().toString(36).substr(2, 9); + } + + // Create lightweight notification message (no pixel data) + const message = { + type: "painting:updated", + action, + tabId: $commonApi._tabId, + timestamp: now, + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height, + ...data + }; + + // Only send to other tabs (exclude self) + channel.postMessage(JSON.stringify(message)); +}; + +// 🚀 Immediate painting broadcast - bypasses throttling for instant sync +$commonApi.broadcastPaintingUpdateImmediate = (action, data = {}) => { + if (!$commonApi.system?.painting || $commonApi._processingBroadcast) return; + + // Check for nopaint interference + const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); + if (isNopaintActive) { + console.log(`🚫 IMMEDIATE NOPAINT INTERFERENCE: Attempting immediate broadcast "${action}" during nopaint operation!`, { + action, + source: data.source, + nopaintState: 'painting' + }); + } + + // Performance: Skip if we just broadcasted very recently (debounce) + const now = Date.now(); + if (now - (lastBroadcastTime || 0) < 16) { // ~60fps max broadcast rate + return; + } + + // Generate unique tab ID if needed + if (!$commonApi._tabId) { + $commonApi._tabId = Math.random().toString(36).substr(2, 9); + } + + const paintingHash = generatePaintingHash($commonApi.system.painting); + const message = { + type: "painting:updated", + action, + tabId: $commonApi._tabId, + timestamp: now, + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height, + hash: paintingHash?.substr(0, 8), + immediate: true, + ...data + }; + + // Immediate broadcasting silently + + // Store painting immediately (async to not block) + setTimeout(() => { + store["painting"] = { + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height, + pixels: $commonApi.system.painting.pixels, + }; + store.persist("painting", "local:db"); + + // 🪝 Fire storage completion hook for resize and crop events + if (data.source === "resize" || data.source === "crop") { + const storageCompleteMessage = { + type: "painting:updated", + action: "storage_complete", + source: data.source, + tabId: message.tabId, + timestamp: message.timestamp, + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height + }; + + channel.postMessage(JSON.stringify(storageCompleteMessage)); + console.log(`🪝 STORAGE COMPLETE hook fired for ${data.source} ${$commonApi.system.painting.width}x${$commonApi.system.painting.height}`, { + timestamp: message.timestamp, + tabId: message.tabId.substr(0, 4) + '...' + }); + } + + }, 0); + + // Update hash to prevent duplicate detection + lastPaintingHash = paintingHash; + lastBroadcastTime = now; + + channel.postMessage(JSON.stringify(message)); +}; + +// 🎨 Painting change detection system +function generatePaintingHash(painting) { + if (!painting?.pixels) return null; + + // Simple hash using width, height, and sample of pixels + let hash = painting.width * 31 + painting.height * 37; + + // Sample every 100th pixel to create a lightweight hash + for (let i = 0; i < painting.pixels.length; i += 100) { + hash = ((hash << 5) - hash + painting.pixels[i]) & 0xffffffff; + } + + return hash.toString(36); +} + +function startPaintingChangeMonitoring() { + if (paintingChangeCheckInterval) return; // Already monitoring + + // Painting change monitoring initialized silently + + // Initialize hash baseline if painting exists + if ($commonApi.system?.painting) { + lastPaintingHash = generatePaintingHash($commonApi.system.painting); + } + + // Enable frame-based monitoring for immediate detection + enableFrameBasedMonitoring(); + + // Keep interval-based monitoring as backup (lower frequency) + paintingChangeCheckInterval = setInterval(() => { + if (!$commonApi.system?.painting || frameBasedMonitoring) return; // Skip if frame monitoring is active + + const currentHash = generatePaintingHash($commonApi.system.painting); + + // Only broadcast if hash actually changed AND we're not currently processing a broadcast + if (lastPaintingHash !== null && + currentHash !== lastPaintingHash && + !$commonApi._processingBroadcast) { + + // Set flag to prevent feedback loops during broadcast processing + $commonApi._processingBroadcast = true; + + // Store updated painting + store["painting"] = { + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height, + pixels: $commonApi.system.painting.pixels, + }; + store.persist("painting", "local:db"); + + // Broadcast the change + $commonApi.broadcastPaintingUpdate("updated", { + source: "interval_monitor", + hash: currentHash.substr(0,8) + }); + + // Clear the processing flag after a brief delay + setTimeout(() => { + $commonApi._processingBroadcast = false; + }, 100); + } + + lastPaintingHash = currentHash; + }, 1000); // Slower backup monitoring (1 second) +} + +function stopPaintingChangeMonitoring() { + frameBasedMonitoring = false; // Stop frame-based monitoring + if (paintingChangeCheckInterval) { + clearInterval(paintingChangeCheckInterval); + paintingChangeCheckInterval = null; + } +} + +// Spawn a session backend for a piece. +async function session(slug, forceProduction = false, service) { + let endPoint = "/session/" + slug; + const params = { service }; + if (forceProduction) params.forceProduction = 1; + endPoint += "?" + new URLSearchParams(params); + + const req = await fetch(endPoint); + + let session; + if (req.status === 200 || req.status === 304) { + session = await req.text().then((text) => { + try { + return JSON.parse(text); + } catch (e) { + return text; + } + }); + } else { + session = await req.text(); + } + + if (typeof session === "string") return session; + + // Session server is always ready (no Jamsocket polling needed) + // Just return the session object with URLs + return session; +} + +// Just for "update". +const $updateApi = {}; + +// 🖼 Painting + +// Pre-fab models: +const QUAD = { + type: "quad", + positions: [ + // Triangle 1 (Left Side) + [-1, -1, 0, 1], // Bottom Left + [-1, 1, 0, 1], // Top Left + [1, 1, 0, 1], // Top Right + // Triangle 2 (Right Side) + [-1, -1, 0, 1], // Bottom Left + [1, -1, 0, 1], // Bottom Right + [1, 1, 0, 1], // Top Right + ], + indices: [ + // These are not re-used for now. + // One + 0, 1, 2, + //Two + 3, 4, 5, + ], +}; + +// A cube of lines. +const CUBEL = { + type: "line", + positions: [ + // Back + [-0.5, -0.5, 0.5, 1], // Down + [-0.5, 0.5, 0.5, 1], + + [-0.5, 0.5, 0.5, 1], // Across + [0.5, 0.5, 0.5, 1], + + [0.5, 0.5, 0.5, 1], // Up + [0.5, -0.5, 0.5, 1], + + [0.5, -0.5, 0.5, 1], // Back + [-0.5, -0.5, 0.5, 1], + // Front + [-0.5, -0.5, -0.5, 1], // Down + [-0.5, 0.5, -0.5, 1], + + [-0.5, 0.5, -0.5, 1], // Across + [0.5, 0.5, -0.5, 1], + + [0.5, 0.5, -0.5, 1], // Up + [0.5, -0.5, -0.5, 1], + + [0.5, -0.5, -0.5, 1], // Back + [-0.5, -0.5, -0.5, 1], + // Bars (back to front) + [-0.5, -0.5, 0.5, 1], // Top Left + [-0.5, -0.5, -0.5, 1], + + [-0.5, 0.5, 0.5, 1], // Bottom Left + [-0.5, 0.5, -0.5, 1], + + [0.5, 0.5, 0.5, 1], // Up + [0.5, 0.5, -0.5, 1], + + [0.5, -0.5, 0.5, 1], // Back + [0.5, -0.5, -0.5, 1], + ], +}; + +const ORIGIN = { + type: "line", + positions: [ + [-0.5, 0, 0, 1], // Horizontal X + [0.5, 0, 0, 1], + [0, 0, -0.5, 1], // Horizontal Z + [0, 0, 2, 1], + [0, -0.5, 0, 1], // Vertical + [0, 0.5, 0, 1], + ], + colors: [ + [255, 0, 0, 255], + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + [0, 0, 255, 255], + ], +}; + +const TRI = { + type: "triangle", + positions: [ + [-1, -1, 0, 1], // Bottom Left + [0, 1, 0, 1], // Top Left + [1, -1, 0, 1], // Top Right + // Triangle 2 (Right Side) + ], + indices: [0, 1, 2], +}; + +const LINE = { + type: "line", + positions: [ + [0, 0, 0, 1], // Bottom + [0, 1, 0, 1], // Top + ], + indices: [0, 1], +}; + +// Inputs: (r, g, b), (r, g, b, a) or an array of those. +// (rgb) for grayscale or (rgb, a) for grayscale with alpha. +// Or hex with "#000000" or "0x000000" or 0x000000. +// TODO: Add `erase` anc all css color alpha support. 23.07.20.14.45 +// TODO: Add transparency and short hex to hex support. +// TODO: Add better hex support via: https://stackoverflow.com/a/53936623/8146077 + +function ink() { + const foundColor = graph.findColor(...arguments); + if (inkFloodLoggingEnabled()) { + console.log( + `${inkFloodLogPrefix()}🖍️ INK DEBUG`, + { + args: cloneArgsForLog(arguments), + resolved: cloneColorForLog(foundColor) + } + ); + } + const result = graph.color(...foundColor); + if (inkFloodLoggingEnabled()) { + console.log( + `${inkFloodLogPrefix()}🖍️ INK APPLIED`, + { + color: cloneColorForLog(result) + } + ); + } + return result; +} + +function ink2() { + if (arguments[0] === null) { + return graph.color2(null); + } else { + return graph.color2(...graph.findColor(...arguments)); + } +} + +// 🎯 Global KidLisp Instance Management +function initializeGlobalKidLisp(api) { + if (!globalKidLispInstance) { + // console.log("🚀 Initializing global KidLisp instance"); + if (typeof lisp.ensureGlobalInstance === "function") { + globalKidLispInstance = lisp.ensureGlobalInstance(); + } else if (typeof lisp.getGlobalInstance === "function") { + globalKidLispInstance = lisp.getGlobalInstance() || new lisp.KidLisp(); + } else { + globalKidLispInstance = new lisp.KidLisp(); + } + globalKidLispInstance.setAPI(api); + // Expose to window for bios.mjs acCLEAR_BAKE_LAYERS + if (typeof window !== 'undefined') { + window.__acGlobalKidLispInstance = globalKidLispInstance; + } + // Enable performance HUD if PERF_MODE is active + if (PERF_MODE) { + globalKidLispInstance.startPerformanceMonitoring(); + } + // Enable auto-density scaling if AUTO_SCALE_MODE is active + if (AUTO_SCALE_MODE) { + globalKidLispInstance.enableAutoDensity(); + } + } + return globalKidLispInstance; +} + +function getGlobalKidLisp() { + return globalKidLispInstance; +} + +// 🎵 Update KidLisp audio globals (safe for worker contexts) +function updateKidLispAudio(audioData) { + const payload = + audioData && typeof audioData === "object" + ? { ...audioData, __source: audioData.__source || "embedded:paintApi" } + : audioData; + if (globalKidLispInstance && globalKidLispInstance.updateAudioGlobals) { + globalKidLispInstance.updateAudioGlobals(payload); + } else if (payload?.amp > 0) { + console.warn("🔊 updateKidLispAudio: no instance!", !!globalKidLispInstance, !!globalKidLispInstance?.updateAudioGlobals); + } +} + +// 🔎 PAINTAPI +const $paintApi = { + // 1. Composite functions (that use $activePaintApi) + // (Must be unwrapped) + // Prints a line of text using the default / current global font. + // Argument options: + // text, pos: {x, y, center}, bg (optional) + + // Parameters: + // text, x, y, options, wordWrap, customTypeface + // text, pos, bg, bounds, wordWrap = true, customTypeface + write: function () { + let text = arguments[0], + pos, + bg, + bounds, + wordWrap = true, + customTypeface = null, + rotation = 0; + + if (text === undefined || text === null || text === "" || !tf) + return $activePaintApi; // Fail silently if no text. + + text = + typeof text === "object" && text !== null + ? JSON.stringify(text) + : text.toString(); + + // Assume: text, x, y, options, wordWrap, customTypeface + if (typeof arguments[1] === "number") { + pos = { x: arguments[1], y: arguments[2] }; + const options = arguments[3]; + bg = options?.bg; + bounds = options?.bounds; + wordWrap = options?.wordWrap === undefined ? wordWrap : options.wordWrap; + customTypeface = options?.typeface; + rotation = options?.rotation ?? options?.angle ?? 0; + } else { + pos = arguments[1]; + bg = arguments[2]; + bounds = arguments[3]; + wordWrap = arguments[4] === undefined ? wordWrap : arguments[4]; + customTypeface = arguments[5]; + // Extract rotation from pos object if it exists + rotation = pos?.rotation ?? pos?.angle ?? 0; + } + + if (customTypeface) { + const resolvedTypeface = resolveTypefaceInstance(customTypeface); + if (resolvedTypeface) { + ensureTypefaceLoaded(resolvedTypeface); + customTypeface = resolvedTypeface; + } + } + + // 🎨 Color code processing + const hasColorCodes = textContainsColorCodes(text); + + if (hasColorCodes) { + + + // Remember the current ink color to restore it later + const originalColor = $activePaintApi.inkrn(); + + // Process color codes into per-character color array + let cleanText = ""; + let charColors = []; + let currentColor = null; + + // Split text by color codes and process each segment. + // [text, code, text, code, …] — the text halves arrive unescaped, so a + // literal backslash the author typed lands in cleanText as one character. + const segments = splitColorCodes(text); + + for (let i = 0; i < segments.length; i++) { + if (i % 2 === 0) { + // This is regular text + const segment = segments[i]; + for (let j = 0; j < segment.length; j++) { + cleanText += segment[j]; + charColors.push(currentColor); + + + } + } else { + // This is a color name or RGB value (from the captured group) + const colorStr = segments[i]; + + if (!colorStr) { + continue; + } + + const normalized = colorStr.trim(); + const lower = normalized.toLowerCase(); + + if (!normalized) { + continue; + } + + if (lower === "reset" || lower === "default" || lower === "base") { + currentColor = null; + continue; + } + + if (normalized.includes(",")) { + // RGB/RGBA format like "255,0,0" or "255,0,0,128" + const parts = normalized.split(",").map((n) => { + const parsed = parseInt(n.trim(), 10); + return Number.isFinite(parsed) ? parsed : 0; + }); + while (parts.length < 3) parts.push(0); + if (parts.length === 3) parts.push(255); + currentColor = parts.slice(0, 4); + } else if (lower === "transparent" || lower === "clear") { + currentColor = [0, 0, 0, 0]; + } else { + const resolved = graph.findColor(normalized); + if (Array.isArray(resolved)) { + currentColor = resolved.slice(); + } else if (resolved && typeof resolved === "object") { + currentColor = { ...resolved }; + } else if (resolved !== undefined) { + currentColor = resolved; + } else { + currentColor = null; + } + } + + + } + } + + // Check if we have any actual text to display after removing color codes + if (cleanText.trim().length === 0) { + return $activePaintApi; // Exit silently if no text content remains + } + + + + // Render with colors - same logic as original text processing + const scale = pos?.size || 1; + + if (bounds) { + const tb = $commonApi.text.box(cleanText, pos, bounds, scale, wordWrap, customTypeface); + if (!tb || !tb.lines) { + return $activePaintApi; // Exit silently if text.box fails + } + + const charMap = tb.charMap || []; + + tb.lines.forEach((lineText, index) => { + const renderedLine = typeof lineText === "string" ? lineText : lineText?.join?.(" ") || ""; + const sourceIndices = charMap[index] || []; + const lineColors = []; + + for (let i = 0; i < renderedLine.length; i++) { + const sourceIndex = sourceIndices[i]; + if ( + typeof sourceIndex === "number" && + sourceIndex >= 0 && + sourceIndex < charColors.length + ) { + lineColors.push(charColors[sourceIndex]); + } else { + lineColors.push(null); + } + } + + // Add rotation to tb.pos + const posWithRotation = { ...tb.pos, rotation }; + + (customTypeface || tf)?.print( + $activePaintApi, + posWithRotation, + index, + renderedLine, + bg, + lineColors, + ); + }); + } else { + // Break on `\n` and handle separate lines + if (cleanText.indexOf("\n") !== -1) { + const lines = cleanText.split("\n"); + const lineHeightGap = 2; + let charIndex = 0; + + lines.forEach((line, index) => { + const lineColors = charColors?.slice( + charIndex, + charIndex + line.length, + ); + (customTypeface || tf)?.print( + $activePaintApi, + { + x: pos?.x, + y: pos + ? pos.y + index * (customTypeface || tf).blockHeight + lineHeightGap + : undefined, + rotation, + }, + 0, + line, + bg, + lineColors, + ); + charIndex += line.length + 1; // +1 for the newline character + }); + } else { + // Add rotation to pos + const posWithRotation = { ...pos, rotation }; + (customTypeface || tf)?.print($activePaintApi, posWithRotation, 0, cleanText, bg, charColors); + } + } + + const typefaceName = typeof customTypeface === "string" ? customTypeface : customTypeface?.name; + if (typefaceName === "MatrixChunky8" || typefaceName === "unifont") { + const hasColorAssignments = charColors?.some((color) => { + if (!color) return false; + if (Array.isArray(color)) { + return color.some((component) => component !== null && component !== undefined); + } + if (typeof color === "object") { + return Object.keys(color).length > 0; + } + return typeof color === "string"; + }); + + if (!hasColorAssignments) { + const snippet = cleanText.length > 120 ? `${cleanText.slice(0, 117)}…` : cleanText; + if (lastMatrixChunkyWriteDiagnosticLog !== snippet) { + lastMatrixChunkyWriteDiagnosticLog = snippet; + console.warn("🎨 MatrixChunky8 HUD charColors missing", { + snippet, + charColorsLength: charColors?.length, + cleanTextLength: cleanText.length, + }); + } + } + } + + // Restore the original ink color + $activePaintApi.ink(...originalColor); + return $activePaintApi; + } + + // 🎁 Original code for text without color codes + // See if the text length is greater than the bounds, and if it is then + // print on a new line. + const scale = pos?.size || 1; + + if (bounds) { + const tb = $commonApi.text.box(text, pos, bounds, scale, wordWrap, customTypeface); // TODO: Get the current ink color, memoize it, and make it static here. + // 23.10.12.22.04 + tb.lines.forEach((lineText, index) => { + const renderedLine = typeof lineText === "string" ? lineText : lineText?.join?.(" ") || ""; + // Add rotation to tb.pos + const posWithRotation = { ...tb.pos, rotation }; + (customTypeface || tf)?.print($activePaintApi, posWithRotation, index, renderedLine, bg); + }); + } else { + // Break on `\n` and handle separate lines + if (text.indexOf("\n") !== -1) { + const lines = text.split("\n"); // Split text on new line characters + const lineHeightGap = 2; + lines.forEach((line, index) => { + (customTypeface || tf)?.print( + $activePaintApi, + { + x: pos?.x, + y: pos + ? pos.y + index * (customTypeface || tf).blockHeight + lineHeightGap + : undefined, + rotation, + }, + 0, + line, + bg, + ); + // Adjust `lineHeight` as needed based on your text spacing + }); + } else { + //if (text === "POW") console.log($activePaintApi.screen); 24.12.10.07.26 - Get write working with deferred rendering and page. + + const actualFont = customTypeface || tf; + // Add rotation to pos + const posWithRotation = { ...pos, rotation }; + actualFont?.print($activePaintApi, posWithRotation, 0, text, bg); // Or print a single line. + } + } + + return $activePaintApi; + }, + // 2. Image Utilities + clonePixels: graph.cloneBuffer, + colorsMatch: graph.colorsMatch, + color: graph.findColor, + resize: graph.resize, + // 3. 3D Classes & Objects + Camera: graph.Camera, + Form: graph.Form, + Dolly: graph.Dolly, + sign: graph.sign, + cacheSignGlyph: graph.cacheSignGlyph, + // Helper to get glyphs from a typeface for use with sign() + glyphs: (fontName = "MatrixChunky8") => { + const typeface = typefaceCache.get(fontName); + return typeface?.glyphs || {}; + }, + TRI, + QUAD, + LINE, + CUBEL, + ORIGIN, + // Project text glyphs onto a 3D plane, drawing each character's line/point + // commands through a projection function. + // Usage: ink(r,g,b,a).write3D("text", { origin, right, down, project, typeface }, scale) + // origin: [x,y,z] — top-left of the first character in world space + // right: [x,y,z] — unit-ish direction for text flow (gets scaled by glyph size) + // down: [x,y,z] — unit-ish direction for descending lines + // project: ([x,y,z]) => [screenX, screenY] — world-to-screen projection + // typeface: optional typeface name (e.g. "MatrixChunky8") — defaults to active tf + // scale: multiplier for glyph coordinate spacing (default 1) + write3D: function (text, plane, scale = 1) { + if (!text || !plane) return $activePaintApi; + const { origin, right, down, project } = plane; + if (!origin || !right || !down || !project) return $activePaintApi; + + // Resolve typeface: use specified one from cache, or fall back to active tf + let face = tf; + if (plane.typeface && typefaceCache.has(plane.typeface)) { + face = typefaceCache.get(plane.typeface); + } + if (!face) return $activePaintApi; + + const blockW = face.blockWidth || face.data?.glyphWidth || 6; + const fontData = face.data || {}; + const advances = fontData.advances || {}; + let cursorX = 0; + + for (let ci = 0; ci < text.length; ci++) { + const char = text[ci]; + const glyph = face.getGlyph?.(char) || face.glyphs?.[char]; + const advance = advances[char] || face.getAdvance?.(char) || blockW; + + if (glyph) { + // Apply glyph offset (baselineOffset preferred, then offset) + let offX = 0, offY = 0; + if (glyph.baselineOffset) { + offX = glyph.baselineOffset[0] * scale; + offY = glyph.baselineOffset[1] * scale; + } else if (glyph.offset) { + offX = glyph.offset[0] * scale; + offY = glyph.offset[1] * scale; + } + + // Compute projected pixel size once for this glyph + // by measuring the screen distance of one glyph-pixel step + const projectPt = (gx, gy) => { + const px = cursorX + (gx * scale + offX); + const py = gy * scale + offY; + const wx = origin[0] + px * right[0] + py * down[0]; + const wy = origin[1] + px * right[1] + py * down[1]; + const wz = origin[2] + px * right[2] + py * down[2]; + return project([wx, wy, wz]); + }; + + // Measure projected pixel size (distance for 1 glyph-pixel in each axis) + const [refX, refY] = projectPt(0, 0); + const [refRX, refRY] = projectPt(1, 0); + const [refDX, refDY] = projectPt(0, 1); + const pxW = Math.sqrt((refRX - refX) ** 2 + (refRY - refY) ** 2) || 1; + const pxH = Math.sqrt((refDX - refX) ** 2 + (refDY - refY) ** 2) || 1; + const pxSize = Math.max(1, Math.round((pxW + pxH) / 2)); + + // Optional per-pixel callback (lets caller vary ink per pixel) + const pixelCb = plane.pixelCallback; + + // Render each "on" pixel as a box at its projected center + const renderPixel = (gx, gy) => { + const [sx, sy] = projectPt(gx + 0.5, gy + 0.5); + const bx = Math.floor(sx - pxSize / 2); + const by = Math.floor(sy - pxSize / 2); + if (pixelCb) pixelCb(bx, by, gx, gy, ci); + $activePaintApi.box(bx, by, pxSize, pxSize); + }; + + // BDF pixel-based glyphs (MatrixChunky8, etc.) + if (glyph.pixels) { + for (let row = 0; row < glyph.pixels.length; row++) { + const pixelRow = glyph.pixels[row]; + if (!Array.isArray(pixelRow)) continue; + for (let col = 0; col < pixelRow.length; col++) { + if (pixelRow[col] === 1) renderPixel(col, row); + } + } + } + + // Vector command-based glyphs (font_1, etc.) + if (glyph.commands) { + for (const { name, args } of glyph.commands) { + if (name === "point") { + renderPixel(args[0], args[1]); + } else if (name === "line") { + // Render pixel boxes at both endpoints + line between + renderPixel(args[0], args[1]); + renderPixel(args[2], args[3]); + const [s1x, s1y] = projectPt(args[0], args[1]); + const [s2x, s2y] = projectPt(args[2], args[3]); + $activePaintApi.line( + Math.floor(s1x), Math.floor(s1y), + Math.floor(s2x), Math.floor(s2y), + ); + } + } + } + } + cursorX += advance * scale; + } + return $activePaintApi; + }, +}; + +// TODO: Eventually move this to `num`. 24.07.23.18.52 +function normalizeAngle(angle) { + return ((angle % 360) + 360) % 360; +} + +let turtleAngle = 270; +let turtleDown = false; +let turtlePosition = { x: 0, y: 0 }; + +// This is where I map the API functions that anyone can use, to the internal +// code that represents them... + +// Rendering of 3D forms. + +const formsToClear = []; +let backgroundColor3D = [0, 0, 0, 255]; +let formsSent = {}; // TODO: This should be cleared more often... + +// `cpu: true` enabled software rendering +function form( + forms, + cam = $commonApi.system.fps.doll.cam, + { cpu, background } = { + cpu: true, + keep: true, + background: backgroundColor3D, + }, +) { // Exit silently if no forms are present. + if (forms == null || forms?.length === 0) return; + + if (cpu === true) { + if (formReframing) { + cam.resize(); + formReframing = false; + } + if (Array.isArray(forms)) + forms.filter(Boolean).forEach((form) => form.graph(cam)); + else forms.graph(cam); + } else { + // GPU forms. + if (!Array.isArray(forms)) forms = [forms]; + + // Clear out any forms that need deleting. + formsToClear.forEach((id) => delete formsSent[id]); + formsToClear.length = 0; + + // Build a list of forms to send, ignoring already sent ones by UID. + const formsToSend = []; + + forms.filter(Boolean).forEach((form) => { + // Clear out any trash in `formsSent` that do not have IDs left in forms. + //if (formsSent[forms.uid]) + + // A. If the form has not been sent yet... + if (formsSent[form.uid] === undefined && form.vertices.length > 0) { + // Set the form to expire automatically if keep is false. + formsToSend.push(form); + formsSent[form.uid] = form; + //console.log("Forms sent:", Object.keys(formsSent).length); + form.gpuVerticesSent = form.vertices.length; + form.gpuReset = false; + } else { + // B. If the form has been sent, but the form has changed and + // needs a partial state update or is simply being redrawn. + let msgCount = 0; + + if (form.gpuRecolored === true) { + formsToSend.push({ + update: "form:color", + uid: form.uid, + color: form.color, + }); + msgCount += 1; + form.gpuRecolored = false; + } + + // Transform the geometry. + if (form.gpuTransformed === true) { + formsToSend.push({ + update: "form:transform", + uid: form.uid, + rotation: form.rotation, + position: form.position, + scale: form.scale, + }); + form.gpuTransformed = false; + msgCount += 1; + } + + if (form.vertices.length > form.gpuVerticesSent || form.gpuReset) { + // Add vertices to buffered forms. + formsToSend.push({ + update: "form:buffered:add-vertices", + uid: form.uid, + reset: form.gpuReset, + vertices: form.vertices.slice(form.gpuVerticesSent), + length: form.vertices.length, // TODO: These aren't being used anymore / they are generated from the GPU. + pastLength: form.gpuVerticesSent, + }); + + // Update form state now that we are sending the message. + // TODO: Put these both under a "gpu" object in form. + //console.log(form.gpuReset); + form.gpuReset = false; + form.gpuVerticesSent = form.vertices.length; + msgCount += 1; + } + + if (msgCount === 0) { + // Simply tell the system we are still drawing the form... otherwise + // it will get cleared. + formsToSend.push({ + update: "form:touch", + uid: form.uid, + }); + } + } + }); + + if (formsToSend.length === 0) return; + + // console.log("Sending form...", performance.now()) + + // Only send a background update if the value changed. + if (background !== backgroundColor3D) { + send({ + type: "gpu-event", + content: { + type: "background-change", + content: background, + }, + }); + backgroundColor3D = background; + } + + send({ + type: "forms", + content: { + forms: formsToSend, + cam: { + position: cam.position, + rotation: cam.rotation, + scale: cam.scale, + fov: cam.fov, + near: cam.near, + far: cam.far, + }, + color: graph.color(), + }, + }); + + // paintFormsResolution?.(); + // return new Promise((resolve) => { + // paintFormsResolution = resolve; + // }); + } +} + +// 🔑 Code resolution cache for #code -> @handle/slug lookups +const codeCache = { + memory: new Map(), // code -> @handle/slug resolution cache + reverseMemory: new Map(), // @handle/slug -> code reverse lookup cache + + async resolve(code) { + // Return cached resolution if available + if (this.memory.has(code)) { + return this.memory.get(code); + } + + // Query painting-code API which returns slug and handle + const response = await fetch(`/api/painting-code?code=${code}`); + if (!response.ok) { + throw new Error(`Code #${code} not found`); + } + + const data = await response.json(); + + // Build the path format: @handle/slug or just slug for anonymous + let path; + if (data.handle && data.handle !== 'anon') { + path = `@${data.handle}/${data.slug}`; + } else { + path = data.slug; + } + + this.memory.set(code, path); + this.reverseMemory.set(path, code); + return path; + }, + + // Store a mapping from timestamp to code (called by profile.mjs and other pieces) + storeCode(slug, handle, code) { + if (!slug || !code) return; + + // Normalize the key format to match what we use elsewhere + const normalizedHandle = handle?.replace(/^@+/, ''); + const key = normalizedHandle ? `@${normalizedHandle}/${slug}` : slug; + + this.reverseMemory.set(key, code); + this.memory.set(code, key); + }, + + // Get code from timestamp+handle + getCode(slug, handle) { + const normalizedHandle = handle?.replace(/^@+/, ''); + const key = normalizedHandle ? `@${normalizedHandle}/${slug}` : slug; + return this.reverseMemory.get(key); + } +}; + + +// 🔑 Expose code cache debug utility +if (typeof window !== "undefined") { + window.acCodeCacheStats = () => { + console.log("🔑 Code resolution cache stats:", { + cacheSize: codeCache.memory.size, + codes: Array.from(codeCache.memory.entries()).map(([code, path]) => ({ + code: `#${code}`, + resolvedTo: path + })) + }); + }; +} + +// Used by `paste` and `stamp` to prefetch bitmaps of the network as needed. +// Occurs also when loading a piece's source code. +async function prefetchPicture(code) { + const originalCode = code; + + // Strip # prefix if present to get the actual code + let actualCode = code; + if (code.startsWith('#')) { + actualCode = code.slice(1); + } + + if (paintings[code] === "fetching") return; + + // 🖼️ Check multi-level cache first + // Level 1: Check if already in paintings cache + if (paintings[code] && paintings[code] !== "fetching") { + return; + } + + // Level 2: Check in-memory image cache + if (imageCache.has(code)) { + paintings[code] = imageCache.get(code); + return; + } + + // Level 3: Check persistent storage (IndexedDB) + if (imageCache.isCacheable(code)) { + const cachedImage = await imageCache.loadFromPersistent(code); + if (cachedImage) { + // console.log("🖼️ Loaded from cache:", code); + paintings[code] = cachedImage; + return; + } + } + + // Level 3.5: Check for embedded painting bitmaps (pack mode / bundled HTML) + const globalScope = (function() { + if (typeof globalThis !== 'undefined') return globalThis; + if (typeof self !== 'undefined') return self; + if (typeof window !== 'undefined') return window; + return {}; + })(); + + if (globalScope.acEMBEDDED_PAINTING_BITMAPS) { + // Check with various key formats + const embeddedBitmap = globalScope.acEMBEDDED_PAINTING_BITMAPS[code] || + globalScope.acEMBEDDED_PAINTING_BITMAPS['#' + actualCode] || + globalScope.acEMBEDDED_PAINTING_BITMAPS[actualCode]; + if (embeddedBitmap) { + console.log("🖼️ Loaded from embedded bundle:", code); + paintings[code] = embeddedBitmap; + return; + } + } + + // Level 4: Fetch from network + // console.log("🖼️ Prefetching from network...", code); + paintings[code] = "fetching"; + + const cacheAndStore = async (img) => { + paintings[code] = img; + // Only cache images from aesthetic.computer media backend + if (imageCache.isCacheable(code)) { + await imageCache.set(code, img); + } + }; + + if (actualCode.startsWith("http")) { + $commonApi.get + .picture(actualCode) + .then(({ img }) => cacheAndStore(img)) + .catch(() => { + delete paintings[code]; + }); + } else if (actualCode.includes("/")) { + // Format: @handle/timestamp or handle/timestamp + const [author, paintingSlug] = actualCode.split("/"); + $commonApi.get + .painting(paintingSlug) + .by(author) + .then(({ img }) => cacheAndStore(img)) + .catch(() => { + delete paintings[code]; + }); + } else if (actualCode.match(/^\d{4}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3}$/)) { + // Timestamp format without handle (e.g., "2025.11.01.23.55.14.266") + // This shouldn't happen - timestamps need a handle + console.warn(`⚠️ Painting timestamp without handle: ${actualCode}`); + delete paintings[code]; + } else if (actualCode.length === 3 && actualCode.match(/^[a-zA-Z0-9]{3}$/)) { + // 3-letter code — the `/media/paintings/.png` endpoint resolves + // the slug + user server-side, so we don't need a separate painting-code + // lookup (which returned a fully-qualified slug and broke `.by()` URL + // construction). + $commonApi.get + .painting(actualCode) + .by() + .then(({ img }) => cacheAndStore(img)) + .catch(() => { + delete paintings[code]; + }); + } else { + // Unknown format - try as direct code anyway + $commonApi.get + .painting(actualCode) + .by() // No author needed - endpoint resolves it + .then(({ img }) => cacheAndStore(img)) + .catch(() => { + delete paintings[code]; + }); + } +} + +const $paintApiUnwrapped = { + // Turtle graphics: 🐢 crawl, left, right, up, down, goto, face + // Move the turtle forward based on angle, wrapping at screen edges. + crawl: (steps = 1) => { + const w = screen.width, h = screen.height; + const x2 = turtlePosition.x + steps * cos(num.radians(turtleAngle)); + const y2 = turtlePosition.y + steps * sin(num.radians(turtleAngle)); + const wx2 = ((x2 % w) + w) % w; + const wy2 = ((y2 % h) + h) % h; + // Only draw if we didn't cross a screen edge (avoids diagonal wrap lines). + const wrapped = Math.abs(wx2 - x2) > 0.5 || Math.abs(wy2 - y2) > 0.5; + if (turtleDown && !wrapped) { + graph.line(turtlePosition.x, turtlePosition.y, wx2, wy2); + } + turtlePosition.x = wx2; + turtlePosition.y = wy2; + return { x: turtlePosition.x, y: turtlePosition.y }; + }, + // Turn turtle left n degrees. + left: (d = 1) => { + turtleAngle = normalizeAngle(turtleAngle - d); + return turtleAngle; + }, + // Turn turtle right n degrees. + right: (d = 1) => { + turtleAngle = normalizeAngle(turtleAngle + d); + return turtleAngle; + }, + // Turtle pen up. + up: () => { + turtleDown = false; + // console.log("🐢 Up"); + }, + // Turtle pen down. + down: () => { + turtleDown = true; + // console.log("🐢 Down"); + }, + // Teleport the turtle position (wraps to screen bounds). + goto: (x = screen.width / 2, y = screen.height / 2) => { + const w = screen.width, h = screen.height; + const wx = ((x % w) + w) % w; + const wy = ((y % h) + h) % h; + if (turtleDown) { + graph.line(turtlePosition.x, turtlePosition.y, wx, wy); + } + turtlePosition.x = wx; + turtlePosition.y = wy; + return { x: turtlePosition.x, y: turtlePosition.y }; + }, + face: (angle = 0) => { + turtleAngle = normalizeAngle(angle); + return turtleAngle; + }, + // Shortcuts + // l: graph.line, + // i: ink, + // Defaults + blend: graph.blendMode, + setEraseTarget: graph.setEraseTarget, + page: function () { + if (arguments[0]?.api) { + // console.log("New paint api?", arguments[0].api); + // $activePaintApi = arguments[0].api; + } + + const buf = arguments[0]; + + // If this is the main screen object (by identity), restore its true state. + // Brushes like smear call page(system.painting) which corrupts screen.pixels/ + // width/height. When they later call page(screen), we recognise the original + // screen object and restore from the frame-start snapshot so subsequent code + // (including other brushes and the nopaint pre-processing restore at line ~12788) + // sees correct dimensions immediately rather than at end-of-frame. + if (buf === _mainScreenObject && _trueScreenPixels) { + buf.pixels = _trueScreenPixels; + buf.width = _trueScreenWidth; + buf.height = _trueScreenHeight; + } else { + // Switching to a sub-buffer: propagate its dimensions into screen so that + // code reading screen.width/height while on this buffer gets correct values. + $activePaintApi.screen.width = buf.width; + $activePaintApi.screen.height = buf.height; + $activePaintApi.screen.pixels = buf.pixels; + } + + graph.setBuffer(buf); + }, + edit: graph.changePixels, // Edit pixels by pasing a callback. + // Color + ink: function () { + const out = ink(...arguments); + twoDCommands.push(["ink", ...out]); + }, + ink2: function () { + const out = ink2(...arguments); + twoDCommands.push(["ink2", ...(out || [])]); + }, + // inkrn: () => graph.c.slice(), // Get current inkColor. + // 2D + wipe: function () { + const cc = graph.c.slice(0); + + // Preserve fade alpha during wipe operations to prevent clearing it + const preserveFadeAlpha = getPreserveFadeAlpha?.() || false; + if (!preserveFadeAlpha && typeof setPreserveFadeAlpha === 'function') { + setPreserveFadeAlpha(true); + } + + // Default to white if no arguments provided + if (arguments.length === 0) { + ink(255, 255, 255); + } else { + ink(...arguments); + } + + // 🎨 WebGPU clear if enabled + if ($commonApi.webgpu.enabled) { + send({ + type: "webgpu-command", + content: { + type: "clear", + color: graph.c.slice(0) + } + }); + ink(...cc); + if (!preserveFadeAlpha && typeof setPreserveFadeAlpha === 'function') { + setPreserveFadeAlpha(false); + } + return; + } + + // 🚀 Use optimized clear() which writes directly to pixels array + // clear() already handles fade colors and uses copyWithin doubling for solid colors + // This is much faster than box() which goes through plot/blend per pixel + graph.clear(); + + twoDCommands.push(["wipe", ...graph.c]); + ink(...cc); + + // Restore previous preservation state + if (!preserveFadeAlpha && typeof setPreserveFadeAlpha === 'function') { + setPreserveFadeAlpha(false); + } + + // 🍞 LAYER 0: Also clear layer 0 if it exists (for KidLisp) + // This ensures wipe() clears the persistent layer, not just the screen + if (this.kidlispInstance?.layer0) { + this.kidlispInstance.layer0.pixels.fill(0); + } + }, + // Set background fill color for reframe operations (especially for KidLisp pieces) + backgroundFill: function (color) { + // This function should fill transparent areas with the background color + // without clearing existing painted content. + // + // For now, we use a simple approach: only do a full wipe during initial boot, + // but skip it during reframe to preserve content. + + const cc = graph.c.slice(0); // Save current ink color + + if (arguments.length === 0) { + ink(255, 255, 255); // Default to white + } else { + ink(...arguments); + } + + // TODO: Implement proper transparent-area-only filling + // For now, always do a full clear + graph.clear(); + twoDCommands.push(["backgroundFill", ...graph.c]); + ink(...cc); // Restore previous ink color + }, + // Erase the screen. + clear: function () { + const cc = graph.c.slice(0); + ink(0, 0); + graph.clear(); + ink(...cc); + }, + copy: graph.copy, + paste: function paste() { + if (typeof arguments[0] === "string") { + // Check to see if the bitmap has been cached by this piece already. + const code = arguments[0]; + if (paintings[code] && paintings[code] !== "fetching") { + graph.paste(paintings[code], ...[...arguments].slice(1)); + } else if (paintings[code] !== "fetching") { + prefetchPicture(code); + } + } else { + graph.paste(...arguments); + } + }, + // Similar to paste, but always draws from the center of x, y. + // Has partial support for {center, bottom}. 24.02.15.12.19 + stamp: function stamp() { + let params; + // Parse the parameters and lay out the stamp. + function makeLayout() { + if (typeof params[0] === "object") { + const layout = params[0]; + if (layout.center === "x") { + params[0] = $activePaintApi.screen.width / 2; + } else { + params[0] = 0; + } + if (layout.bottom !== undefined) { + params[1] = + $activePaintApi.screen.height - + layout.bottom - + paintings[code].height / 2; + } else { + params[1] = 0; + } + } + } + if (typeof arguments[0] === "string") { + // Check to see if the bitmap has been cached by this piece already. + const code = arguments[0]; + params = [...arguments].slice(1); + if (paintings[code] && paintings[code] !== "fetching") { + makeLayout(); + graph.stamp(paintings[code], ...params); + } else if (paintings[code] !== "fetching") { + prefetchPicture(code); + } + } else { + params = [...arguments].slice(1); + if (params.length === 0) params = [0, 0]; + makeLayout(); + graph.stamp(arguments[0], ...params); + } + }, + pixel: graph.pixel, + plot: function () { + if (arguments.length === 1) { + graph.plot(arguments[0].x, arguments[0].y); + } else { + graph.plot(...arguments); + } + }, // TODO: Should this be renamed to set? + flood: graph.flood, + compositeLayers: graph.compositeLayers, // GPU-accelerated multi-layer compositing + batchedEffects: graph.batchedEffects, // GPU-accelerated batched effects (zoom+scroll+contrast+brightness in one pass) + point: function () { + const out = graph.point(...arguments); + twoDCommands.push(["point", ...out]); + }, + line: function() { + // 🎨 WebGPU line if enabled + if ($commonApi.webgpu.enabled && arguments.length >= 4) { + const [x1, y1, x2, y2] = arguments; + const color = graph.c.slice(0); + send({ + type: "webgpu-command", + content: { + type: "line", + x1, y1, x2, y2, + color + } + }); + return; + } + return graph.line(...arguments); + }, + lineAngle: graph.lineAngle, + pline: graph.pline, + // Set the alpha of every non-transparent pixel in a buffer to a uniform value. + // Useful for per-stroke alpha — must be called after drawing on the buffer. + setBufferAlpha: function (buffer, alpha) { + if (!buffer || !buffer.pixels) return; + const target = Math.round(alpha * 255); + const px = buffer.pixels; + for (let i = 3; i < px.length; i += 4) { + if (px[i] > 0) px[i] = target; + } + }, + pppline: graph.pixelPerfectPolyline, + oval: graph.oval, + circle: graph.circle, + pie: graph.pie, + tri: graph.tri, + poly: graph.poly, + box: graph.box, + shape: graph.shape, + grid: graph.grid, + draw: graph.draw, + setShowClippedWireframes: graph.setShowClippedWireframes, + clearWireframeBuffer: graph.clearWireframeBuffer, + drawBufferedWireframes: graph.drawBufferedWireframes, + getRenderStats: graph.getRenderStats, + printLine: graph.printLine, // TODO: This is kind of ugly and I need a state machine for type. + form, + pan: graph.pan, + unpan: graph.unpan, + savepan: graph.savepan, + loadpan: graph.loadpan, + mask: graph.mask, + unmask: graph.unmask, + steal: graph.steal, + putback: graph.putback, skip: graph.skip, + scroll: graph.scroll, + flip: graph.flip, + spin: graph.spin, + sort: graph.sort, + zoom: graph.zoom, + suck: graph.suck, + blur: function(radius = 1) { + // 🔧 FIX: Ensure blur operates on current buffer context + // When called from within a painting() context, the graph module + // should already have the correct buffer set via setBuffer() + return graph.blur(radius); + }, + sharpen: function(strength = 1) { + // Apply sharpening filter to enhance edges and details + // When called from within a painting() context, the graph module + // should already have the correct buffer set via setBuffer() + return graph.sharpen(strength); + }, + invert: function() { + // Invert RGB colors (255 - value) while preserving alpha + // When called from within a painting() context, the graph module + // should already have the correct buffer set via setBuffer() + return graph.invert(); + }, + contrast: graph.contrast, + shear: graph.shear, + resetScrollState: function() { + // Reset scroll accumulator - used by burn to ensure clean state + return graph.resetScrollState(); + }, + noise16: graph.noise16, + noise16DIGITPAIN: graph.noise16DIGITPAIN, + noise16Aesthetic: graph.noise16Aesthetic, + noise16Sotce: graph.noise16Sotce, + noiseTinted: graph.noiseTinted, + // 🎨 Alpha-blended paste for crossfade compositing + pasteWithAlpha: function pasteWithAlpha(source, x, y, alpha) { + if (!source || !source.pixels || alpha <= 0) return; + const dst = $activePaintApi.screen; + if (!dst || !dst.pixels) return; + const srcPixels = source.pixels; + const dstPixels = dst.pixels; + const sw = source.width, sh = source.height; + const dw = dst.width, dh = dst.height; + if (alpha >= 255) { + $activePaintApi.paste(source, x, y); + return; + } + const alphaFactor = alpha / 255.0; + for (let sy = 0; sy < sh; sy++) { + const dy = sy + y; + if (dy < 0 || dy >= dh) continue; + for (let sx = 0; sx < sw; sx++) { + const dx = sx + x; + if (dx < 0 || dx >= dw) continue; + const si = (sy * sw + sx) * 4; + const di = (dy * dw + dx) * 4; + let srcA = srcPixels[si + 3]; + if (srcA === 0 && (srcPixels[si] || srcPixels[si + 1] || srcPixels[si + 2])) { + srcA = 255; // Promote opaque RGB with zero alpha + } + if (srcA === 0) continue; + const ea = (srcA * alphaFactor + 1) | 0; + const inv = 256 - ea; + dstPixels[di] = (ea * srcPixels[si] + inv * dstPixels[di]) >> 8; + dstPixels[di + 1] = (ea * srcPixels[si + 1] + inv * dstPixels[di + 1]) >> 8; + dstPixels[di + 2] = (ea * srcPixels[si + 2] + inv * dstPixels[di + 2]) >> 8; + dstPixels[di + 3] = Math.min(255, dstPixels[di + 3] + ea); + } + } + }, + // 🎯 Simplified KidLisp integration using global singleton instance + kidlisp: function kidlisp(x = 0, y = 0, width, height, source, options = {}) { + // Initialize global instance if needed + if (!globalKidLispInstance) { + initializeGlobalKidLisp($activePaintApi); + } + + // Default dimensions to screen size if not provided + if (!width) width = $activePaintApi.screen.width; + if (!height) height = $activePaintApi.screen.height; + + // console.log(`🎯 Simple kidlisp call: ${width}x${height} at (${x},${y})`); + + // Extract options + const { noCache = false, accumulate: forceAccumulate = false, noPaste = false } = options; + + try { + // Initialize persistent paintings cache if needed + if (!globalKidLispInstance.persistentPaintings) { + globalKidLispInstance.persistentPaintings = new Map(); + } + + // For dollar codes, check if we already have a resolved version cached + let resolvedSource = source; + if (source && source.startsWith && source.startsWith('$') && source.length > 1) { + const cacheId = source.slice(1); + + // Use a singleton-specific cache to avoid conflicts with normal prompt loading + if (!globalKidLispInstance.singletonDollarCodeCache) { + globalKidLispInstance.singletonDollarCodeCache = new Map(); + } + + // Track loading states to prevent concurrent requests + if (!globalKidLispInstance.loadingDollarCodes) { + globalKidLispInstance.loadingDollarCodes = new Set(); + } + + // First check if we have a painting with the resolved source already + if (globalKidLispInstance.singletonDollarCodeCache.has(cacheId)) { + resolvedSource = globalKidLispInstance.singletonDollarCodeCache.get(cacheId); + // Note: regionKey will be set later after accumulation detection + + // If we already have this resolved painting, we'll check after regionKey is set + // console.log(`🎯 Using cached source: ${resolvedSource}`); + } else if (globalKidLispInstance.loadingDollarCodes.has(cacheId)) { + // Already loading this dollar code, wait for it to complete + // console.log(`🎯 ${source} already loading, waiting...`); + return null; + } else { + // Start loading and mark as loading + console.log(`🎯 Loading ${source} for first time...`); + globalKidLispInstance.loadingDollarCodes.add(cacheId); + + getCachedCodeMultiLevel(cacheId).then(loadedSource => { + if (loadedSource) { + console.log(`🎯 ✅ Loaded source for ${cacheId}: ${loadedSource.length} chars`); + globalKidLispInstance.singletonDollarCodeCache.set(cacheId, loadedSource); + } else { + console.warn(`❌ Could not load source for ${cacheId}`); + } + }).catch(error => { + console.error(`❌ Error loading ${cacheId}:`, error); + }).finally(() => { + // Remove from loading set when done (success or failure) + console.log(`🎯 Finished loading attempt for ${cacheId}`); + globalKidLispInstance.loadingDollarCodes.delete(cacheId); + }); + + // Return early - no painting this frame, wait for cache + return null; + } + } + + // Guard: if no resolved source, bail early (e.g., disk-based piece, not raw KidLisp) + if (!resolvedSource || typeof resolvedSource !== "string") { + return null; + } + + // 🎨 AUTO-DETECT ACCUMULATION: Check if source starts with a color word or forced via option + const colorWords = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'cyan', 'magenta', 'black', 'white', 'gray', 'grey', 'brown', 'pink']; + const firstWord = resolvedSource.trim().split(/\s+/)[0]?.toLowerCase(); + const startsWithColor = colorWords.includes(firstWord); + + // Auto-generate accumulation settings (can be forced via option) + const accumulate = forceAccumulate || startsWithColor; + const accumulateKey = accumulate ? `auto_${x}_${y}_${width}_${height}_${firstWord}` : null; + + // Create persistent painting key with accumulation support + let regionKey; + // Build a dimension-independent content key for finding previous paintings after resize + let contentKey; + if (accumulate && accumulateKey) { + regionKey = `${x},${y},${width},${height}:ACCUMULATE:${accumulateKey}`; + contentKey = `ACCUMULATE:auto_${x}_${y}_${firstWord}`; + } else { + regionKey = `${x},${y},${width},${height}:${resolvedSource}`; + contentKey = resolvedSource; + } + + // 🔄 RESIZE RESILIENCE: If no cached painting at current dimensions, + // look for a previous painting with the same source at different dimensions. + // This preserves animation continuity when the screen resizes. + let resizedPreviousPainting = null; + let resizedPreviousKey = null; + if (!globalKidLispInstance.persistentPaintings.has(regionKey)) { + for (const [key, val] of globalKidLispInstance.persistentPaintings) { + // Match keys that share the same content suffix but differ in dimensions + if (key !== regionKey && key.endsWith(`:${contentKey}`)) { + resizedPreviousPainting = val; + resizedPreviousKey = key; + break; + } + } + } + + // Check if the code contains frame-dependent randomization commands + const hasFrameDependentCommands = /\(\s*ink\s*\)|\(\s*color\s*\)|\(\s*rand\s*\)/.test(resolvedSource); + + // Check if we need to reset (resolved source contains 'wipe') + const shouldReset = resolvedSource.includes('wipe') && !accumulate; // Don't reset in accumulation mode + + // Check if source contains animation-related commands that need fresh execution + const animationCommands = ['rainbow', 'zebra', 'time', 'random', 'noise', 'clock', 'scroll', 'zoom', 'flip', 'contrast', 'fade']; + const hasTimingCommands = /\d+\.?\d*s\b/.test(resolvedSource); // Detect timing like "0.15s", "1s", etc. + const hasAnimationCommands = animationCommands.some(cmd => resolvedSource.includes(cmd)); + const isDollarCode = source && source.startsWith && source.startsWith('$'); // Dollar codes should always refresh + // Don't force fresh execution for timing commands - they need to run continuously + // In accumulate mode, prefer building on existing surface unless explicitly forced + const needsFreshExecution = !accumulate && (noCache || (isDollarCode && !hasTimingCommands) || (hasAnimationCommands && !hasTimingCommands)); + + let painting; + + // Create fresh painting if: reset needed, no cached version exists, or animation commands need updating + if (shouldReset || needsFreshExecution || !globalKidLispInstance.persistentPaintings.has(regionKey)) { + // Create fresh painting (first time, after wipe, or for animations) + const reason = shouldReset ? 'wipe command' : needsFreshExecution ? 'animation content' : 'first time'; + // console.log(`🎨 Creating fresh painting for key: ${regionKey.slice(0, 50)}... (${reason})`); + + // For animations, start with previous frame if available (unless wiping) + // Also check for resized previous painting from a different dimension + const previousPainting = !shouldReset && globalKidLispInstance.persistentPaintings.has(regionKey) + ? globalKidLispInstance.persistentPaintings.get(regionKey) + : (!shouldReset ? resizedPreviousPainting : null); + + // Clean up the old dimension key if we found a resized previous + if (resizedPreviousKey && resizedPreviousPainting) { + globalKidLispInstance.persistentPaintings.delete(resizedPreviousKey); + } + + painting = $activePaintApi.painting(width, height, (api) => { + // Paste previous frame to maintain animation continuity (including across resizes) + if (previousPainting && needsFreshExecution && !shouldReset) { + api.paste(previousPainting); + } + + globalKidLispInstance.setAPI(api); + + // Add basic timing support for KidLisp commands + if (!api.clock) { + api.clock = { time: () => new Date() }; + } + // Override zoom and scroll functions to implement actual effects in painting context + const originalZoom = api.zoom; + const originalScroll = api.scroll || $activePaintApi.scroll; + + api.zoom = (...args) => { + // console.log(`🔍 Zoom called in painting context: args=${JSON.stringify(args)}`); + // Just call the original zoom function - it will operate on the current buffer + if (originalZoom && typeof originalZoom === 'function') { + return originalZoom(...args); + } + }; + + api.scroll = (dx, dy) => { + // console.log(`📜 Scroll called in painting context: dx=${dx}, dy=${dy}`); + // Call the real scroll function from the main paint API + if (originalScroll && typeof originalScroll === 'function') { + return originalScroll(dx, dy); + } else if ($activePaintApi.scroll && typeof $activePaintApi.scroll === 'function') { + return $activePaintApi.scroll(dx, dy); + } + }; + // Add randomization support for ink() and other commands + if (!api.num) { + api.num = $activePaintApi.num || { + random: () => Math.random(), + randInt: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min, + rainbow: num.rainbow, // Use the actual rainbow function from num.mjs + zebra: num.zebra // Use the actual zebra function from num.mjs + }; + } + // Add color support for proper ink randomization + if (!api.color) { + api.color = $activePaintApi.color || { + random: () => [ + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256) + ] + }; + } + + // 🎨 Add CSS color functions to make unquoted color words work + // Import CSS colors from num.mjs and add them as functions to the API + const cssColors = num.cssColors; + if (cssColors) { + Object.keys(cssColors).forEach(colorName => { + if (!api[colorName]) { + api[colorName] = () => cssColors[colorName]; + } + }); + } + + // Add rainbow and zebra as top-level functions for unquoted usage + if (!api.rainbow) { + api.rainbow = () => num.rainbow(); + } + if (!api.zebra) { + api.zebra = () => num.zebra(); + } + // Reset KidLisp color state to prevent cross-contamination between regions + globalKidLispInstance.currentInk = null; + + // Save original state before configuring execution mode + const originalInEmbedPhase = globalKidLispInstance.inEmbedPhase; + const originalIsNestedInstance = globalKidLispInstance.isNestedInstance; + const originalEmbeddedLayers = globalKidLispInstance.embeddedLayers; + const originalIsEmbeddedContext = globalKidLispInstance.isEmbeddedContext; + + // 🎯 CRITICAL: Set isEmbeddedContext=true so effects (zoom, blur, scroll, etc.) + // execute immediately instead of being deferred to postCompositeCommands + // This aligns embedded kidlisp() with the main KidLisp paint behavior + globalKidLispInstance.isEmbeddedContext = true; + + // Check if source contains timing expressions that need proper scheduling + const hasTimingExpressions = /\d+\.?\d*s(\.\.\.|!)?/.test(resolvedSource); + // Check if source contains scroll/zoom/flip that needs deferred execution + const hasScrollZoom = /\(\s*(scroll|zoom|flip)\s/.test(resolvedSource); + + + + if (hasTimingExpressions) { + // console.log(`🎯 Detected timing expressions, preserving normal execution flow`); + // Don't force immediate execution for timing expressions + globalKidLispInstance.embeddedLayers = null; // Still clear embedded layers + } else if (hasScrollZoom) { + // console.log(`🎯 Detected scroll/zoom/flip, allowing deferred execution`); + // Allow scroll/zoom/flip to use deferred execution but clear embedded layers + globalKidLispInstance.inEmbedPhase = false; // Allow deferring for scroll/zoom/flip + globalKidLispInstance.isNestedInstance = false; // Allow deferring for scroll/zoom/flip + globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers + } else { + // console.log(`🎯 No timing/scroll/zoom/flip detected, forcing immediate execution`); + // Force immediate execution mode for simplified kidlisp() calls without timing or scroll/zoom/flip + globalKidLispInstance.inEmbedPhase = true; // Prevent deferring + globalKidLispInstance.isNestedInstance = true; // Enable immediate execution + globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers + } + + executeLispCode(resolvedSource, api, false); // false = not accumulating, fresh painting + + // Restore original state to avoid interfering with other KidLisp operations + globalKidLispInstance.inEmbedPhase = originalInEmbedPhase; + globalKidLispInstance.isNestedInstance = originalIsNestedInstance; + globalKidLispInstance.embeddedLayers = originalEmbeddedLayers; + globalKidLispInstance.isEmbeddedContext = originalIsEmbeddedContext; + + globalKidLispInstance.setAPI($activePaintApi); + }); + // Always cache the painting for next frame continuity (even for animations) + globalKidLispInstance.persistentPaintings.set(regionKey, painting); + } else { + // Build on existing painting (accumulate effects) + const existingPainting = globalKidLispInstance.persistentPaintings.get(regionKey); + // console.log(`🎨 Accumulating on existing painting for key: ${regionKey.slice(0, 50)}...`); + + painting = $activePaintApi.painting(width, height, (api) => { + // Paste previous state first + api.paste(existingPainting); + // Then add new effects on top + globalKidLispInstance.setAPI(api); + + // Add basic timing support for KidLisp commands + if (!api.clock) { + api.clock = { time: () => new Date() }; + } + // Add scroll and zoom support for animations + const originalZoom = api.zoom; + const originalScroll = api.scroll || $activePaintApi.scroll; + + api.zoom = (...args) => { + // console.log(`🔍 Zoom called in accumulation context: args=${JSON.stringify(args)}`); + if (originalZoom && typeof originalZoom === 'function') { + return originalZoom(...args); + } + }; + + api.scroll = (dx, dy) => { + // console.log(`📜 Scroll called in accumulation context: dx=${dx}, dy=${dy}`); + if (originalScroll && typeof originalScroll === 'function') { + return originalScroll(dx, dy); + } else if ($activePaintApi.scroll && typeof $activePaintApi.scroll === 'function') { + return $activePaintApi.scroll(dx, dy); + } + }; + // Add randomization support for ink() and other commands + if (!api.num) { + api.num = $activePaintApi.num || { + random: () => Math.random(), + randInt: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min, + rainbow: num.rainbow, // Use the actual rainbow function from num.mjs + zebra: num.zebra // Use the actual zebra function from num.mjs + }; + } + // Add color support for proper ink randomization + if (!api.color) { + api.color = $activePaintApi.color || { + random: () => [ + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256) + ] + }; + } + + // 🎨 Add CSS color functions to make unquoted color words work + // Import CSS colors from num.mjs and add them as functions to the API + const cssColors = num.cssColors; + if (cssColors) { + Object.keys(cssColors).forEach(colorName => { + if (!api[colorName]) { + api[colorName] = () => cssColors[colorName]; + } + }); + } + + // Add rainbow and zebra as top-level functions for unquoted usage + if (!api.rainbow) { + api.rainbow = () => num.rainbow(); + } + if (!api.zebra) { + api.zebra = () => num.zebra(); + } + // Reset KidLisp color state to prevent cross-contamination between regions + globalKidLispInstance.currentInk = null; + + // Save original state before configuring execution mode + const originalInEmbedPhase = globalKidLispInstance.inEmbedPhase; + const originalIsNestedInstance = globalKidLispInstance.isNestedInstance; + const originalEmbeddedLayers = globalKidLispInstance.embeddedLayers; + const originalIsEmbeddedContext = globalKidLispInstance.isEmbeddedContext; + + // 🎯 CRITICAL: Set isEmbeddedContext=true so effects (zoom, blur, scroll, etc.) + // execute immediately instead of being deferred to postCompositeCommands + // This aligns embedded kidlisp() with the main KidLisp paint behavior + globalKidLispInstance.isEmbeddedContext = true; + + // Check if source contains timing expressions that need proper scheduling + const hasTimingExpressions = /\d+\.?\d*s(\.\.\.|!)?/.test(resolvedSource); + // Check if source contains scroll/zoom/flip that needs deferred execution + const hasScrollZoom = /\(\s*(scroll|zoom|flip)\s/.test(resolvedSource); + + // Only log occasionally to reduce console spam + + + if (hasTimingExpressions) { + // console.log(`🎯 Detected timing expressions in accumulation, preserving normal execution flow`); + // Don't force immediate execution for timing expressions + globalKidLispInstance.embeddedLayers = null; // Still clear embedded layers + } else if (hasScrollZoom) { + // console.log(`🎯 Detected scroll/zoom/flip in accumulation, allowing deferred execution`); + // Allow scroll/zoom/flip to use deferred execution but clear embedded layers + globalKidLispInstance.inEmbedPhase = false; // Allow deferring for scroll/zoom/flip + globalKidLispInstance.isNestedInstance = false; // Allow deferring for scroll/zoom/flip + globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers + } else { + // console.log(`🎯 No timing/scroll/zoom/flip in accumulation, forcing immediate execution`); + // Force immediate execution mode for simplified kidlisp() calls without timing or scroll/zoom/flip + globalKidLispInstance.inEmbedPhase = true; // Prevent deferring + globalKidLispInstance.isNestedInstance = true; // Enable immediate execution + globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers + } + + executeLispCode(resolvedSource, api, true); // true = accumulating on existing painting + + // Restore original state to avoid interfering with other KidLisp operations + globalKidLispInstance.inEmbedPhase = originalInEmbedPhase; + globalKidLispInstance.isNestedInstance = originalIsNestedInstance; + globalKidLispInstance.embeddedLayers = originalEmbeddedLayers; + globalKidLispInstance.isEmbeddedContext = originalIsEmbeddedContext; + + globalKidLispInstance.setAPI($activePaintApi); + }); + // Update the cache with the new accumulated state (unless noCache is true or animation content) + if (!noCache || !needsFreshExecution) { + globalKidLispInstance.persistentPaintings.set(regionKey, painting); + } + } + + // Paste the painting to the specified location (unless noPaste is set) + if (!noPaste && $activePaintApi.paste && painting) { + // console.log(`🎯 Pasting painting to (${x},${y})`); + $activePaintApi.paste(painting, x, y); + } + + return painting; + + } catch (error) { + console.error("🚫 Simple KidLisp error:", error); + + // Draw error directly to main screen (skip in projection/embed mode like kidlisp.com) + if (!projectionMode) { + const originalInk = $activePaintApi.ink(); + $activePaintApi.ink(255, 0, 0); + if ($activePaintApi.write) { + $activePaintApi.write("KidLisp Error", x, y); + } + $activePaintApi.ink(originalInk); + } + + return null; + } + }, + // 🎵 Update KidLisp audio globals (accessible to all pieces) + updateKidLispAudio: updateKidLispAudio, + // glaze: ... +}; + +// 🎨 Expose wipe function globally for KidLisp reframe operations +if (typeof globalThis !== "undefined") { + globalThis.$paintApiUnwrapped = $paintApiUnwrapped; + globalThis.wipe = $paintApiUnwrapped.wipe; +} + +// Helper function to execute KidLisp code +function executeLispCode(source, api, isAccumulating = false) { + try { + // Check if trace is enabled (set by kidlisp.com visualization) + const shouldTrace = $commonApi?.kidlispEnableTrace; + if (shouldTrace) { + enableKidlispTrace(); + clearExecutionTrace(); + } else { + disableKidlispTrace(); + } + + // Parse and evaluate the source directly without going through module lifecycle + // console.log(`🔍 Parsing KidLisp source:`, source); + + // Clear previous first-line color state for fresh detection + + globalKidLispInstance.firstLineColor = null; + + // Store source for error location lookup + globalKidLispInstance.currentSource = source; + + globalKidLispInstance.parse(source); + // console.log(`🔍 Generated AST:`, globalKidLispInstance.ast); + + if (globalKidLispInstance.ast) { + // Detect first-line color but only apply it if not accumulating + globalKidLispInstance.detectFirstLineColor(); + + if (globalKidLispInstance.firstLineColor && !isAccumulating) { + // console.log(`🎨 Applying first-line color background: ${globalKidLispInstance.firstLineColor}`); + api.wipe(globalKidLispInstance.firstLineColor); + } + + // Execute the parsed AST using the main evaluate method + // console.log(`🔍 Executing AST using main evaluate method...`); + // console.log(`🔍 AST contains:`, globalKidLispInstance.ast.map(expr => Array.isArray(expr) ? expr[0] : expr)); + + // Set up proper timing environment by ensuring clock and frameCount are available + if (!api.clock) { + api.clock = { time: () => new Date() }; + } + + // Increment frame count for timing expressions + if (typeof globalKidLispInstance.frameCount !== 'number') { + globalKidLispInstance.frameCount = 0; + } + globalKidLispInstance.frameCount++; + + // Normal KidLisp evaluation (dollar codes already resolved) + const result = globalKidLispInstance.evaluate(globalKidLispInstance.ast, api, globalKidLispInstance.localEnv); + // console.log(`🔍 Evaluation result:`, result); + + // Post execution trace if enabled (only on first frame to avoid spam) + if (shouldTrace && globalKidLispInstance.frameCount === 1) { + postExecutionTrace(); + } + } else { + // console.log(`⚠️ No AST generated from source`); + } + } catch (evalError) { + console.error("🚫 KidLisp evaluation error:", evalError); + // Draw error indicator (skip in projection/embed mode like kidlisp.com) + if (!projectionMode) { + api.wipe(60, 0, 0); + api.ink(255, 255, 255); + if (api.write) { + api.write("KidLisp Eval Error", 5, 15); + } + } + } +} + +// TODO: Eventually restructure this a bit. 2021.12.16.16.0 +// Should global state like color and transform be stored here? + +let $activePaintApi; + +let paintingAPIid = 0n; + +const twoDCommands = []; +graph.twoD(twoDCommands); // Set a global for passing 2d commands here. + +class Painting { + #layers = []; + #layer = 0; + api = {}; + inkrn; + + // panrn; // In order for this feature to work, translation needs to be stored outside of graph / captured differently? + + constructor() { + Object.assign(this.api, $paintApi); + const p = this; + + p.api.index = paintingAPIid; + paintingAPIid += 1n; + + p.inkrn = graph.c.slice(); // Init global state machine read-outs. + p.pagern = graph.getBuffer(); + // p.panrn = graph.getPan(); + + // Filter for and then wrap every rendering behavior of $paintApi into a + // system to be deferred in groups, using layer. + // ⛓️ This wrapper also makes the paint API chainable. + + function globals(k, args) { + if (k === "page") p.pagern = args[0]; + // TODO: 😅 Add other state globals like line thickness? 23.1.25 + } + + for (const k in $paintApiUnwrapped) { + if (typeof $paintApiUnwrapped[k] === "function") { + // Wrap and then transfer to #api. + p.api[k] = function () { + if (k === "ink") { + $paintApiUnwrapped[k](...arguments); + p.inkrn = graph.c.slice(); + } else { + globals(k, arguments); // Keep track of global state, like page, via `pagern`. + } + // Create layer if necessary. + if (notArray(p.#layers[p.#layer])) p.#layers[p.#layer] = []; + const callArgs = arguments; + // Add each deferred paint api function to the layer, to be run + // all at once in `paint` on each frame update. + p.#layers[p.#layer].push([ + k, + () => { + if (k === "ink") { + $paintApiUnwrapped[k](...callArgs); + p.inkrn = graph.c.slice(); + } else { + globals(k, callArgs); // Update globals again on chainable calls. + $paintApiUnwrapped[k](...callArgs); + } + }, + ]); + return p.api; + }; + } + } + + // Ensure critical graphics functions are available (fallback if wrapping fails) + if (!p.api.box && typeof graph.box === "function") { + p.api.box = function () { + const callArgs = arguments; + if (notArray(p.#layers[p.#layer])) p.#layers[p.#layer] = []; + p.#layers[p.#layer].push([ + "box", + () => graph.box(...callArgs), + ]); + return p.api; + }; + } + if (!p.api.line && typeof graph.line === "function") { + p.api.line = function () { + const callArgs = arguments; + if (notArray(p.#layers[p.#layer])) p.#layers[p.#layer] = []; + p.#layers[p.#layer].push([ + "line", + () => graph.line(...callArgs), + ]); + return p.api; + }; + } + if (!p.api.wipe && typeof $paintApiUnwrapped.wipe === "function") { + p.api.wipe = function () { + const callArgs = arguments; + if (notArray(p.#layers[p.#layer])) p.#layers[p.#layer] = []; + p.#layers[p.#layer].push([ + "wipe", + () => $paintApiUnwrapped.wipe(...callArgs), + ]); + return p.api; + }; + } + if (!p.api.ink && typeof $paintApiUnwrapped.ink === "function") { + p.api.ink = function () { + $paintApiUnwrapped.ink(...arguments); + p.inkrn = graph.c.slice(); + return p.api; + }; + } + + // Allows grouping & composing painting order using an AofA (Array of Arrays). + // n: 0-n (Cannot be negative.) + // fun: A callback that contains $paintApi commands or any other code. + this.api.layer = function (n) { + p.#layer = n; + // TODO: ❤️‍🔥 Current layer needs to be set on each API state...! + return p.api; + }; + + // Creates a new pixel buffer with its own layering wrapper / context + // on top of the base painting API. + this.api.painting = function painting() { + const oldActivePaintApi = $activePaintApi; + const painting = new Painting(); + $activePaintApi = painting.api; + // Mock out the screen here using the arguments. + $activePaintApi.screen = { + width: arguments[0], + height: arguments[1], + // pix gets added in the makeBuffer... + }; + const pix = graph.makeBuffer(...arguments, painting, $activePaintApi); + $activePaintApi = oldActivePaintApi; + return pix; + }; + + this.api.pixel = function () { + return graph.pixel(...arguments); + }; + + this.api.inkrn = () => this.inkrn; // Return current ink color. + // this.api.panrn = graph.getPan; // Return current panTranslation. + this.api.pagern = () => this.pagern; // Return current page buffer. + + // This links to abstract, solitary graph functions that do not need + // to be wrapped or deferred for rendering. + // TODO: Maybe these functions should be under a graphics algorithms label? + this.api.abstract = { bresenham: graph.bresenham }; + } + + // Paints every layer. + //async paint(immediate = false) { + paint(immediate = false) { + for (let layer of this.#layers) { + layer ||= []; // Evaporate empty layers. + for (const paint of layer) { + // if (immediate) { + // console.log("Label:", paint[0]); + paint[1](); + // } else { + // await paint(); + // } + } + } + this.#layers.length = 0; + this.#layer = 0; + } +} + +const painting = new Painting(); + +let glazeAfterReframe; + +// *** Resolution *** +// Accepts width, height and gap either as numbers or as +// an object with those keys. +// +// Usage: resolution(64); +// resolution(320, 240); +// resolution(display); // "display" is a global object whose width +// and height matches the hardware display +// hosting aesthetic.computer. +let lastGap = 8; +$commonApi.resolution = function (width, height = width, gap = 8) { + if (typeof width === "object") { + const props = width; + height = props.height; + width = props.width || props.height; + gap = props.gap === 0 ? 0 : props.gap || 8; + } + + if (typeof width === "number" && typeof height === "number") { + width = round(width); + height = round(height); + } + + // Don't do anything if there is no change and no gap update. + if (screen.width === width && screen.height === height && gap === lastGap) + return; + + lastGap = gap; + + // width = width || currentDisplay.innerWidth; + // height = height || currentDisplay.innerHeight; + + // TODO: Paint anything that needs to be painted before resizing... + // TODO: Does this even work right now? + painting.paint(); + + if (width === undefined && height === undefined) { + // 1. Generate a new width and height. + width = round(currentDisplay.width / currentDisplay.subdivisions); + height = round(currentDisplay.height / currentDisplay.subdivisions); + // Build a reframe request that will be sent to the main thread, mirroring this. + reframe = { + width: undefined, + height: undefined, + gap, + }; + } else { + // 2. Manually set the width and height. + reframe = { width, height, gap }; + } + + // console.log( + // "🖼 Reframe to:", + // width, + // height, + // "from", + // screen.width, + // screen.height, + // ); + + // 3. Assign the generated or manual width and height. + const oldScreen = { + width: screen.width, + height: screen.height, + pixels: screen.pixels, + }; + + screen.width = width; + screen.height = height; + + // Reset / recreate the depth buffer. (This is only used for the 3D software renderer in `graph`) + graph.depthBuffer.length = screen.width * screen.height; + graph.depthBuffer.fill(Number.MAX_VALUE); + graph.writeBuffer.length = 0; //screen.width * screen.height; + // graph.writeBuffer.fill(Number.MAX_VALUE); + + screen.pixels = new Uint8ClampedArray(screen.width * screen.height * 4); + screen.pixels.fill(255); + + graph.setBuffer(screen); + graph.paste({ + painting: oldScreen, + crop: new geo.Box(0, 0, oldScreen.width, oldScreen.height), + }); + + // 🎨 Fill any new pixels with background color after screen expansion + if (width > oldScreen.width || height > oldScreen.height) { + const persistentColor = getPersistentFirstLineColor(); + console.log("🎨 Resolution function: Screen expansion detected"); + console.log("🎨 Resolution function: Old screen:", oldScreen.width, "x", oldScreen.height); + console.log("🎨 Resolution function: New screen:", width, "x", height); + console.log("🎨 Resolution function: Persistent color found:", persistentColor); + + let fillSpec = resolveBackgroundFillSpec(persistentColor); + if (!fillSpec && typeof globalKidLispInstance?.getBackgroundFillColor === "function") { + const fallbackColor = globalKidLispInstance.getBackgroundFillColor(); + console.log("🎨 Post-reframe: Fallback background candidate:", fallbackColor); + fillSpec = resolveBackgroundFillSpec(fallbackColor); + } + + if (fillSpec) { + if (fillSpec.type === "fade") { + console.log("🎨 Post-reframe: Applying fade background to expansions"); + fillExpandedWithFadePixels( + screen, + width, + height, + oldScreen.width, + oldScreen.height, + fillSpec.fadeInfo, + ); + } else { + console.log("🎨 Post-reframe: Filling expansions with solid color", fillSpec.rgba); + fillExpandedWithSolidPixels( + screen, + width, + height, + oldScreen.width, + oldScreen.height, + fillSpec.rgba, + ); + } + } else { + console.log("🎨 Post-reframe: No background fill spec available for expansion"); + } + } else { + console.log("🎨 Resolution function: No screen expansion needed"); + } +}; + +// Add new content to the DOM. +// (Requires `send`) +class Content { + nodes = []; + #id = 0; + + constructor() {} + + // Make a request to add new content to the DOM. + add(content) { + // if (debug) console.log("📃 Adding content:", content); + this.nodes.push({ id: this.#id }); + this.#id = this.nodes.length - 1; + send({ type: "content-create", content: { id: this.#id, content } }); + return this.nodes[this.nodes.length - 1]; + } + + remove() { + send({ type: "content-remove" }); + this.nodes = []; + this.#id = 0; + } + + receive({ id, response }) { + this.nodes[id].response = response; + } + + //update({ id, msg }) { + // send({ type: "content-update", content: { id, msg } }); + //} +} + +// 🔈 Sound + +// Microphone State (Audio Input) +class Microphone { + amplitude = 0; + waveform = []; + pitch = 0; + connected = false; // Flips to true on a callback message from `bios`. + recording = false; + recordingPromise; + permission = ""; + permissionPending = false; + permissionChecked = false; + recordingBuffer = null; // Live recording buffer for preview + + requestPermission() { + if (this.permissionPending || this.permissionChecked) return this; + this.permissionPending = true; + send({ type: "microphone-permission-request" }); + return this; + } + + // Note: can send `{monitor: true}` in `options` for audio feedback. + connect(options) { + send({ type: "microphone", content: options }); + return this; + } + + disconnect() { + send({ type: "microphone", content: { detach: true } }); + } + + poll() { + send({ type: "get-microphone-amplitude" }); + send({ type: "get-microphone-waveform" }); + send({ type: "get-microphone-pitch" }); + if (this.recording) { + send({ type: "get-microphone-recording-buffer" }); + } + } + + // Start recording. + rec() { + this.recording = true; + send({ type: "microphone-record" }); + } + + // Stop recording. + cut() { + const prom = new Promise((resolve, reject) => { + this.recordingPromise = { resolve, reject }; + }); + send({ type: "microphone-cut" }); + this.recording = false; + return prom; + } +} + +class Speaker { + waveforms = { left: [], right: [] }; + amplitudes = { left: [], right: [] }; + frequencies = { left: [], right: [] }; + beat = { detected: false, strength: 0, timestamp: 0 }; // Add beat detection data + + poll() { + send({ type: "get-waveforms" }); + send({ type: "get-amplitudes" }); + send({ type: "get-frequencies" }); + } +} + +let sound, + // soundClear, // Used by receivedBeat and defined in first frame update. + soundId = 0n, // Increment each sound / give it an id in the `bios`. + soundTime; // Used by `$sound.synth` for global timing. + +sound = { + bpm: undefined, + sounds: [], + bubbles: [], + farts: [], + kills: [], +}; + +const speaker = new Speaker(); +const microphone = new Microphone(); + +// 🎮 Game Boy Emulator API +const gameboy = { + frame: null, // Current frame data (Uint8ClampedArray) + width: 160, // Game Boy screen width + height: 144, // Game Boy screen height + romName: null, // Currently loaded ROM name + isPlaying: false // Whether emulator is running +}; + +// Resolve a user code (e.g. "ac25namuc") to a handle via the permahandle API. +// Returns the handle string (e.g. "jeffrey") or null if not a user code / not found. +async function resolveUserCode(slug) { + if (!slug || slug.length !== 9 || !slug.startsWith("ac")) return null; + if (!/^ac[0-9]{2}[a-z]{5}$/.test(slug)) return null; + try { + const res = await fetch(`/api/permahandle/${slug}`); + if (!res.ok) return null; + const data = await res.json(); + return data.handle || null; + } catch { + return null; + } +} + +// 2. ✔ Loading the disk. +let originalHost; +let firstLoad = true; + +let notice, noticeTimer, noticeColor, noticeOpts; // Renders a full-screen notice on piece-load if present. +let overlay2D = null; // A per-frame vector display-list drawn on uiCtx (api.overlay). + +async function load( + parsed, // If parsed is not an object, then assume it's source code. + fromHistory = false, + alias = false, + devReload = false, + loadedCallback, + forceKidlisp = false, // Force interpretation as kidlisp even without prefix + forceP5 = false, // Force interpretation as a p5.js sketch (dropped .js files) +) { + const loadFunctionStartTime = performance.now(); + diskTimings.loadStarted = Math.round(loadFunctionStartTime - diskTimingStart); + // Silent: load() started + + let fullUrl, source; + let params, + search, + colon, + hash, + path, + host = originalHost, + text, + slug, + clockShortcode = null; // Track original *code for URL display + + // 🕷️ SPIDER MODE: Always log what piece we're loading + if (typeof window !== 'undefined' && window.acSPIDER) { + console.log("🕷️ SPIDER: Starting load() with:", { + parsed, + fromHistory, + alias, + devReload, + forceKidlisp + }); + } + + // Check for stuck loading state before proceeding + checkLoadingTimeout(); + + if (loading === false) { + loading = true; + loadingStartTime = Date.now(); + } else { + // TODO: If the piece is different, then there should be a way to abort + // or ignore a current load. + console.warn( + "Coudn't load:", + parsed.path || parsed.name, + "(Already loading.)", + ); + return true; + } + + // Reload a previously sideloaded piece on subsequent loads. + if ( + !parsed.source && + store["publishable-piece"] && + parsed.piece === store["publishable-piece"].slug + ) { + parsed.source = store["publishable-piece"].source; + parsed.name = store["publishable-piece"].slug; + parsed.ext = store["publishable-piece"].ext; + } + + // 🕸️ Loading over the network from a parsed path object with no source code. + if (!parsed.source) { + params = parsed.params; + path = parsed.path; + search = parsed.search; + colon = parsed.colon; + hash = parsed.hash; + host = parsed.host; + slug = parsed.text; + + if (typeof window !== 'undefined' && window.acSPIDER) { + console.log("🕷️ SPIDER: Parsed slug/path:", { + slug, + path, + parsedText: parsed.text, + parsedPath: parsed.path, + hash, + params + }); + } + + // 📡 A piece published under a handle owns the code channel named after + // its own route. `/run` accepts a push there only from that handle's token, + // so the name is free to be public — which is what lets the address someone + // scanned be the channel too. Loading @handle/piece is therefore enough to + // start watching it live, and the session server replays the last source to + // a late joiner, so a phone arriving mid-session gets the current piece + // without waiting for the next save. + // + // Subscribed alongside this browser's own channel rather than replacing it, + // and deliberately never persisted: this is someone else's piece, not this + // browser's identity. + const routeChannel = (parsed.piece || slug || "").split(":")[0]; + if (routeChannel.startsWith("@") && routeChannel.indexOf("/") !== -1) { + const owned = routeChannel.slice(1); + if (owned !== pieceCodeChannel) { + pieceCodeChannel = owned; + socket?.send("code-channel:sub", pieceCodeChannel); + } + // 🩺 Report on this piece to whoever owns it. Scoped to the piece's own + // channel, which only that handle's token can listen to — a piece being + // authored is the only thing that ever gets reported on. + diagnostics.channel(pieceCodeChannel); + } else { + pieceCodeChannel = undefined; + diagnostics.channel(""); + } + + // 👱 Route to the `profile` piece if we are just hitting an empty + // username. + if (slug.startsWith("@") && slug.indexOf("/") === -1) { + params = [slug, ...params]; // Rewrite all params for `@user` slug urls. + //slug = "profile"; // Go to `profile` instead of the `@user`. + const hiddenSlug = "profile"; + // Rewrite path to `profile`. + console.log("Profile Path:", path); + path = [...path.split("/").slice(0, -1), hiddenSlug].join("/"); + } + + // ⏰ Normalize *clock shortcodes to the clock piece and let clock.mjs fetch + // the stored melody by code. This avoids intercepting later in the loader + // and ensures the correct module is fetched. + if (slug && slug.startsWith("*") && slug.length > 1) { + clockShortcode = slug; // Preserve original *code for URL + const cacheId = slug.slice(1); + const clockParam = `*${cacheId}`; + params = [clockParam, ...(params || [])]; + slug = "clock"; + path = "aesthetic.computer/disks/clock"; + parsed.text = slug; + parsed.path = path; + parsed.params = params; + } + + // if (debug) console.log(debug ? "🟡 Development" : "🟢 Production"); + if (host === "") host = originalHost; + loadFailure = undefined; + host = host.replace(/\/$/, ""); // Remove any trailing slash from host. + // Note: This fixes a preview bug on teia.art. 2022.04.07.03.00 if (path === "") path = ROOT_PIECE; // Set bare path to what "/" maps to. + // if (path === firstPiece && params.length === 0) params = firstParams; + + // Check if the path already has a language extension and use it directly, otherwise default to .mjs + // Handle sandboxed environments where location.protocol might be "null:" + // For aesthetic.computer pieces, determine the correct server + const { protocol, hostname } = getSafeUrlParts(); + + // If we're loading an aesthetic.computer piece, choose the appropriate server + let baseUrl; + if (path.startsWith('aesthetic.computer/')) { + // Check if we're in OBJKT mode - use local bundled files + if (typeof window !== 'undefined' && window.acSPIDER) console.log("🕷️ getPackMode():", getPackMode(), "window.acPACK_MODE:", window.acPACK_MODE, "checkPackMode():", checkPackMode()); + if (getPackMode()) { + // In OBJKT mode, use relative paths to load bundled pieces + baseUrl = "."; + } else { + // Check if we're in a development environment (localhost with port) + baseUrl = getBuiltInPieceBaseUrl(); + } + } else { + baseUrl = `${protocol}//${hostname}`; + } + + if (typeof window !== 'undefined' && window.acSPIDER) console.log("�️ SPIDER MODE Debug:", { protocol, hostname, baseUrl, isSandboxed: isSandboxed(), path, isDevelopment: hostname === 'localhost' && typeof location !== 'undefined' && location.port }); + + // Check if path already includes the hostname to avoid double paths + let resolvedPath = path; + if (getPackMode() && path.startsWith('aesthetic.computer/')) { + // In OBJKT mode, keep the full path since files are bundled with directory structure + resolvedPath = path; + } else if (baseUrl === 'https://aesthetic.computer' && path.startsWith('aesthetic.computer/')) { + // Strip "aesthetic.computer/" prefix when using production domain to avoid doubling + resolvedPath = path.substring('aesthetic.computer/'.length); + } + + // if (debug) console.log("🔍 Debug path resolution:", { originalPath: path, resolvedPath, hostname, baseUrl }); + + if (path.endsWith('.lisp') || path.endsWith('.lua')) { + if (getPackMode()) { + // In OBJKT mode, use absolute path from iframe origin + fullUrl = "/" + resolvedPath + "?v=" + Date.now(); + } else { + fullUrl = baseUrl + "/" + resolvedPath + "?v=" + Date.now(); + } + } else { + if (getPackMode()) { + // In OBJKT mode, navigate up from lib directory to aesthetic.computer root, then to target + const relativePath = resolvedPath.startsWith('aesthetic.computer/') + ? resolvedPath.substring('aesthetic.computer/'.length) + : resolvedPath; + fullUrl = "../" + relativePath + ".mjs" + "?v=" + Date.now(); + } else { + fullUrl = baseUrl + "/" + resolvedPath + ".mjs" + "?v=" + Date.now(); + } + } + // The query param `v` busts the cache so that the environment is + // reset if a disk is re-entered while the system is running. + // Note: Hash fragments (#) don't bust browser cache, only query strings (?) do. + // if (debug) console.log("🕸", fullUrl); + } else { + // 📃 Loading with provided source code. + // This could either be JavaScript or LISP. + + if ( + devReload === true && + parsed.codeChannel && + parsed.codeChannel !== codeChannel && + parsed.codeChannel !== pieceCodeChannel + ) { + console.warn( + "🙅 Not reloading, code channel invalid:", + codeChannel || "N/A", + ); + return; + } + // console.log("📃 Loading from source:", JSON.stringify(parsed)); + // console.log("📃 Source content to run:", JSON.stringify(parsed.source)); + source = parsed.source; + params = parsed.params; + search = parsed.search; + colon = parsed.colon || []; + hash = parsed.hash; // tood: these probably don't work? 24.07.09.23.46 + host = parsed.host; + slug = parsed.name; // not 'text' for this. + + // ⏰ Normalize *clock shortcodes for source-based loads too. + if (slug && slug.startsWith("*") && slug.length > 1) { + clockShortcode = slug; // Preserve original *code for URL + const cacheId = slug.slice(1); + const clockParam = `*${cacheId}`; + params = [clockParam, ...(params || [])]; + slug = "clock"; + if (slug !== "(...)" && !path) path = "aesthetic.computer/disks/clock"; + parsed.name = slug; + parsed.path = path || "aesthetic.computer/disks/clock"; + parsed.params = params; + } + + // 🔍 Enable tracing for kidlisp.com visualization if requested + if (parsed.enableTrace) { + enableKidlispTrace(); + } else { + disableKidlispTrace(); + } + + if (slug !== "(...)") path = parsed.path; //"aesthetic.computer/disks/" + slug; + // 📓 Might need to fill in hash, path, or slug here. 23.06.24.18.49 + } + + let prefetches; // Will be acted on after `hotSwap`. + + // 🅱️ Load the piece. + + // const moduleLoadTime = performance.now(); + + let blobUrl, sourceCode, originalCode; + try { + // If this is a reload (with no source change) then just create a new + // blobURL off the old source. + // TODO: Cache piece code locally / in an intelligent way, + // and then receive socket updates when it changes on the server? + if ( + slug?.split("~")[0] === currentText?.split("~")[0] && + sourceCode == currentCode && + !devReload + ) { + const blob = new Blob([currentCode], { type: "application/javascript" }); + blobUrl = URL.createObjectURL(blob); + sourceCode = currentCode; + originalCode = sourceCode; + } else { + let sourceToRun; + let fetchStartTime = performance.now(); // Initialize timing at the start + + // 💾 Check if this is a cached kidlisp code (starts with $ followed by a cache ID) + if (slug && slug.startsWith("$") && slug.length > 1) { + const cacheId = slug.slice(1).split(":")[0]; // Remove $ prefix and any :fps colon params + + // Clear author/hits immediately to prevent stale data showing during load + currentHUDAuthor = null; + currentHUDHits = null; + + // First check if we have this in objktKidlispCodes (offline bundle) + const globalScope = (function () { + if (typeof window !== 'undefined') return window; + if (typeof globalThis !== 'undefined') return globalThis; + if (typeof global !== 'undefined') return global; + if (typeof self !== 'undefined') return self; + return {}; + })(); + + if (globalScope.objktKidlispCodes && globalScope.objktKidlispCodes[cacheId]) { + sourceToRun = globalScope.objktKidlispCodes[cacheId]; + currentOriginalCodeId = slug; + currentHUDAuthor = null; // No author info in offline bundles + currentHUDHits = null; + } else { + console.log("💾 Loading cached kidlisp code:", cacheId); + try { + console.log("🔍 Fetching cached code from API..."); + sourceToRun = await getCachedCodeMultiLevel(cacheId); + if (!sourceToRun) { + throw new Error(`Cached code not found: ${cacheId}`); + } + // Track the original $code identifier for sharing + currentOriginalCodeId = slug; // Keep the full $code format + console.log("✅ Successfully loaded cached code:", cacheId, `(${sourceToRun.length} chars)`); + + // 👤 Fetch author metadata in background (for logging only) + fetchKidlispMetadata(cacheId).then(meta => { + if (meta) { + // Note: author/hits are fetched but not displayed for KidLisp pieces + console.log(`👤 Author: ${meta.handle || 'anonymous'}, Hits: ${meta.hits}`); + } + }).catch(err => { + console.warn("⚠️ Failed to fetch KidLisp metadata:", err); + }); + } catch (error) { + console.error("❌ Failed to load cached code:", cacheId, error); + throw new Error(`Failed to load cached code: ${cacheId}`); + } + } + } else if (fullUrl) { + // 🎄 Check if piece code is cached (for merry preloading) + const pieceSlug = slug?.split("~")[0]; + const cachedPiece = pieceSlug ? pieceCodeCache.get(pieceSlug) : null; + + if (cachedPiece) { + console.log(`🎄 Using cached code for: ${pieceSlug}`); + sourceToRun = cachedPiece.code; + // Skip fetch, use cached code directly + } else { + // In OBJKT mode, try direct import first for .mjs files to avoid CSP issues + // Extract the filename from URL, handling ./ prefix and hash fragments + const urlWithoutHash = fullUrl.split('#')[0]; + const filename = urlWithoutHash.split('/').pop(); + + if (getPackMode() && filename.endsWith('.mjs')) { + // In OBJKT mode, skip dynamic import and use fetch directly since files are bundled locally + // Will proceed to fetch() below + } + + let response; + if (logs.loading) console.log("📥 Loading from url:", fullUrl); + // if (debug) console.log("🔍 Debug: Constructed fullUrl:", fullUrl); + + // Notify boot progress + const fetchStartTime = performance.now(); + send({ + type: "boot-log", + content: `fetching: ${path}` + }); + + // Use cache: 'no-store' to force fresh fetch, especially important for LAN IP access + // Retry on network failure (bad connections drop fetches) + { + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + response = await fetch(fullUrl, { cache: 'no-store' }); + break; + } catch (fetchErr) { + if (attempt >= maxAttempts) throw fetchErr; + console.warn(`🔄 Piece fetch attempt ${attempt}/${maxAttempts} failed, retrying in ${attempt}s...`); + await new Promise(r => setTimeout(r, 1000 * attempt)); + } + } + } + const fetchEndTime = performance.now(); + diskTimings.fetchComplete = Math.round(fetchEndTime - diskTimingStart); + // Silent: fetch completed + if (response.status === 404 || response.status === 403) { + const extension = path.endsWith('.lisp') + ? '.lisp' + : path.endsWith('.lua') + ? '.lua' + : '.mjs'; + // Handle sandboxed environments for anon URL construction + const { protocol } = getSafeUrlParts(); + const anonUrl = + protocol + + "//" + + "art.aesthetic.computer" + + "/" + + path.split("/").pop() + + extension + + "#" + + Date.now(); + if (logs.loading) + console.log("🧑‍🤝‍🧑 Attempting to load piece from anon url:", anonUrl); + response = await fetch(anonUrl, { cache: 'no-store' }); + if (response.status === 404 || response.status === 403) + throw new Error(response.status); + } + sourceToRun = await response.text(); + } + } else { + sourceToRun = source; + } + + // 🔥 Idea + // One should be able to drag a piece in, then be able to load the piece + // go back to the prompt, and return to it and it should still load + // the modded code! // Then refresh should be able to function as well? + // ⚠️ Detect if we are running `kidlisp` or JavaScript syntax. + // Note: This may not be the most reliable way to detect `kidlisp`? + // 🚗 Needs to know if the source was from a prompt with a lisp module. + // console.log("🔍 Checking if kidlisp source:", JSON.stringify(sourceToRun)); + if ( + sourceToRun.startsWith("(") || + sourceToRun.startsWith(";") || + forceKidlisp || + slug === "(...)" || + path === "(...)" || + (path && path.endsWith(".lisp")) || + (slug && slug.startsWith("$") && slug.length > 1) || // Cached codes are always kidlisp + // Pack mode KidLisp pieces - but NOT if acPACK_COLOPHON explicitly says isKidLisp: false + (typeof window !== 'undefined' && window.acPACK_PIECE && slug === window.acPACK_PIECE && + window.acPACK_COLOPHON?.piece?.isKidLisp !== false) + ) { + // Only use basic detection, not the broader isKidlispSource function + // which can incorrectly detect JavaScript as kidlisp, unless forceKidlisp is true + // or this came from parse function as kidlisp (slug/path === "(...)") + // Assume lisp. + // console.log( + // "🐍 Lisp piece detected (slug:", + // slug, + // "path:", + // path, + // "forceKidlisp:", + // forceKidlisp, + // ")", + // ); + sourceCode = sourceToRun; + originalCode = sourceCode; + + // 🔒 KidLisp pieces are always restricted (safe by design) + pieceMetadata = { + code: slug || "kidlisp", + trustLevel: "kidlisp", + anonymous: true + }; + + const isPackMode = + (typeof window !== "undefined" && window.acPACK_MODE) || + (typeof globalThis !== "undefined" && globalThis.acPACK_MODE); + if (!isPackMode) { + log.lisp.log("Initializing KidLisp piece..."); + } + + // Notify boot progress + const compileStartTime = performance.now(); + const fetchElapsed = Math.round(compileStartTime - fetchStartTime); + // console.log("📢 Disk sending boot-log: compiling kidlisp"); + // Ensure send is available before using it + if (typeof send === 'function') { + send({ + type: "boot-log", + content: `compiling kidlisp (fetch: ${fetchElapsed}ms)` + }); + } else { + log.lisp.warn("send function not available for boot-log"); + } + + // Initialize persistent cache for $codes (only needs to be done once) + initPersistentCache(store); + + // Initialize persistent image cache for paste/stamp (only needs to be done once) + imageCache.init(store); + + loadedModule = lisp.module(sourceToRun, path && path.endsWith(".lisp")); + + const compileEndTime = performance.now(); + const compileElapsed = Math.round(compileEndTime - compileStartTime); + diskTimings.compileComplete = Math.round(compileEndTime - diskTimingStart); + // Silent: KidLisp compiled + if (!isPackMode) { + log.lisp.success(`KidLisp module loaded (${compileElapsed}ms)`); + } + + // Notify boot progress + // console.log("📢 Disk sending boot-log: kidlisp compiled"); + send({ + type: "boot-log", + content: `kidlisp compiled (${compileElapsed}ms)` + }); + + // Send source file to boot canvas for display + send({ + type: "boot-file", + content: { filename: path, source: sourceCode.slice(0, 8000) } + }); + + if (devReload) { + store["publishable-piece"] = { + slug, + source: sourceToRun, + ext: "lisp", + }; + if (logs.loading) + console.log("💌 Publishable:", store["publishable-piece"]); + } + } else if ( + forceP5 || + parsed?.ext === "js" || + (path && path.endsWith(".js")) + ) { + // 🎨 p5.js sketch (dropped .js file) — run real p5 in this worker + // against an OffscreenCanvas blitted into screen.pixels (Option B), + // mirroring the URL fallback path for .js pieces. + sourceCode = sourceToRun; + originalCode = sourceCode; + pieceMetadata = { + code: slug || "p5", + trustLevel: "p5", + anonymous: true, + }; + + const compileStartTime = performance.now(); + const fetchElapsed = Math.round(compileStartTime - fetchStartTime); + send({ + type: "boot-log", + content: `compiling p5 (fetch: ${fetchElapsed}ms)`, + }); + + loadedModule = await makeP5WorkerModule({ slug, source: sourceToRun }); + + const compileEndTime = performance.now(); + const compileElapsed = Math.round(compileEndTime - compileStartTime); + diskTimings.compileComplete = Math.round(compileEndTime - diskTimingStart); + + send({ + type: "boot-log", + content: `p5 compiled (${compileElapsed}ms)`, + }); + + send({ + type: "boot-file", + content: { filename: path, source: sourceCode.slice(0, 8000) }, + }); + + if (devReload) { + store["publishable-piece"] = { + slug, + source: sourceToRun, + ext: "js", + }; + if (logs.loading) + console.log("💌 Publishable:", store["publishable-piece"]); + } + } else if ( + parsed?.ext === "lua" || + (path && path.endsWith(".lua")) || + (fullUrl && fullUrl.includes(".lua")) || + (sourceToRun.trim().startsWith("--") && + /(?:^|\n)\s*function\s+(setup|draw)\s*\(/.test(sourceToRun)) + ) { + sourceCode = sourceToRun; + originalCode = sourceCode; + pieceMetadata = { + code: slug || "l5", + trustLevel: "l5", + anonymous: true, + }; + + const compileStartTime = performance.now(); + const fetchElapsed = Math.round(compileStartTime - fetchStartTime); + send({ + type: "boot-log", + content: `compiling lua (fetch: ${fetchElapsed}ms)` + }); + + loadedModule = await l5.module(sourceToRun); + + const compileEndTime = performance.now(); + const compileElapsed = Math.round(compileEndTime - compileStartTime); + diskTimings.compileComplete = Math.round(compileEndTime - diskTimingStart); + + send({ + type: "boot-log", + content: `lua compiled (${compileElapsed}ms)` + }); + + send({ + type: "boot-file", + content: { filename: path, source: sourceCode.slice(0, 8000) } + }); + + if (devReload) { + store["publishable-piece"] = { + slug, + source: sourceToRun, + ext: "lua", + }; + if (logs.loading) + console.log("💌 Publishable:", store["publishable-piece"]); + } + } else { + if (devReload) { + store["publishable-piece"] = { slug, source: sourceToRun }; + if (logs.loading) + console.log("💌 Publishable:", store["publishable-piece"].slug); + } + + // Initialize persistent image cache for paste/stamp (also for .mjs pieces) + imageCache.init(store); + + // 🔒 Determine piece trust level + // Built-in disk pieces (aesthetic.computer/disks/*) are always trusted + const isBuiltInDisk = path && path.includes("aesthetic.computer/disks/"); + if (isBuiltInDisk) { + pieceMetadata = { code: slug || "system", trustLevel: "trusted", anonymous: false }; + } else if (slug && !devReload) { + try { + // Clear permissions from previous piece + if (pieceMetadata?.code) { + clearPermissions(pieceMetadata.code); + } + + pieceMetadata = await fetchPieceMetadata(slug); + if (logs.loading) { + console.log(`🔒 Piece metadata:`, { + code: pieceMetadata.code, + trustLevel: pieceMetadata.trustLevel, + anonymous: pieceMetadata.anonymous + }); + } + } catch (err) { + console.warn(`⚠️ Failed to fetch piece metadata, defaulting to untrusted:`, err); + pieceMetadata = { code: slug, trustLevel: "untrusted", anonymous: true }; + } + } else { + // Dev reload or no slug - default to untrusted + pieceMetadata = { code: slug || "unknown", trustLevel: "untrusted", anonymous: true }; + } + + originalCode = sourceToRun; + const updatedCode = updateCode(sourceToRun, host, debug); + + prefetches = updatedCode + .match(/"(@\w[\w.]*\/[^"]*)"/g) + ?.map((match) => match.slice(1, -1)); // for "@name/code". + + const blob = new Blob([updatedCode], { + type: "application/javascript", + }); + + blobUrl = URL.createObjectURL(blob); + + // Notify boot progress + const importStartTime = performance.now(); + const importFetchElapsed = Math.round(importStartTime - fetchStartTime); + // console.log("📢 Disk sending boot-log: importing module"); + send({ + type: "boot-log", + content: `importing module (fetch: ${importFetchElapsed}ms)` + }); + + sourceCode = updatedCode; + // Retry dynamic import on network failures (sub-dependency fetches can fail on bad connections) + { + const maxImportAttempts = 3; + for (let attempt = 1; attempt <= maxImportAttempts; attempt++) { + try { + loadedModule = await import(blobUrl); + break; + } catch (importErr) { + const isNetworkError = importErr.toString().includes("Failed to fetch"); + if (attempt < maxImportAttempts && isNetworkError) { + console.warn(`🔄 Module import attempt ${attempt}/${maxImportAttempts} failed, retrying in ${1.5 * attempt}s...`); + URL.revokeObjectURL(blobUrl); + blobUrl = URL.createObjectURL(new Blob([updatedCode], { type: "application/javascript" })); + await new Promise(r => setTimeout(r, 1500 * attempt)); + } else { + throw importErr; + } + } + } + } + + const importEndTime = performance.now(); + const importElapsed = Math.round(importEndTime - importStartTime); + diskTimings.compileComplete = Math.round(importEndTime - diskTimingStart); + // Silent: JS module imported + if (logs.loading) console.log(`✅ Module imported (${importElapsed}ms)`); + send({ + type: "boot-log", + content: `module imported (${importElapsed}ms)` + }); + + // Send source file to boot canvas for display + send({ + type: "boot-file", + content: { filename: path, source: sourceCode.slice(0, 8000) } // Limit size + }); + } + } + } catch (err) { + const moduleLoadErrorTime = performance.now(); + // console.log(`⏰ Module load error caught at ${moduleLoadErrorTime}ms`); + console.log("🟡 Error loading mjs module:", err); + + // Determine if this was a fetch failure (404/403) vs a module import/compile error. + // Only try language fallbacks if this was a missing .mjs path. + const isFetchError = err.message === "404" || err.message === "403"; + const isNetworkImportError = err.toString().includes("Failed to fetch dynamically imported module"); + const isModuleImportError = isNetworkImportError || + err.toString().includes("Unexpected token") || + err.toString().includes("SyntaxError") || + err.toString().includes("Cannot use import"); + + // If the .mjs file was fetched but failed to import (JS error), don't try fallbacks. + if (fullUrl && !fullUrl.includes('.lisp') && !fullUrl.includes('.lua') && !fullUrl.includes('.js?') && isFetchError && !isModuleImportError) { + try { + const fallbackExts = [".js", ".lua", ".lisp"]; + let loadedFromFallback = false; + let fallbackError = null; + + for (const fallbackExt of fallbackExts) { + let fallbackUrl = fullUrl.replace(".mjs", fallbackExt); + if (logs.loading) { + console.log(`📥 Loading ${fallbackExt.slice(1)} from url:`, fallbackUrl); + } + + let response = await fetch(fallbackUrl, { cache: 'no-store' }); + let resolvedUrl = fallbackUrl; + + if (response.status === 404 || response.status === 403) { + // Handle sandboxed environments for anon URL construction + const { protocol } = getSafeUrlParts(); + const anonUrl = + protocol + + "//" + + "art.aesthetic.computer" + + "/" + + path.split("/").pop() + + fallbackExt + + "#" + + Date.now(); + if (logs.loading) { + console.log("🧑‍🤝‍🧑 Attempting to load piece from anon url:", anonUrl); + } + response = await fetch(anonUrl, { cache: 'no-store' }); + + if (response.status === 404 || response.status === 403) { + fallbackError = new Error(response.status); + continue; + } + resolvedUrl = anonUrl; + } + + sourceCode = await response.text(); + originalCode = sourceCode; + + if (fallbackExt === ".js") { + // Plain JavaScript / p5-style sketch — run real p5 in this worker + // against an OffscreenCanvas blitted into screen.pixels (Option B). + pieceMetadata = { + code: slug || "p5", + trustLevel: "p5", + anonymous: true, + }; + loadedModule = await makeP5WorkerModule({ slug, source: sourceCode }); + if (devReload) { + store["publishable-piece"] = { slug, source: sourceCode, ext: "js" }; + } + } else if (fallbackExt === ".lua") { + pieceMetadata = { + code: slug || "l5", + trustLevel: "l5", + anonymous: true, + }; + loadedModule = await l5.module(sourceCode); + if (devReload) { + store["publishable-piece"] = { slug, source: sourceCode, ext: "lua" }; + } + } else { + pieceMetadata = { + code: slug || "kidlisp", + trustLevel: "kidlisp", + anonymous: true + }; + loadedModule = lisp.module(sourceCode, true); + if (devReload) { + store["publishable-piece"] = { slug, source: sourceCode, ext: "lisp" }; + } + } + + loadedFromFallback = true; + break; + } + + if (!loadedFromFallback) { + throw fallbackError || new Error("404"); + } + + if (devReload && logs.loading) { + console.log("💌 Publishable:", store["publishable-piece"]); + } + } catch (err) { + // Continue with current module if one has already loaded. + console.error( + `😡 "${path}" load failure:`, + err, + "💾 First load:", + firstLoad, + ); + loadFailure = err; + $commonApi.net.loadFailureText = err.message + "\n" + sourceCode; + loading = false; + + // Only return a 404 if the error type is correct. + if (firstLoad && (err.message === "404" || err.message === "403")) { + // Check if slug is a user code (e.g. "ac25namuc") and redirect to their profile. + const handle = await resolveUserCode(slug); + if (handle) { + $commonApi.jump(`@${handle}`); + } else { + $commonApi.jump(`404~${slug}`); + } + } else { + // At the prompt, a not-found entry isn't a failure — its helper shows + // a "thinking" character + LLM reply instead of a sad face. + if (currentText !== "prompt") + $commonApi.notice(":(", ["red", "yellow"]); + } + return false; + } + } else { + // Module import/compile error - the .mjs file exists but has errors + // Don't try language fallbacks, just show the error + if (isNetworkImportError) { + console.error( + `😡 "${path}" module import failed (network error loading dependencies after ${3} retries):`, + err, + ); + } else if (isModuleImportError) { + console.error( + `😡 "${path}" module import failed (JS error in the piece):`, + err, + ); + } else { + console.error( + `😡 "${path}" load failure:`, + err, + "💾 First load:", + firstLoad, + ); + } + loadFailure = err; + $commonApi.net.loadFailureText = err.message + "\n" + (sourceCode || ""); + loading = false; + + // Only return a 404 if the error type is correct. + if (firstLoad && (err.message === "404" || err.message === "403")) { + // Check if slug is a user code (e.g. "ac25namuc") and redirect to their profile. + const handle = await resolveUserCode(slug); + if (handle) { + $commonApi.jump(`@${handle}`); + } else { + $commonApi.jump(`404~${slug}`); + } + } else { + $commonApi.notice(":(", ["red", "yellow"]); + } + return false; + } + } + + // console.log("Module load time:", performance.now() - moduleLoadTime, module); + // 🧨 Fail out if no module is found. + const moduleCheckTime = performance.now(); + // console.log(`⏰ Module check at ${moduleCheckTime}ms, module loaded: ${loadedModule !== undefined}`); + + if (loadedModule == null) { + loading = false; + leaving = false; + return false; + } + + // 🧩 Piece code has been loaded... + // Now we can instantiate the piece. + + pieceHistoryIndex += fromHistory === true ? 0 : 1; // Adjust the history. + + if (!debug && !firstLoad) { + // console.clear(); + headers($commonApi.dark); // Clear console and re-print headers if we are in production. + } + + // console.log("🧩", path, "🌐", host); + + $commonApi.net.devReload = devReload; // Expose to the piece if it was + // reloaded by the developer, for special logic per piece. + + // Add debug to the common api. + $commonApi.debug = debug; + + // Add reload to the common api. + $commonApi.reload = function reload({ + piece, + name, + source, + codeChannel, + createCode, + codeId, // The $code identifier (e.g., "inz") from kidlisp.com + authToken, // Auth token from kidlisp.com login + enableTrace, // Enable execution trace for kidlisp.com visualization + language, // Optional source language (e.g., "lua") + ext, // Optional source extension hint (e.g., "lua") + liveName, // Optional stable live piece name + } = {}) { + // console.log("⚠️ Reloading:", piece, name, source); + + // Check for global loading timeout first + if (checkLoadingTimeout()) { + console.log("🔴 Recovered from stuck loading state"); + } + + if (loading && source && !name && !piece) { + // If a piece is loading and we have new source code, queue the reload + // But only queue up to 10 times (1 second max wait), then force through + const queueCount = (reload._queueCount || 0) + 1; + reload._queueCount = queueCount; + + if (queueCount > 10) { + // Too many retries - force reset loading state and proceed + console.warn("🔴 Reload queue timeout - forcing loading reset"); + loading = false; + loadingStartTime = null; + reload._queueCount = 0; + } else { + console.log("🟡 Queueing reload until current load completes..."); + setTimeout( + () => + reload({ + source, + codeId, + createCode, + authToken, + enableTrace, + language, + ext, + liveName, + }), + 100, + ); + return; + } + } else { + // Reset queue counter when not queuing + reload._queueCount = 0; + } + + if (loading) { + console.log("🟡 A piece is already loading."); + return; + } + if (piece === "*refresh*") { + // padding: 8px; border-radius: 2px;console.log("💥️ Restarting system..."); + send({ type: "refresh" }); // Refresh the browser. + } else if (piece === "*piece-reload*") { + // console.log("🎨 Reloading current piece..."); + // Reload the current piece without refreshing the entire page + $commonApi.load( + { + path: currentPath, + host: currentHost, + search: currentSearch, + colon: currentColon, + params: currentParams, + hash: currentHash, + text: currentText, + }, + true, // fromHistory - don't add to history stack + alias, + true, // devReload + ); + } else if (source && !name && !piece) { + // Live reload with new source code (e.g., from kidlisp.com editor) + const isLuaReload = language === "lua" || ext === "lua"; + + // Just pass the source directly without path/text + currentText = isLuaReload ? (liveName || "l5-live") : source; + currentPath = isLuaReload ? (liveName || "l5-live") : source; + + if (isLuaReload) { + $commonApi.load( + { + source, + name: liveName || "l5-live", + ext: "lua", + search: currentSearch, + colon: currentColon, + params: currentParams, + hash: currentHash, + }, + true, // fromHistory - don't add to history stack + alias, + true, // devReload + ); + return; + } + + // Store the $code identifier for screenshots/sharing + if (codeId) { + currentOriginalCodeId = `$${codeId}`; + } + + // Store createCode flag globally so kidlisp can access it + if (createCode) { + $commonApi.kidlispCreateCode = true; + } else { + // console.log('⚠️ createCode flag is false or undefined:', createCode); + $commonApi.kidlispCreateCode = false; + } + + // Store auth token globally so kidlisp can use it for saving + if (authToken) { + $commonApi.kidlispAuthToken = authToken; + } + + // Store trace flag globally for kidlisp visualization + $commonApi.kidlispEnableTrace = enableTrace || false; + + // Reset frameCount so trace is posted on first frame of new code + if (globalKidLispInstance) { + globalKidLispInstance.frameCount = 0; + } + + $commonApi.load( + { + source: source, // Pass as source so it's used directly + name: "kidlisp-live", // Give it a name so slug won't be undefined + search: currentSearch, + colon: currentColon, + params: currentParams, + hash: currentHash, + enableTrace: enableTrace, // Pass trace flag + }, + true, // fromHistory - don't add to history stack + alias, + true, // devReload + undefined, // loadedCallback + true, // forceKidlisp - always treat as KidLisp code + ); + } else if (name && source) { + // TODO: Check for existence of `name` and `source` is hacky. 23.06.24.19.27 + // TODO: 🔥 This should somehow keep current commands or params, etc. + + // console.log( + // "🪷 Current: params:", + // currentParams, + // "text:", + // currentText, + // "path:", + // currentPath, + // ); + + // Note: This is used for live development via the socket server. + // + // A push names the piece by its bare slug (`balozo`), but this browser + // may be sitting on that piece's handle route (`@jeffrey/balozo`). The + // name given here becomes `currentText`, and `bios.mjs` rewrites the + // address bar from it — so taking the pushed slug at face value renamed + // the URL to `/balozo`, and the next refresh resolved that as a system + // piece, failed, and landed on `404~balozo`. It also dropped + // `pieceCodeChannel`, which quietly ended live updates. + // + // When the route we are on is that same piece, the route is its name. + const routed = + currentText?.startsWith("@") && + currentText.split("/")[1]?.split(":")[0] === name + ? currentText + : name; + $commonApi.load({ source, name: routed, codeChannel }, false, false, true); // Load source code. + } /*if (piece === "*" || piece === undefined /*|| currentText === piece*/ /*) {*/ else { + // console.log("💾️ Reloading:", piece, "Params:", currentParams); + // $commonApi.pieceCount = -1; // Reset pieceCount on developer reload. + // (This can be disabled while testing pieces + // that rely on pieceCount increments) + // ❤️‍🔥 TODO: Reloading the same piece should not effect piece count. + + $commonApi.load( + { + path: currentPath, + host: currentHost, + search: currentSearch, + colon: currentColon, + params: currentParams, + hash: currentHash, + text: currentText, + }, + // Use the existing contextual values when live-reloading in debug mode. + true, // (fromHistory) ... never add any reload to the history stack + alias, + true, // devReload + ); + } //else if (piece !== undefined) { + //console.log("Reloading pieceeeeeeee:", piece, parse(piece)); + //$commonApi.load(parse(piece), false, false, true); + //} + }; + + // Start the socket server + // TODO: Before we load the disk, in case of needing to reload remotely on failure? 23.01.27.12.48 + let receiver; // Handles incoming messages from the socket. + const forceProd = false; // For testing prod socket servers in development. + // TOOD: Hoist this to an environment variable? + + // Requests a session-backend and connects via websockets. + function startSocket() { + // Skip socket connections in OBJKT mode or SPIDER mode + if (getPackMode() || (typeof window !== 'undefined' && window.acSPIDER)) { + return; + } + + // 📡 Set up remote logging for LAN Dev mode + setupRemoteLogging(); + + if ( + //parsed.search?.startsWith("preview") || + //parsed.search?.startsWith("icon") + previewOrIconMode + ) { + console.log("🧦 Sockets disabled, just grabbing screenshots. 😃"); + return; + } + // Never open socket server in icon / preview mode. + // if (debug && logs.session) console.log("🫂 Finding session server..."); + socket = new Socket(debug, send); // Then redefine and make a new socket. + + const monolith = "monolith"; // "monolith"; // or `undefined` for horizontal scaling via + // jamstack + + session(slug, forceProd, monolith) + .then(async (sesh) => { + if (typeof sesh === "string") throw new Error(sesh); // Cancel if error. + const url = new URL(sesh.url); // Parse the url. + const udpUrl = new URL(sesh.udp); // Parse the udp url. + + // console.log("Session URL:", url); + + // 🩰 Fetch dev-info to get TURN host (for local dev with Docker) + // Only attempt this in dev mode (localhost) - not in production + let turnHost = null; + const isLocalDev = url.hostname === "localhost" || url.hostname.endsWith(".local"); + if (isLocalDev) { + try { + const devInfoRes = await fetch(`${sesh.url}/dev-info`); + if (devInfoRes.ok) { + const devInfo = await devInfoRes.json(); + if (devInfo.ip) { + turnHost = devInfo.ip; + log.socket.debug("Got TURN host from dev-info:", turnHost); + } + } + } catch (e) { + log.socket.debug("Could not fetch dev-info for TURN host:", e.message); + } + } + + // 🩰 UDP... (via `bios`) + // Use the protocol from the session response (http for localhost, https for prod) + log.socket.debug("Sending udp:connect to BIOS:", { + url: `${udpUrl.protocol}//${udpUrl.hostname}`, + port: udpUrl.port || (udpUrl.protocol === 'https:' ? '443' : '80'), + turnHost, + }); + send({ + type: "udp:connect", + content: { + url: `${udpUrl.protocol}//${udpUrl.hostname}`, + port: udpUrl.port || (udpUrl.protocol === 'https:' ? '443' : '80'), + turnHost, // Pass TURN host separately for ICE servers + }, + }); + + let slugBroadcastInterval; + + // 🕸️ Web Sockets + socket?.connect( + url.host + url.pathname, + (id, type, content) => { + // Globally receivable messages... + // (There are also some messages handled in `Socket`) + // 😱 Scream at everyone who is connected! + if (type === "scream" && socket?.id !== id) { + console.log("😱 Scream:", content, "❗"); + scream = content; + } + // 🎯 Jump to a specific piece! + if (type === "jump") { + $commonApi.jump(content.piece); + } + // 📱 Handle dev identity from session server (LAN Dev mode) + if (type === "dev:identity") { + devIdentity = content; + remoteLogSocket = socket; // Store socket reference for remote logging + flushRemoteLogQueue(); // Send any queued logs + $commonApi?.needsPaint?.(); + return; + } + // 🧩 Pieces get all other messages not caught in `Socket`. + receiver?.(id, type, content); // Run the piece receiver. + }, + $commonApi.reload, + "wss", + () => { + // 🔩 Connected! (Post-connection logic.) + if (USER) socket?.send("login", { user: USER }); + + // Broadcast current location. + if (HANDLE) { + // console.log("🗼 Broadcasting slug:", currentText, "for:", HANDLE); + socket?.send("location:broadcast", { + handle: HANDLE, + slug: currentText, + user: USER, // Include user for identity resolution + }); + slugBroadcastInterval = setInterval(() => { + socket?.send("location:broadcast", { + handle: HANDLE, + slug: "*keep-alive*", + user: USER, // Include user for identity resolution + }); + }, 2500); + } + + // Subscribe to code-channel as needed. + if (codeChannel) socket?.send("code-channel:sub", codeChannel); + // …and to the loaded piece's own channel, which a reconnect would + // otherwise drop silently — the piece would keep rendering and + // simply stop updating. + if (pieceCodeChannel) + socket?.send("code-channel:sub", pieceCodeChannel); + + updateHUDStatus(); + $commonApi.needsPaint(); + codeChannelAutoLoader?.(); + // setTimeout(function () { + // currentHUDStatusColor = undefined; + // }, 250); + }, + () => { + // 💔 Disconnected! (Post-disconnection logic.) + updateHUDStatus(); + clearInterval(slugBroadcastInterval); + }, + ); + }) + .catch((err) => { + console.error("Session connection error:", err); + }); + } + + // End the socket connection before switching pieces if one exists. + // socket?.kill(); + // udp?.kill(); + // socket = undefined; + + // Delay session server by .75 seconds in order to prevent redundant + // connections being opened as pieces are quickly re-routing and jumping. + clearTimeout(socketStartDelay); + socket?.kill(); // Kill any already open socket from a previous disk. + udp?.kill(); + socket = undefined; + socketStartDelay = setTimeout(() => startSocket(), 250); + + $commonApi.net.socket = function (receive) { + receiver = receive || (() => {}); + if (!socket) { + // Just in case we init. in a `boot` before the timeout fires above. + clearTimeout(socketStartDelay); + startSocket(); + } else { + // Return the server then send an already connected message. + setTimeout(() => { + if (socket?.id) receiver(socket.id, "connected:already"); + }, 10); + } + return socket; + }; + + // ***Client Metadata Fields*** + // Set default metadata fields for SEO and sharing, + // (requires serverside prerendering, also via `index.js`). + let meta; + + if (alias === false) { + // Parse any special piece metadata. + + // Check if we're in OBJKT mode and have colophon data + let objktContext = null; + if (checkPackMode() && typeof window !== 'undefined' && window.acOBJKT_COLOPHON) { + objktContext = { author: window.acOBJKT_COLOPHON.build.author }; + } + + // Use parsed.piece for the title (just the piece name, not full slug with params) + const titleSlug = parsed.piece || slug; + + const { title, desc, ogImage, twitterImage, icon } = metadata( + location.host, // "aesthetic.computer", + titleSlug, + loadedModule.meta?.({ + ...parsed, + num: $commonApi.num, + store: $commonApi.store, + }) || inferTitleDesc(originalCode), + location.protocol, // Pass the current protocol + objktContext // Pass OBJKT context if available + ); + + meta = { + title, + desc, // Note: This doesn't auto-update externally hosted module descriptions, and may never need to? 22.07.19.06.00 + img: { + og: ogImage, + twitter: twitterImage, + icon, + }, + url: "https://aesthetic.computer/" + slug, + }; + } + + // Add meta to the common api so the data can be overridden as needed. + $commonApi.meta = (data) => send({ type: "meta", content: data }); + + // Rewrite a new URL / parameter path without affecting the history. + $commonApi.net.rewrite = (path, historical = false) => { + if (historical) $commonApi.history.push(path); + send({ type: "rewrite-url-path", content: { path, historical } }); // Jump the browser to a new url. + }; + + // Add host to the networking api. + $commonApi.net.host = host; + + // Jump the browser to a new url. + $commonApi.net.web = (url, jumpOut) => { + send({ type: "web", content: { url, blank: jumpOut } }); + }; + + $commonApi.net.refresh = () => { + send({ type: "refresh" }); + }; + + // Tell the system to wait until preloading is finished before painting. + $commonApi.net.waitForPreload = () => { + send({ type: "wait-for-preload", content: true }); + }; + + // Tell the system that all preloading is done. + $commonApi.net.preloaded = () => { + send({ type: "preload-ready", content: true }); + }; + + $commonApi.content = new Content(); + + $commonApi.dom = {}; + + $commonApi.dom.clear = () => { + $commonApi.content.remove(); + }; + + $commonApi.dom.html = (strings, ...vars) => { + const processed = defaultTemplateStringProcessor(strings, ...vars); + $commonApi.content.add(processed); + }; + + $commonApi.dom.css = (strings, ...vars) => { + const processed = defaultTemplateStringProcessor(strings, ...vars); + $commonApi.content.add(``); + }; + + $commonApi.dom.javascript = (strings, ...vars) => { + const processed = defaultTemplateStringProcessor(strings, ...vars); + $commonApi.content.add(``); + }; + // 💾 Uploading + Downloading + // Add download event to trigger a file download from the main thread. + $commonApi.download = (filename, data, modifiers) => { + send({ type: "download", content: { filename, data, modifiers } }); + }; + + // * Preload * + // Add preload to the boot api. + // Accepts paths local to the original disk server, full urls, and demos. + // Usage: preload("demo:drawings/2021.12.12.17.28.16.json") // pre-included + // preload("https://myserver.com/test.json") // remote + // preload("drawings/default.json") // hosted with disk + // Results: preload().then((r) => ...).catch((e) => ...) // via promise + + $commonApi.net.preload = async function ( + path, + parseJSON = true, + progressReport, + options = {}, + ) { + let extension; + + const rejection = (reject) => { + reject(new DOMException("Aborted", "AbortError")); + }; + + // 🎒 PACK mode: Check VFS for embedded files first (used for offline bundles) + const packModeActive = + (typeof window !== "undefined" && window.acPACK_MODE) || + (typeof globalThis !== "undefined" && globalThis.acPACK_MODE); + const packVfs = + (typeof window !== "undefined" && window.VFS) || + (typeof globalThis !== "undefined" && globalThis.VFS); + + if (packModeActive && packVfs) { + let vfsPath = typeof path === "object" ? path.path : path; + // Normalize path: strip leading slash and "aesthetic.computer/" prefix + if (vfsPath.startsWith('/')) vfsPath = vfsPath.slice(1); + if (vfsPath.startsWith('aesthetic.computer/')) vfsPath = vfsPath.slice('aesthetic.computer/'.length); + + const vfsEntry = packVfs[vfsPath]; + if (vfsEntry) { + const ext = typeof path === "object" ? path.extension : vfsPath.split(".").pop().split("?")[0]; + progressReport?.(1); // Report completion immediately for VFS files + + if (ext === "json") { + // JSON files in VFS: { content: "...", type: 'json' } or { content: "...", type: 'base64' } + let jsonStr; + if (typeof vfsEntry === 'object' && vfsEntry.content !== undefined) { + if (vfsEntry.type === 'base64') { + jsonStr = atob(vfsEntry.content); + } else { + jsonStr = vfsEntry.content; // type: 'json' stores content as string + } + } else if (typeof vfsEntry === 'string') { + jsonStr = vfsEntry; + } + if (jsonStr !== undefined) { + return parseJSON ? JSON.parse(jsonStr) : jsonStr; + } + } else if (ext === "xml") { + let xmlStr; + if (typeof vfsEntry === 'object' && vfsEntry.content !== undefined) { + xmlStr = vfsEntry.type === 'base64' ? atob(vfsEntry.content) : vfsEntry.content; + } else { + xmlStr = vfsEntry; + } + const parser = new DOMParser(); + return parser.parseFromString(xmlStr, "text/xml"); + } + // For other types, fall through to normal handling if not JSON/XML + } + } + + if (soundWhitelist.includes(path)) { + extension = "ogg"; + } else { + if (typeof path === "object") { + extension = path.extension; + path = path.path; + } else { + extension = path.split(".").pop().split("?")[0]; + } + + if (path.indexOf("/") === 0) path = path.slice(1); + + try { + const url = new URL(path); + if (url.protocol === "demo:") { + path = `/demo/${url.pathname}`; + } else if (url.protocol !== "https:") { + } + } catch { + // Handle sandboxed environments for path construction + const { protocol, hostname, host } = getSafeUrlParts(); + const originalPath = path; + + // Apply the same origin-aware logic as in module loading + let baseUrl; + if (path.startsWith('aesthetic.computer/')) { + // Check if we're in a development environment (localhost with port) + baseUrl = getBuiltInPieceBaseUrl(); + + // Only strip "aesthetic.computer/" if we're using the main production domain + if (baseUrl === 'https://aesthetic.computer') { + path = path.substring('aesthetic.computer/'.length); + } + } else { + baseUrl = `${protocol}//${host || hostname}`; + } + + path = `${baseUrl}/${path}`; + + // Removed debug log for font glyph preload + } + } + + if (extension === "xml" || extension === undefined) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + options.signal?.addEventListener("abort", () => { + xhr.abort(); + rejection(reject); + }); + + xhr.open("GET", path, true); + xhr.onprogress = function (event) { + const progress = min(event.loaded / event.total, 1); + if (debug && logs.download) { + console.log(`💈 XML Download: ${progress * 100}%`); + } + progressReport?.(progress); + }; + xhr.onload = function () { + if (xhr.status === 200 || xhr.status === 304) { + resolve(xhr.responseXML || xhr.responseText); + } else { + reject(xhr.status); + } + }; + xhr.onerror = reject; + xhr.send(); + }); + } else if (extension === "json") { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + options.signal?.addEventListener("abort", () => { + xhr.abort(); + rejection(reject); + }); + + xhr.open("GET", path, true); + xhr.onprogress = function (event) { + const progress = min(event.loaded / event.total, 1); + if (debug && logs.download) { + console.log(`💈 JSON Download: ${progress * 100}%`); + } + progressReport?.(progress); + }; + xhr.onload = function () { + if (xhr.status === 200 || xhr.status === 304) { + resolve(parseJSON ? JSON.parse(xhr.response) : xhr.response); + } else { + reject(xhr.status); + } + }; + xhr.onerror = reject; + xhr.send(); + }); + } else if ( + extension === "webp" || + extension === "jpg" || + extension === "gif" || + extension === "jpeg" || + extension === "png" + ) { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + rejection(reject); + return; + } + + send({ type: "load-bitmap", content: path }); + preloadPromises[path] = { resolve, reject }; + + options.signal?.addEventListener("abort", () => { + send({ type: "load:abort", content: path }); + rejection(reject); + }); + }); + } else if ( + extension === "m4a" || + extension === "ogg" || + extension === "webm" || + extension === "wav" || + extension === "mp3" + ) { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + rejection(reject); + return; + } + + preloadPromises[path] = { resolve, reject }; + // Register the receiver before asking BIOS to load. BIOS deliberately + // acknowledges an HTMLAudioElement immediately, so sending first can + // race the reply and leave this promise pending forever. + send({ type: "sfx:load", content: path }); + + options.signal?.addEventListener("abort", () => { + rejection(reject); + }); + }); + } else if (extension === "zip") { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + rejection(reject); + return; + } + + send({ type: "zip:load", content: path }); + preloadPromises[path] = { resolve, reject }; + + options.signal?.addEventListener("abort", () => { + rejection(reject); + }); + }); + } + }; + + // 🎬 Load animated WebP and decode all frames + $commonApi.net.preloadAnimatedWebp = async function (url) { + return new Promise((resolve, reject) => { + send({ type: "load-animated-webp", content: url }); + preloadPromises[url] = { resolve, reject }; + }); + }; + + $commonApi.slug = slug; + $commonApi.piece = slug?.split("~")[0].split(":")[0]; + $commonApi.query = Object.fromEntries(new URLSearchParams(search)); + $commonApi.params = params || []; + $commonApi.colon = colon || []; + $commonApi.hash = hash; // Add hash support for painting codes like #2ug + + // Initialize duration tracking from query parameters + if ($commonApi.query.duration) { + const duration = parseFloat($commonApi.query.duration); + if (!isNaN(duration) && duration > 0) { + durationTotal = duration; + durationStartTime = pageLoadTime; // Start from page load time immediately + durationProgress = 0; + durationCompleted = false; + durationBlinkState = false; + console.log("⏱️ Duration parameter detected:", duration, "seconds - starting from page load time:", pageLoadTime); + } + } + + $commonApi.load = async function () { + // Load a piece, wrapping it in a leave function so a final frame + // plays back. + leaving = true; + graph.unmask(); // Clear any active mask when leaving a piece + + return new Promise((resolve) => { + leaveLoad = async () => { + const loaded = await load(...arguments); + resolve(loaded); // Resolve with `true` or `false`. + }; + }); + }; + + // 💡 Eventually this could merge with net.web so there is one command + // to either go to a piece within the system if one loads... or an entirely + // different url somehow! 23.02.07.21.21 + + $commonApi.alias = function alias(name, colon, params) { + $commonApi.jump( + name + + colon.map((c) => `:` + c).join("") + + params.map((p) => `~` + p).join(""), + true, + true, + ); + }; + + // Go back to the previous piece, or to the prompt if there is no history. + $commonApi.back = () => { + if (pieceHistoryIndex > 0) { + send({ type: "back-to-piece" }); + } else { + $commonApi.jump("prompt"); + } + }; + + if (!alias) $commonApi.pieceCount += 1; // Don't bump pieceCount if aliased. + + // Load typeface if it hasn't been yet. + // (This only has to happen when the first piece loads.) + // Skip expensive typeface loading - use on-demand loading for all glyphs + const skipTypefacePreload = true; // Always skip preload, load on-demand instead + // console.log(`⏰ Typeface skip check: skipTypefacePreload=${skipTypefacePreload}, tf exists=${!!tf}`); + + const typefaceLoadStartTime = performance.now(); + if (!tf && !skipTypefacePreload) { + tf = await new Typeface(/*"unifont"*/).load($commonApi.net.preload); + const typefaceLoadEndTime = performance.now(); + // console.log(`⏰ Typeface load: ${Math.round(typefaceLoadEndTime - typefaceLoadStartTime)}ms`); + } else if (skipTypefacePreload) { + // Create a Typeface instance and call load() to set up Proxy + tf = new Typeface(/*"unifont"*/); + // In TV/headless mode (oven captures), await font load to ensure glyphs + // are available before first paint. Otherwise load on-demand (fire-and-forget). + const isHeadless = typeof window !== "undefined" && + new URLSearchParams(window.location?.search).get("tv") === "true"; + if (isHeadless) { + try { + await tf.load($commonApi.net.preload); + if (typeof window !== "undefined") { + window.acFontsReady = true; + window.__acTypeface = tf; + } + console.log("🔤 font_1 awaited load complete (tv mode)"); + } catch (err) { + console.error("🔤 tf.load() FAILED:", err?.message || err); + if (typeof window !== "undefined") window.acFontsReady = "error"; + } + } else { + tf.load($commonApi.net.preload).then(() => { + if (typeof window !== "undefined") { + window.acFontsReady = true; + window.__acTypeface = tf; + } + }).catch(() => {}); + } + const typefaceLoadEndTime = performance.now(); + // console.log(`⏰ Typeface load skipped (on-demand mode), Proxy initialized in ${Math.round(typefaceLoadEndTime - typefaceLoadStartTime)}ms`); + } else { + // console.log(`⏰ Typeface already loaded`); + } + + $commonApi.typeface = tf; // Expose a preloaded typeface globally. + if (typeof window !== "undefined") window.__acTypeface = tf; // Expose for oven diagnostics + ui.setTypeface(tf); // Set the default `ui` typeface. + + // Initialize HUD metrics now that the primary typeface is available + currentHUDLabelBlockWidth = tf.blockWidth; + currentHUDLabelBlockHeight = tf.blockHeight; + currentHUDShareWidth = tf.blockWidth * "share ".length; + currentHUDEditWidth = tf.blockWidth; + + // Initialize MatrixChunky8 font for QR code text rendering and keep it warm across pieces + // Always register the font so kidlisp.com previews still render MatrixChunky8 labels + const matrixFontStartTime = performance.now(); + let matrixFont = typefaceCache.get("MatrixChunky8"); + + if (!matrixFont) { + matrixFont = new Typeface("MatrixChunky8"); + typefaceCache.set("MatrixChunky8", matrixFont); + } + + if (!matrixFont.__loadPromise) { + matrixFont.__loadPromise = matrixFont.load($commonApi.net.preload); + } + + // Only run the heavier glyph preload pass when auth UI is enabled; kidlisp.com preview just needs the font registered + const skipMatrixGlyphPreload = typeof window !== "undefined" && window.acNOAUTH; + + if (!skipMatrixGlyphPreload) { + await matrixFont.__loadPromise; + + if (matrixFont && !matrixFont.__preloadedCommonGlyphs) { + const glyphPreloadStartTime = performance.now(); + // Pre-load common QR code characters to avoid fallback during rendering + const commonQRChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz$"; + + // Access each character to trigger the Proxy loading mechanism and collect promises + const glyphPromises = []; + + for (const char of commonQRChars) { + // Accessing the glyph triggers the Proxy to start loading it + const glyph = matrixFont.glyphs[char]; + + // If it's a Promise, collect it to wait for loading + if (glyph && typeof glyph.then === "function") { + glyphPromises.push(glyph); + } + } + + // Wait for all common glyphs to finish loading + if (glyphPromises.length > 0) { + try { + await Promise.all(glyphPromises); + } catch (e) { + console.warn("🔤 Some MatrixChunky8 glyphs failed to preload:", e); + } + } + + const glyphPreloadEndTime = performance.now(); + // console.log(`⏰ MatrixChunky8 glyph preload: ${Math.round(glyphPreloadEndTime - glyphPreloadStartTime)}ms`); + matrixFont.__preloadedCommonGlyphs = true; + } + + const matrixFontEndTime = performance.now(); + // console.log(`⏰ MatrixChunky8 total: ${Math.round(matrixFontEndTime - matrixFontStartTime)}ms`); + } else { + // console.log("⏰ MatrixChunky8 preload skipped (noauth mode)"); + } + + /** + * @function video + * @descrption Make a live video feed. Returns an object that links to current frame. + * @param {string} type - "camera" or "camera-update" or see below. 💡 + * @param {object} options - *unimplemented* { src, width, height } + */ + + let videoTimeout; + let constraintTimeout; + let lastConstraintSendTime = 0; + const CONSTRAINT_SEND_INTERVAL = 60; // ms between zoom/torch tweak sends. + + $commonApi.video = function (type, options) { + // TODO: ❤️‍🔥 Prevent fast multiple taps while camera is updating... + + // TODO: Options could eventually be { src, width, height } + // const vid = video("youtube-link"); + // const vid = video("tiktok:@whistlegraph"); + // https://codepen.io/oceangermanique/pen/LqaPgO + + if (videoSwitching === false) { + // Torch and zoom adjust constraints on the live track — the stream + // never restarts, so frames keep flowing and there is nothing to wait + // for. Only facing/size changes swap the stream and need the + // frame-hold (`videoSwitching`) treatment; holding for a zoom tweak + // froze the camera preview mid-recording because zoom-only updates + // never emit `camera:updated` to release the hold. + const keys = options ? Object.keys(options) : []; + const constraintOnly = + type === "camera:update" && + keys.length > 0 && + keys.every((key) => key === "torch" || key === "zoom"); + + if (constraintOnly) { + // Leading + trailing throttle on its own timer. A trailing-only + // debounce starves a continuous zoom slide — every new touch value + // resets the timer, so nothing sends until the finger pauses and the + // recorded zoom then lurches in one big step. Sending immediately + // (rate-limited) keeps the ramp tracking the finger; the trailing + // send delivers the final resting value. + const now = performance.now(); + const since = now - lastConstraintSendTime; + clearTimeout(constraintTimeout); + if (since >= CONSTRAINT_SEND_INTERVAL) { + lastConstraintSendTime = now; + send({ type: "video", content: { type, options } }); + } else { + constraintTimeout = setTimeout(() => { + lastConstraintSendTime = performance.now(); + send({ type: "video", content: { type, options } }); + }, CONSTRAINT_SEND_INTERVAL - since); + } + return videoFrame; + } + + if (type === "camera:update") { + lastActiveVideo = activeVideo || lastActiveVideo; + activeVideo = null; + } + + clearTimeout(videoTimeout); + videoTimeout = setTimeout(() => { + send({ type: "video", content: { type, options } }); + }, 50); + + if (type === "camera:update") videoSwitching = true; + } + + // Return an object that can grab whatever the most recent frame of + // video was. + return videoFrame; + }; + + function videoFrame(shader) { + if (activeVideo) { + const { width, pixels } = activeVideo; + + if (shader) { + for (let i = 0; i < pixels.length; i += 4) { + const c = pixels.subarray(i, i + 4); + const p = { x: (i / 4) % width, y: floor(i / 4 / width) }; + shader(p, c); + } + } + } + // With no fresh frame (e.g. mid camera switch), hand back the last real + // frame untouched so the preview holds instead of flashing a placeholder. + return activeVideo || lastActiveVideo; + } + + // This function actually hotSwaps out the piece via a callback from `bios` once fully loaded via the `loading-complete` message. + hotSwap = () => { + loadedCallback?.(); // Run the optional load callback. (See also: `jump`) + + $commonApi.rec.loadCallback?.(); // Start any queued tape. + $commonApi.rec.loadCallback = null; + + // 📤 Send ready signal to parent window (e.g., kidlisp.com editor) + // Note: disk.mjs runs in a Web Worker, so window may not exist + const hasWindow = typeof window !== 'undefined'; + + try { + if (hasWindow && window.parent && window.parent !== window) { + window.parent.postMessage({ type: "kidlisp-ready" }, "*"); + } + // If no window (worker context), silently skip + } catch (e) { + // Silently fail if in worker context or cross-origin + } + + // Process any queued piece-reload that arrived before piece was ready + if (pendingPieceReload && $commonApi.reload) { + log.piece.log("Processing queued piece-reload from hotSwap"); + const pending = pendingPieceReload; + pendingPieceReload = null; + // Use setTimeout to ensure all initialization is complete + setTimeout(() => { + $commonApi.reload({ + source: pending.source, + codeId: pending.codeId, + createCode: pending.createCode, + authToken: pending.authToken, + enableTrace: pending.enableTrace, + language: pending.language, + ext: pending.ext, + liveName: pending.liveName, + }); + }, 50); + } + + module = loadedModule; + + // 🧹 Reset KidLisp baked layers when returning to the prompt to avoid stale flashes + if (module?.system?.startsWith("prompt") && + globalKidLispInstance?.clearBakedLayers) { + globalKidLispInstance.clearBakedLayers(); + graph.unmask(); // Clear mask when returning to prompt from KidLisp + } + + if (!module.system?.startsWith("world")) + $commonApi.system.world.teleported = false; + + // 📚 nopaint system + if ( + !Array.isArray(module) && + (module.system?.startsWith("nopaint") || + typeof module?.brush === "function" || + typeof module?.lift === "function" || + typeof module?.filter === "function") + ) { + // If there is no painting is in ram, then grab it from the local store, + // or generate one. + const modsys = module.system || "nopaint"; + + // TODO: 🖌️💬 Integrate nopaint system with chat. + // console.log(chat); + + const chatEnabled = false; + + $commonApi.system.nopaint.bakeOnLeave = + modsys.split(":")[1] === "bake-on-leave"; // The default is to `bake` at the end of each gesture aka `bake-on-lift`. + + boot = async ($) => { + // Reset scroll state when a new piece boots + graph.resetScrollState(); + // Reset mask state to prevent masks from persisting across pieces + graph.unmask(); + + const booter = module.boot || nopaint_boot; + booter($); + if (chatEnabled) { + try { + const chatModule = await import("../disks/chat.mjs"); + chatModule.boot($); + } catch (err) { + console.log("💬 Chat disabled in OBJKT mode"); + } + } + }; + + sim = module.sim || defaults.sim; + receive = module.receive || defaults.receive; + paint = async ($) => { + let painted = false; + + if (module.paint) { + painted = module.paint($); + if ($.system?.nopaint) { + $.system.nopaint.needsPresent = true; + } + } + + // 🎨 OVERLAY SUPPORT: Call overlay function for preview rendering with painting coordinates + if (module.overlay && $.system.nopaint?.brush?.dragBox) { + // Transform painting coordinates to screen coordinates for direct screen rendering + const originalDragBox = $.system.nopaint.brush.dragBox; + const zoom = $.system.nopaint.zoomLevel; + const tx = $.system.nopaint.translation.x; + const ty = $.system.nopaint.translation.y; + + // Create screen-space dragBox + const screenDragBox = { + x: originalDragBox.x * zoom + tx, + y: originalDragBox.y * zoom + ty, + w: originalDragBox.w * zoom, + h: originalDragBox.h * zoom + }; + + // Temporarily replace the dragBox with screen coordinates + const originalBrush = $.system.nopaint.brush; + $.system.nopaint.brush = { ...originalBrush, dragBox: screenDragBox }; + + // Add color word for overlay function + $.color = $.system.nopaint.color; + + // Add mark word for overlay function (preview dragBox) + $.mark = $.system.nopaint.brush?.dragBox; + + // Render overlay directly to screen buffer + module.overlay($); + + // Restore original dragBox + $.system.nopaint.brush = originalBrush; + } + + // 📊 Always render nopaint performance HUD if enabled (regardless of module.paint result) + if (system === "nopaint" && nopaintPerf) { + nopaint_renderPerfHUD($); + // Force continuous painting for nopaint system when perf HUD is enabled + painted = true; + } + + if (painted) { + return painted; + } + + // TODO: Pass in extra arguments here that flag the wipe. + if (chatEnabled) { + try { + const chatModule = await import("../disks/chat.mjs"); + chatModule.paint($, { embedded: true }); // Render any chat interface necessary. + } catch (err) { + console.log("💬 Chat disabled in OBJKT mode"); + } + } + }; + beat = module.beat || defaults.beat; + brush = module.brush; + lift = module.lift; + filter = module.filter; + act = ($) => { + nopaint_act($); // Inherit base functionality. + + // 🎯 IMMEDIATE BAKING: Fix race condition by triggering bake immediately after lift + const np = $.system?.nopaint; + if (np?.needsBake === true && bake) { + // 📊 Trigger bake flash effect and beep sound + nopaint_triggerBakeFlash(); + + // 🔊 Microwave-style beep for bake completion (only for robo) + if (currentPath?.includes('robo')) { + $commonApi.sound.synth({ + tone: 800, // Higher pitched beep like a microwave + duration: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.3, + }); + } + + // 🎨 ELEGANT BRUSH PATTERN: Call brush or lift function for final painting + if (brush || lift) { + const finalBrushApi = { ...$ }; + // Add top-level 'color' word that maps to system.nopaint.color + finalBrushApi.color = $.system.nopaint.color; + + // Add top-level 'mark' word that maps to finalDragBox for brush + finalBrushApi.mark = $.system.nopaint.finalDragBox; + + // 🎯 Use preserved coordinates since brush is null at bake time + const preservedDragBox = $.system.nopaint.finalDragBox; + const preservedStartDrag = $.system.nopaint.finalStartDrag; + + if (preservedDragBox) { + // Create a pen object with the preserved coordinates + finalBrushApi.pen = { + dragBox: preservedDragBox, + x: preservedStartDrag?.x || preservedDragBox.x, + y: preservedStartDrag?.y || preservedDragBox.y + }; + } else { + finalBrushApi.pen = $.system.nopaint.brush; // Fallback to original (likely null) + } + + finalBrushApi.lift = true; // 🎯 Single clean flag for final brush call + + $.page($.system.painting); // Set context to painting surface + + // Call lift function if it exists, otherwise fall back to brush + if (lift) { + lift(finalBrushApi); // Call lift for final painting + } else if (brush) { + brush(finalBrushApi); // Call brush for final painting (legacy) + } + + $.page($.screen); // Reset context + } + + $.page($.system.painting); + bake($); + + // 🧹 CLEAR BUFFER: Prevent flickering by clearing buffer BEFORE presentation + $.page($.system.nopaint.buffer).wipe(255, 255, 255, 0); + + $.page($.screen); + np.present($); + np.needsBake = false; + + // 📸 Snapshot for undo after every stroke bake. + addUndoPainting($.system.painting, "stroke"); + + // 🚀 Broadcast bake completion + $commonApi.broadcastPaintingUpdateImmediate("baked", { + source: "immediate_bake", + piece: loadedModule?.meta?.()?.title || "unknown" + }); + } + + if (module.act) { + return module.act($); + } else { + return defaults.act($); + } + }; + leave = ($) => { + module.leave?.($); // Run the custom leave. + nopaint.leave($); // And the inherited default leave from nopaint. + }; + bake = module.bake || nopaint.bake; + system = "nopaint"; + } else if (module.system?.startsWith("prompt")) { + // 📖 prompt system + // Default wrap to "word" if using `prompt:character`. + const wrap = + module.wrap || + (module.system.indexOf("character") > -1 ? "word" : undefined); + + if (module.scheme) { + if ( + module.scheme.dark === undefined && + module.scheme.light === undefined + ) { + const defaultScheme = { ...module.scheme }; + module.scheme.dark = module.scheme.light = defaultScheme; + keys(module.scheme).forEach((key) => { + if (key !== "dark" && key !== "light") delete module.scheme[key]; + }); + } + } + + boot = async ($) => { + await prompt.prompt_boot( + $, + { + prompt: module.prompt, + program: { + before: module.before, + after: module.after, + }, + hint: module.system.split(":").slice(1).join(":"), // See `ask.ts`. + forgetful: module.forgetful || false, + memory: module.memory || Infinity, + gutterMax: module.gutterMax, + lineSpacing: module.lineSpacing, + }, + module.reply, + module.halt, + module.scheme, + wrap, + module.copied, + module.activated, + ); + await module.boot?.($); + }; + + sim = ($) => { + module.sim?.($); + if (!shellHTMLMode) prompt.prompt_sim($); + else shellPromptSync($); // mirror reply text / thinking to the shell + }; + + paint = ($) => { + let noPaint = module.paint?.($); // Carry the return. + + // Paint an illustration if it exists and `paint` is not defined. + if (!module.paint && module.illustration) { + $.wipe(...module.scheme.dark.background.slice(0, 3)); + $.stamp(module.illustration, { center: "x", bottom: 0 }); + noPaint = true; + } + + // shellhtml: the host page's DOM layer IS the prompt — never composite + // the low-res typable input over the stage beneath it. + const promptPainted = shellHTMLMode ? false : prompt.prompt_paint($); + return noPaint || promptPainted; + }; + + beat = module.beat || defaults.beat; + + act = ($) => { + module.act?.($); + if (!shellHTMLMode) prompt.prompt_act($); // shell DOM owns typing + }; + + leave = ($) => { + module.leave?.($); + prompt.prompt_leave($); + }; + + // console.log("📨 Module exports check:", { + // hasReceive: !!module.receive, + // receiveType: typeof module.receive, + // receiveIsDefault: module.receive === defaults.receive, + // }); + + receive = module.receive || defaults.receive; + + system = "prompt"; + } else if (module.system?.startsWith("world")) { + // 🗺️ world system + boot = async ($) => { + await world.world_boot($, module.world); + await module.boot?.($); + }; + + sim = ($) => { + world.world_sim($); + module.sim?.($); + }; + + paint = ($) => { + if (!world.coversScreen($.screen)) module.background?.($); + world.world_paint($, module.paint, module.curtain); + }; + + beat = module.beat || defaults.beat; + + act = ($) => { + world.world_act($); + module.act?.($); + }; + + leave = ($) => { + world.world_leave($); + module.leave?.($); + }; + + system = "world"; + } else if (module.system?.startsWith("fps")) { + // 🧊 fps system + let doll; + + boot = ($) => { + // Default camera opts; pieces can override via exported `fpsOpts` to + // enable grounded physics (gravity/jump/run/crouch) or just retune FOV. + const defaults = { fov: 80, z: 0, y: 0, sensitivity: 0.002 }; + const opts = { ...defaults, ...(module.fpsOpts || {}) }; + doll = new CamDoll($.Camera, $.Dolly, opts); + $commonApi.system.fps = { doll, renderStats: graph.renderStats }; + module?.boot?.($); + }; + + sim = ($) => { + doll?.sim(); + module?.sim?.($); + }; + + act = ($) => { + doll?.act($.event); + module?.act?.($); + }; + + paint = module.paint || defaults.paint; + leave = module.leave || defaults.leave; + + system = "fps"; + } else if (module.system?.startsWith("game")) { + // 🎲 🎮 game system + console.log("game!"); + // TODO: ⚠️ Make game template. 25.06.05.09.19 + } else { + const moduleAssignStartTime = performance.now(); + // console.log(`⏰ Starting module function assignment at ${moduleAssignStartTime}ms`); + + // 🧩 piece + // Reset scroll state when a piece loads + graph.resetScrollState(); + // Reset mask state to prevent masks from persisting across pieces + graph.unmask(); + + boot = module.boot || defaults.boot; + sim = module.sim || defaults.sim; + paint = module.paint || defaults.paint; + beat = module.beat || defaults.beat; + act = module.act || defaults.act; + leave = module.leave || defaults.leave; + + // console.log("📨 Module exports check:", { + // hasReceive: !!module.receive, + // receiveType: typeof module.receive, + // receiveIsDefault: module.receive === defaults.receive + // }); + + receive = module.receive || defaults.receive; // Handle messages from BIOS + + // ⏱️ Background mode: pieces can export `background = true` to keep sim() + // running when the page is hidden (e.g. clock.mjs for audio scheduling) + pieceBackground = !!module.background; + + // 🎨 AUTO-DETECT BRUSH FUNCTIONS: If a piece exports a brush or lift function, automatically use nopaint system + system = module.system || (module.brush || module.lift ? "nopaint" : null); + + // 🎨 AUTO-GENERATE BAKE: If using brush/lift but no explicit bake function, create a default one + if ((module.brush || module.lift) && !module.bake) { + bake = ({ paste, system, page }) => { + paste(system.nopaint.buffer); + page(system.nopaint.buffer).wipe(255, 255, 255, 0); + }; + } + + const moduleAssignEndTime = performance.now(); + // console.log(`⏰ Module function assignment complete (${Math.round(moduleAssignEndTime - moduleAssignStartTime)}ms)`); + + // delete $commonApi.system.name; // No system in use. + } + + preview = module.preview || defaults.preview; // Set preview method. + icon = module.icon || defaults.icon; // Set preview method. + + // ♻️ Reset global state for this piece. + paintCount = 0n; + paintingAPIid = 0n; + simCount = 0n; + booted = false; + // initialSim = true; + activeVideo = null; + videoSwitching = false; + lastActiveVideo = null; + keys(preloadPromises).forEach((key) => preloadPromises[key].reject(key)); + preloadPromises = {}; + noPaint = false; + hdUnload(); // 🖼️ Drop the previous piece's HD layer (hides it in bios). + + // 🎞️ Reset piece-level FPS control + pieceFPS = null; + lastPaintTime = 0; + lastPaintOut = undefined; + shouldSkipPaint = false; + pieceFrameCount = 0; + // Reset acPieceReady so the signal fires again for the next piece + if (typeof window !== "undefined") { + window.acPieceReady = false; + } + + // 🎬 Piece Transition: Capture CURRENT screen pixels BEFORE clearing + // This enables piece-to-piece morphing transitions (not just noise16→piece) + // Skip entirely if TRANSITION_TYPE is "none" + if (TRANSITION_TYPE !== "none") { + // Check if buffer is valid (not detached from transfer to main thread) + try { + if (screen?.pixels && screen.width > 0 && screen.height > 0 && + screen.pixels.buffer && !screen.pixels.buffer.detached && screen.pixels.byteLength > 0) { + golTransition.overlayPixels = new Uint8ClampedArray(screen.pixels); + golTransition.width = screen.width; + golTransition.height = screen.height; + + // Initialize and START transition immediately (loading phase) + initGOLCells(golTransition.width, golTransition.height); + golTransition.generation = 0; + golTransition.active = true; + console.log(`🎬 Transition: Started ${TRANSITION_TYPE} (loading phase)`, screen.width, "x", screen.height); + } + } catch (e) { + // Buffer may be detached if pixels were transferred - skip transition + console.log("🎬 Transition: Could not capture pixels (buffer detached), skipping"); + golTransition.overlayPixels = null; + } + } + + // Note: transition state is now set above when starting + + // 🧹 Clear screen buffer when loading a new piece to prevent stale pixels + // This fixes the blank screen issue when backspacing from kidlisp to prompt + graph.clear(); + + formsSent = {}; // Clear 3D list for GPU. + currentPath = path; + currentHost = host; + currentSearch = search; + // console.log("Set currentSearch to:", search); + firstPreviewOrIcon = true; + + // Clear clock piece cached code when leaving (will be set by clock piece if it caches) + cachedClockCode = null; + + // Parse search parameters properly to check for nolabel + // Also hide label in pack mode (standalone bundles have no URL params) + hideLabel = (typeof window !== "undefined" && window.acPACK_MODE && !window.acKEEP_LABEL) || hideLabelForFrame; + if (parsed.search) { + const searchParams = new URLSearchParams(parsed.search); + if (searchParams.has("nolabel")) hideLabel = hideLabelForFrame = true; + if (searchParams.has("autoreload")) autoUpdateForFrame = true; + } + if (shellHTMLMode) hideLabel = true; // the shell's DOM corner overlay replaces it + + currentColon = colon; + currentParams = params; + currentHash = hash; + // sound = null; + glazeEnabled = null; + // soundClear = null; + hourGlasses.length = 0; + // labelBack = false; // Now resets after a jump label push. 25.03.22.21.36 + + // Parse search parameters properly to check for preview and icon + previewMode = false; + iconMode = false; + if (parsed.search) { + const searchParams = new URLSearchParams(parsed.search); + previewMode = searchParams.has("preview"); + iconMode = searchParams.has("icon"); + } + + // console.log("🔴 PREVIEW OR ICON:", PREVIEW_OR_ICON, "Preview mode:", previewMode, "Icon mode:", iconMode); + // console.log("📑 Search:", parsed.search); + // console.log("🖼️ ICON MODE:", iconMode); + + previewOrIconMode = previewMode || iconMode; + paintings = {}; // Reset painting cache. + // Reset KidLisp persistent paintings cache to avoid stale embedded content + if (globalKidLispInstance?.persistentPaintings) { + globalKidLispInstance.persistentPaintings.clear(); + } + prefetches?.forEach((p) => prefetchPicture(p)); // Prefetch parsed media. + graph.color2(null); // Remove any secondary color that was added from another piece. + // 🐢 Reset turtle state. + turtleAngle = 270; + turtleDown = false; + turtlePosition = { x: screen.width / 2, y: screen.height / 2 }; + + //$api.fps = function (newFps) { + // No longer reset global FPS when loading new pieces + // send({ type: "fps-change", content: undefined }); + //}; + + // 🪧 See if notice needs to be shown. + if ($commonApi.query.notice === "success") { + notice = "PRINTED!"; + noticeColor = ["white", "green"]; + noticeBell(cachedAPI); + } else if ($commonApi.query.notice === "cancel") { + notice = "CANCELLED"; + noticeColor = ["yellow", "red"]; + noticeBell(cachedAPI, { tone: 300 }); + } else if ($commonApi.query.notice === "email-verified") { + notice = "Email verified!"; + noticeColor = ["white", "blue"]; + noticeBell(cachedAPI, { tone: 300 }); + } else if ($commonApi.query.notice?.length > 0) { + notice = $commonApi.query.notice; + noticeColor = ["white", "green"]; + noticeBell(cachedAPI, { tone: 300 }); + } + + if (!alias) { + currentHUDTxt = slug; // Update hud if this is not an alias. + + // Special HUD label formatting for specific pieces + if (slug === "r8dio") { + // Display as "r8Dio" with "8D" in magenta + currentHUDTxt = "r\\255,0,255\\8D\\255,200,220\\io"; + currentHUDPlainTxt = "r8Dio"; + } else { + currentHUDPlainTxt = stripColorCodes(slug); + } + + // Hide HUD label for RGB-only kidlisp pieces (e.g., "255 0 0") + // since they're just background colors and don't need to be displayed + if (sourceCode && lisp?.isValidRGBString && lisp.isValidRGBString(sourceCode.trim())) { + currentHUDTxt = undefined; + currentHUDPlainTxt = undefined; + } + } + if (module.nohud || system === "prompt") { + currentHUDTxt = undefined; + currentHUDPlainTxt = undefined; + } + // Clear original code ID for non-$code pieces (unless this was a $code load) + if (!slug?.startsWith("$")) { + currentOriginalCodeId = undefined; + currentHUDAuthor = null; // Reset author for non-KidLisp pieces + currentHUDHits = null; + } + currentHUDOffset = undefined; // Always reset these to the defaults. + currentHUDTextColor = undefined; + currentHUDStatusColor = "red"; //undefined; + currentHUDButton = undefined; + currentHUDHovered = false; + currentHUDHoverAmount = 0; + currentHUDScrub = 0; + currentHUDLeftPad = 0; + currentHUDQR = null; // Reset QR code when loading new piece + currentHUDQRCells = null; + currentHUDSuperscript = null; // Reset custom superscript + forceTinyHudLabel = false; // Reset tiny label flag + // currentPromptButton = undefined; + + // Push last piece to a history list, skipping prompt and repeats. + if ( + !fromHistory && + currentText && + currentText !== "prompt" && + currentText !== $commonApi.history[$commonApi.history.length - 1] + ) { + $commonApi.history.push(currentText); + } + + currentText = slug; + currentCode = sourceCode; + + // 🧾 Begin piece-run telemetry (batched console capture → /api/piece-log) + try { + pieceRuns.start({ + slug, + params, + colon, + host, + user: USER?.sub || USER?.handle || null, + }); + } catch (err) { + // never break piece load + } + + // Broadcast location change to session server + if (HANDLE && socket?.connected && slug !== "*keep-alive*") { + socket.send("location:broadcast", { + handle: HANDLE, + slug: slug, + user: USER, + }); + if (logs.socket) console.log("📍 Broadcasting location:", slug); + } + + if (screen) screen.created = true; // Reset screen to created if it exists. + + cursorCode = "precise"; // Set default cursor. + + if (firstLoad === true) { + firstLoad = false; + // firstPiece = path; + // firstParams = params; + // firstSearch = search; + } + }; + + send({ + type: "disk-loaded", + content: { + path, + host, + search, + params, + hash, + text: slug, + pieceCount: $commonApi.pieceCount, + pieceHasSound: true, // TODO: Make this an export flag for pieces that don't want to enable the sound engine. 23.07.01.16.40 + background: pieceBackground, // ⏱️ Piece wants sim() to keep running when hidden + // 📓 Could also disable the sound engine if the flag is false on a subsequent piece, but that would never really make practical sense? + fromHistory, + alias, + meta, + taping: $commonApi.rec.loadCallback !== null || $commonApi.rec.recording, // 🎏 Hacky flag. 23.09.17.05.09 + // noBeat: beat === defaults.beat, + clockShortcode, // ⏰ Original *code for URL display (null if not a clock shortcode) + }, + }); + + // Notify parent window of progress via bios relay + const loadCompleteTime = performance.now(); + // console.log("⏰ load() completed at", loadCompleteTime); + // console.log("📢 Disk sending boot-log: loaded"); + send({ + type: "boot-log", + content: `loaded: ${slug || path}` + }); + + return true; // Loaded successfully. +} + +const isWorker = typeof importScripts === "function"; +const _workerReadyTime = performance.now(); +// Only log worker ready if there was significant import time +if (_workerReadyTime - _importStart > 50) { + console.log(`📦 [DISK] Ready: ${(_workerReadyTime - _importStart).toFixed(0)}ms`); +} + +// 📦 Long-poll `/api/version` to detect new deployments. Runs once for the +// life of the worker so the green ↑ badge can appear in any piece. +async function startGlobalVersionPoll() { + if (updatePollStarted) return; + updatePollStarted = true; + + const fetchVersion = async () => { + try { + const res = await fetch("/api/version"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + globalVersionInfo = debug + ? { + ...data, + deployed: data.deployed === "unknown" ? "dev" : data.deployed, + status: "local", + behindBy: 0, + } + : data; + globalRecentCommits = data.recentCommits || []; + try { $commonApi.needsPaint?.(); } catch {} + } catch (e) { + console.warn("📦 Could not fetch version info:", e); + } + }; + + await fetchVersion(); + + if (debug) { + // Local dev: poll once a minute instead of long-polling. + setInterval(fetchVersion, 60 * 1000); + return; + } + + // Long-poll loop: detect new deployments within ~5 seconds. + while (true) { + try { + if (!globalVersionInfo?.deployed) { + await new Promise((r) => setTimeout(r, 5000)); + continue; + } + updatePollController = new AbortController(); + const res = await fetch( + `/api/version?current=${globalVersionInfo.deployed}`, + { signal: updatePollController.signal }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (data.changed === false) continue; // Server already waited ~4s + globalUpdateReady = true; + globalVersionInfo = data; + globalRecentCommits = data.recentCommits || []; + console.log("📦 New deployment detected:", data.deployed); + try { + send({ type: "sw:clear-cache" }); + } catch (e) { + console.warn("📦 Could not request SW cache clear:", e); + } + try { $commonApi.needsPaint?.(); } catch {} + break; // Stop polling — update is available + } catch (e) { + if (e.name === "AbortError") break; + console.warn("📦 Version poll error:", e); + await new Promise((r) => setTimeout(r, 10000)); + } + } +} + +// ***Bootstrap*** +// Start by responding to a load message, then change +// the message response to makeFrame. +if (isWorker) { + // Signal to bios.mjs that disk.mjs has loaded successfully + postMessage({ type: "worker-ready" }); + + onmessage = (e) => { + // DEBUG: Log DAW messages only when debugging + if (e.data?.type?.startsWith?.("daw:") && logs.daw) { + console.log("🎹 WORKER:", e.data?.type, e.data); + } + makeFrame(e); + }; +} else { + noWorker.onMessage = (d) => makeFrame({ data: d }); +} + +// The main messaging function to comumunicate back with the main thread. +function send(data, shared = []) { + if (isWorker) { + if (shared[0] === undefined) shared = []; + postMessage(data, shared); + } else { + if (noWorker.postMessage) { + noWorker.postMessage({ data }); + } else { + console.warn("⚠️ noWorker.postMessage is not defined yet, cannot send:", data); + } + } +} + +// Used to subscribe to live coding / development reloads. +let codeChannel, codeChannelAutoLoader; +// The channel named by the piece currently loaded from a handle route, +// held separately from this browser's own `codeChannel` because it belongs +// to whoever published the piece. Never persisted. +let pieceCodeChannel; + +// Queue for export events that arrive when actEvents isn't available +let pendingExportEvents = []; + +// 4. ✔ Respond to incoming messages, and probably produce a frame. +// Boot procedure: +// First `paint` happens after `boot`, then any `act` and `sim`s each frame +// before `paint`ing occurs. One `sim` always happens after `boot` and before +// any `act`. `paint` can return false to stop drawing every display frame, +// then, it must be manually restarted via `needsPaint();`). 2022.01.19.01.08 +// 🔥 +// TODO: makeFrame is no longer a great name for this function, which actually +// receives every message from the main thread, one of which renders a +// frame. +// TODO: Make simple needsPaint example. +// TODO: Try to remove as many API calls from here as possible. + +async function makeFrame({ data: { type, content } }) { + // DEBUG: Log ALL messages starting with "pedal:" or "daw:" + if (type?.startsWith?.("pedal:") || type?.startsWith?.("daw:") || type?.startsWith?.("spreadnob:")) { + console.log("🎹🎹🎹 makeFrame received:", type, JSON.stringify(content)); + } + + // Handle permission responses from bios.mjs + if (type === "permission-response") { + resolvePermissionRequest(content.requestId, content.granted); + return; + } + + // Navigate to a new piece (device.html slideshow uses this for instant transitions) + if (type === "navigate") { + const target = content?.to; + console.log("🧭 Navigate received:", target, "jump available:", !!$commonApi?.jump, "loading:", loading, "leaving:", leaving); + if (target && $commonApi?.jump) { + $commonApi.jump(target); + } else { + console.warn("🧭 Navigate failed: target=", target, "$commonApi=", !!$commonApi, "jump=", !!$commonApi?.jump); + } + return; + } + + // Native DOM prompt bridge (prompt.ac). Keep the TextInput and prompt piece + // as the source of truth while leaving their low-resolution composition off. + if (type === "prompt:dom-input") { + const promptInput = $commonApi?.system?.prompt?.input; + if (!promptInput || $commonApi?.slug !== "prompt") { + // The bridge comes up before the prompt piece is current, so a submit + // sent in that window must not vanish — reroute it through an autorun + // prompt load, which executes the command once the piece is ready. + // Never jump mid-load though: interrupting an in-flight load wedges the + // loader ("Already loading"), and the shell re-sends once ac:piece says + // the prompt is live. + const text = String(content?.text ?? "").trim(); + if (content?.submit === true && text && !loading && !leaving) { + $commonApi?.jump?.(`prompt~${text}~!autorun`); + } + return; + } + + const text = String(content?.text ?? ""); + promptInput.text = text; + promptInput.addUserText?.(text); + promptInput.runnable = text.trim().length > 0; + promptInput.canType = content?.active !== false; + if (promptInput.enter?.btn) { + promptInput.enter.btn.disabled = !promptInput.runnable; + } + promptInput.snap?.(); + $commonApi.needsPaint?.(); + + if (content?.submit === true && promptInput.runnable) { + await promptInput.run($commonApi.store); + } + return; + } + + // 🎵 ac-electron drag-drop: a file was dropped onto the app icon or + // window. Stash it on system.droppedFile, fire a dropped:file act event + // for the live piece, and auto-jump to `dj` unless a player piece (dj or + // play) is already running and will swap tracks itself. + if (type === "dropped:file") { + if (!content || !content.url) return; + if ($commonApi?.system) $commonApi.system.droppedFile = content; + actAlerts.push("dropped:file"); + try { + const slug = $commonApi?.slug || ""; + const inPlayer = ["dj", "play"].some( + (p) => + slug === p || + slug.startsWith(p + "~") || + slug.startsWith(p + ":") || + (currentPath && currentPath.endsWith("/" + p)), + ); + if (!inPlayer && $commonApi?.jump) { + $commonApi.jump("dj"); + } + } catch (e) { + console.warn("🎵 dropped:file auto-jump failed:", e?.message || e); + } + return; + } + + // Warm the kidlisp code cache with pre-fetched sources from parent + if (type === "warm-kidlisp-cache") { + const codes = content?.codes; + if (codes) { + let count = 0; + for (const [codeId, source] of Object.entries(codes)) { + saveCodeToAllCaches(codeId, source); + count++; + } + console.log(`🔥 Warmed kidlisp cache with ${count} codes`); + } + return; + } + + // Runs once on boot. + if (type === "piece-source") { + // Late arrival of the shell's prefetched piece source (see init-from-bios). + if (content?.slug && typeof content.code === "string" && !pieceCodeCache.has(content.slug)) { + pieceCodeCache.set(content.slug, { code: content.code, type: "mjs" }); + } + return; + } + + if (type === "init-from-bios") { + debug = content.debug; + setDebug(content.debug); + ROOT_PIECE = content.rootPiece; + hdPixelRatio = content.pixelRatio || 1; // 🖼️ For the hd() layer's scale. + if (content.bootId && typeof self !== "undefined") self.acBOOT_ID = content.bootId; + // 🎁 Piece source the shell fetched at parse time: seed the piece-code + // cache so load() takes its "Using cached code" path and never fetches. + if (content.pieceSource?.slug && typeof content.pieceSource.code === "string") { + pieceCodeCache.set(content.pieceSource.slug, { code: content.pieceSource.code, type: "mjs" }); + } + + // 📦 Kick off global version polling (skip preview/icon/pack/objkt where + // the user can't reload anyway). + if ( + !content.previewOrIcon && + !content.objktMode && + !content.packMode + ) { + startGlobalVersionPoll(); + } + + if (content.debugHud) { + globalThis.debugHudHitbox = true; + console.log("🔍 HUD Hitbox Debug: ON (init-from-bios)"); + $commonApi.needsPaint(); + } + + // Store noauth flag for iframe embedding (kidlisp.com) + // In noauth mode, mark session as started immediately so piece loading + // doesn't wait for the session:started message (resilience when session server is down) + if (content.noauth) { + globalThis.NOAUTH_MODE = true; + sessionStarted = true; + } + + USER = content.user; + // if (USER) socket.send("login", { user: USER }); + + LAN_HOST = content.lanHost; + SHARE_SUPPORTED = content.shareSupported; + PREVIEW_OR_ICON = content.previewOrIcon; + VSCODE = content.vscode; + setPackMode( + Boolean( + content.objktMode || + content.packMode || + (typeof globalThis !== "undefined" && globalThis.acPACK_MODE), + ), + ); // Store PACK/OBJKT mode flag in worker global scope + if (content.objktKidlispCodes) { + const globalScope = (function() { + if (typeof globalThis !== 'undefined') return globalThis; + if (typeof self !== 'undefined') return self; + if (typeof global !== 'undefined') return global; + return {}; + })(); + globalScope.objktKidlispCodes = content.objktKidlispCodes; + } + + TV_MODE = content.resolution?.tv === true; + DEVICE_MODE = content.resolution?.device === true; + SOLO_MODE = content.resolution?.solo === true; + + // Parse highlight parameter + const highlightParam = content.resolution?.highlight; + if (highlightParam) { + HIGHLIGHT_MODE = true; + if (highlightParam !== true && typeof highlightParam === 'string') { + // If it's a color string, parse it + if (highlightParam.startsWith('#')) { + // Convert hex to RGB + const hex = highlightParam.slice(1); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + HIGHLIGHT_COLOR = `${r},${g},${b}`; + } else if (highlightParam.includes(',')) { + // Already RGB format + HIGHLIGHT_COLOR = highlightParam; + } else { + // Named color or other format - use CSS color parser or default + HIGHLIGHT_COLOR = highlightParam; + } + } + } else { + HIGHLIGHT_MODE = false; + } + + // Parse perf parameter for KidLisp performance HUD + PERF_MODE = content.resolution?.perf === true; + + // Parse auto-scale parameter for automatic density scaling + AUTO_SCALE_MODE = content.resolution?.autoScale === true; + SPOOF_AUDIO_MODE = content.resolution?.spoofaudio === true; + NOGAP_MODE = content.resolution?.gap === 0; + + microphone.permission = content.microphonePermission; + + $commonApi.canShare = SHARE_SUPPORTED; + $commonApi.vscode = VSCODE; // Add vscode flag to the common api. + $commonApi.net.lan = LAN_HOST; + $commonApi.user = USER; + $commonApi.net.iframe = content.iframe; + $commonApi.net.sandboxed = content.sandboxed; + $commonApi.net.tvMode = TV_MODE; // Add TV mode flag to common API + + // 🔑 Hand `/api/ask` a way to learn who is asking. Signing in or out is + // picked up on the next ask because the provider resolves the token at call + // time rather than caching one here. + setAskTokenProvider(() => $commonApi.authorize()); + + codeChannelAutoLoader = null; + codeChannel = await store.retrieve("code-channel"); + if (!codeChannel || codeChannel?.length === 0) { + codeChannel = nanoid(); + store["code-channel"] = codeChannel; + store.persist("code-channel"); + } + + // console.log("💻 Code channel:", codeChannel); + + // Always send the codeChannel up to any parent window. + codeChannelAutoLoader = () => { + send({ + type: "post-to-parent", + content: { type: "setCode", value: codeChannel }, + }); + codeChannelAutoLoader = null; + }; + + // 🚀 Eagerly initialize GPU effects module for faster spin/blur/zoom + // Currently GPU effects are disabled due to Y-flip issues, so this is non-blocking + // TODO: Re-enable await when GPU effects Y-coordinate handling is fixed + if (typeof graph.initGpuEffects === 'function') { + graph.initGpuEffects().catch(e => { + console.warn('🎮 GPU Effects initialization failed:', e); + }); + } + + // console.log("Init:", content); + // await handle(); // Get the user's handle. + // console.log("🟢 Loading after preamble:", content.parsed); + + originalHost = content.parsed.host; + loadAfterPreamble = () => { + const loadCallStartTime = performance.now(); + // console.log("⏰ loadAfterPreamble called at", loadCallStartTime); + loadAfterPreamble = null; + load(content.parsed); // Load after some of the default frames run. + }; + + if (PREVIEW_OR_ICON) { + console.log("💬 Chat disabled, just grabbing screenshots. 😃"); + } else if (getPackMode()) { + // Skip chat connection in PACK mode - offline bundle + } else { + chatClient.connect("system"); // Connect to `system` chat. + } + + send({ type: "disk-defaults-loaded" }); + return; + } + + // Enable KidLisp-only console channel (forwarded from boot.mjs). + if (type === "kidlisp-console-enable") { + // Call the kidlisp.mjs enableKidlispConsole() to set the module-level flag + // This is needed because bios.mjs imports isKidlispConsoleEnabled() from kidlisp.mjs + enableKidlispConsole(); + return; + } + + // � Toggle HUD hitbox debug visualization from main thread + if (type === "debug:hud-hitbox:toggle") { + globalThis.debugHudHitbox = !globalThis.debugHudHitbox; + console.log("🔍 HUD Hitbox Debug:", globalThis.debugHudHitbox ? "ON" : "OFF"); + $commonApi.needsPaint(); + return; + } + + // �🛑 Watchdog ping - respond immediately with pong + if (type === "watchdog:ping") { + send({ type: "watchdog:pong", content: { timestamp: content?.timestamp } }); + return; + } + + if (type === "logout:broadcast:subscribe") { + console.log("🏃‍♂️ Broadcasting logout:", content); + socket?.send("logout:broadcast:subscribe", content); + return; + } + + // Get visualViewport update, for keyboard overlays, etc. + if (type === "viewport-height:changed" && booted) { + const $api = cachedAPI; + const data = { ...content }; + Object.assign(data, { + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + return; + } + + // Receive a midi input message. + if (type === "midi:keyboard" && booted) { + // console.log("🎹 Keyboard:", content.data); + const $api = cachedAPI; + const data = { ...content }; + Object.assign(data, { + device: "midi:keyboard", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + return; + } + + if (type === "audio:sample-rate") { + AUDIO_SAMPLE_RATE = content; + return; + } + + // 🎹 DAW Sync messages (from Max for Live via bios.mjs) + // Use persistentDawState since $commonApi.sound gets recreated every frame + if (type === "daw:tempo") { + // Send acknowledgement back to main thread for debugging + send({ type: "daw:tempo:ack", content: { bpm: content?.bpm } }); + sound.bpm = content.bpm; + // Store in persistent state (survives frame updates) + persistentDawState.bpm = content.bpm; + return; + } + + if (type === "daw:transport") { + // Send acknowledgement back to main thread for debugging + send({ type: "daw:transport:ack", content: { playing: content?.playing } }); + // Store in persistent state (survives frame updates) + persistentDawState.playing = content.playing; + return; + } + + if (type === "daw:phase") { + // Store in persistent state (survives frame updates) + persistentDawState.time = content.time; + return; + } + + if (type === "daw:samplerate") { + // Store in persistent state (survives frame updates) + persistentDawState.sampleRate = content.rate; + return; + } + + // 🎛️ Spreadnob messages (from M4L via bios) — stored on persistentDawState + if (type === "spreadnob:note") { + persistentDawState.snNote = content.note; + return; + } + if (type === "spreadnob:target") { + persistentDawState.snTarget = content.name; + return; + } + if (type === "spreadnob:value") { + persistentDawState.snValue = content.value; + return; + } + if (type === "spreadnob:active") { + persistentDawState.snActive = content.active; + return; + } + if (type === "spreadnob:min") { + persistentDawState.snMin = content.min; + return; + } + if (type === "spreadnob:max") { + persistentDawState.snMax = content.max; + return; + } + if (type === "spreadnob:state") { + persistentDawState.snRawNote = content.raw; + persistentDawState.snNormalizedNote = content.note; + persistentDawState.snShift = content.shift; + persistentDawState.snLocked = content.locked; + persistentDawState.snAmbiguous = content.ambiguous; + return; + } + if (type === "spreadnob:qwerty-range") { + persistentDawState.snQwertyLow = content.low; + persistentDawState.snQwertyHigh = content.high; + return; + } + + // 🎸 Pedal messages (for audio effect visualization) + if (type === "pedal:peak") { + // Forward to the piece's receive function if it exists + if (typeof receive === "function") { + receive({ type: "pedal:peak", peak: content.peak }); + } + return; + } + + if (type === "pedal:envelope") { + // Forward to the piece's receive function if it exists + if (typeof receive === "function") { + receive({ type: "pedal:envelope", ...content }); + } + return; + } + + // Update the logged in user after initialization. + if (type === "session:started") { + // console.log("🟢 Session starting..."); + USER = content.user; + $commonApi.user = USER; // User will be set to "null" here + // it it doesn't exist. + + if (USER) { + // console.log("Getting handle..."); + await handle(); // Get the user's handle. + // console.log("Handle recived:", HANDLE); + const displayName = HANDLE || USER.email; + console.log(`%c🔐 auth%c Welcome back %c${displayName}`, + 'background: #ec4899; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold;', + 'color: inherit;', + 'color: #ec4899; font-weight: bold;' + ); + // Log tab ID for debugging + if (!$commonApi._tabId) { + $commonApi._tabId = Math.random().toString(36).substr(2, 9); + } + // Broadcast to other tabs... + $commonApi.broadcast("login:success"); + } else { + // console.log("🔐 You are not logged in."); + // Generate tab ID for debugging (anonymous user) + if (!$commonApi._tabId) { + $commonApi._tabId = Math.random().toString(36).substr(2, 9); + } + } + sessionStarted = true; + diskTimings.sessionStarted = Math.round(performance.now() - diskTimingStart); + // Silent: sessionStarted + return; + } + + if (type === "microphone-permission") { + microphone.permission = content; + microphone.permissionPending = false; + microphone.permissionChecked = true; + if ( + content === "granted" && + globalKidLispInstance?.containsMicrophoneFunctions?.( + globalKidLispInstance.ast, + ) + ) { + globalKidLispInstance.requestMicrophoneConnection?.($activePaintApi); + } + return; + } + + // Handle live reload from kidlisp.com editor + if (type === "piece-reload") { + log.piece.log("Reloading with new code:", content.source?.substring(0, 30) + "..."); + if ($commonApi.reload) { + $commonApi.reload({ + source: content.source, + codeId: content.codeId, // The $code identifier (e.g., "inz") + createCode: content.createCode, + authToken: content.authToken, // Pass token from kidlisp.com login + enableTrace: content.enableTrace, // Enable execution trace for visualization + language: content.language, + ext: content.ext, + liveName: content.liveName, + }); + } else { + // Queue the reload request to be processed once the piece is ready + // Always update the queue with the latest code (overwrite previous) + log.piece.warn("Reload function not ready yet, queuing:", content.source?.substring(0, 20)); + pendingPieceReload = { + source: content.source, + codeId: content.codeId, + createCode: content.createCode, + authToken: content.authToken, + enableTrace: content.enableTrace, + language: content.language, + ext: content.ext, + liveName: content.liveName, + }; + } + return; + } + + // 🎚️ Slide mode update - re-evaluate code but preserve buffers/state + if (type === "piece-slide") { + if (content.source) { + // Use the exported slideUpdate from kidlisp.mjs to update the correct instance + // (The one used by lisp.module() for loaded pieces) + lisp.slideUpdate(content.source); + } + return; + } + + // 🎵 Audio data from jukebox - update KidLisp audio state + if (type === "kidlisp-audio") { + lisp.updateKidLispAudio?.(content); + return; + } + + // Confirming if the pen has been locked or unlocked by the Pointer Lock API. + if (type === "pen:locked" || type === "pen:unlocked") { + actAlerts.push(type); + return; + } + + // Capture a link from the docs system. + if (type === "docs:link") { + console.log("📚 Doc link captured:", content); + $commonApi.jump("prompt~" + content); + return; + } + + // Capture the browser scroll wheel / scroll effect. + if (type === "scroll") { + if (!cachedAPI) return; // Guard against undefined cachedAPI + const $api = cachedAPI; + const data = { ...content }; + Object.assign(data, { + device: "wheel", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + return; + } + + // Jump to any piece slug from the bios. + if (type === "jump") { + let ahistorical, alias; + if (content.ahistorical === undefined) { + ahistorical = true; + } else ahistorical = content.ahistorical; + if (content.alias === undefined) { + alias = true; + } else alias = content.alias; + $commonApi.jump(content.piece, ahistorical, alias); + return; + } + + // Create a notice. + if (type === "notice") { + $commonApi.notice(content, ["white", "maroon"]); + return; + } + + if (type === "loading-complete") { + leaving = false; + hotSwap?.(); // Actually swap out the piece functions and reset the state. + loading = false; + loadingStartTime = null; + noPaint = false; // 🎨 Ensure first frame paints after piece swap + return; + } + + if (type === "udp:receive") { + udp.receive(content); + return; + } + + if (type === "udp:connected") { + console.log("🩰 DISK received udp:connected! Setting udp.connected = true"); + udp.connected = true; + updateHUDStatus(); + $commonApi.needsPaint(); + return; + } + + if (type === "udp:disconnected") { + console.log("🩰 DISK received udp:disconnected! Setting udp.connected = false"); + udp.connected = false; + updateHUDStatus(); + $commonApi.needsPaint(); + return; + } + + if (type === "microphone-disconnect") { + microphone.connected = false; + actAlerts.push("microphone-disconnect"); + return; + } + + // if (type === "hand-tracking-data") { + // $commonApi.hand = { mediapipe: content }; + // return; + // } + + // Load the source code for a dropped `.mjs` or KidLisp file. + if (type === "dropped:piece") { + // Pass forceKidlisp=true if this was detected as KidLisp (e.g., from .lisp.html bundle) + const forceKidlisp = content.isKidLisp === true; + // Pass forceP5=true if this was a dropped .js sketch (run via the p5 worker). + const forceP5 = content.isP5 === true; + load(content, false, false, true, undefined, forceKidlisp, forceP5); + return; + } + + // Run dropped text as a prompt command (navigation / piece name). + if (type === "dropped:text") { + $commonApi.jump(content.text); + return; + } + + if (type === "dropped:bitmap") { + if (currentPath === "aesthetic.computer/disks/prompt") { + $commonApi.system.nopaint.replace( + { system: $commonApi.system, store, needsPaint: $commonApi.needsPaint }, + content.source, + ); + } else { + const $api = cachedAPI; + const data = { name: content.name, painting: content.source }; + Object.assign(data, { + device: "none", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + } + return; + } + + // Handle dropped ALS files - route to piece receive function if available + if (type === "dropped:als") { + console.log("🎵 DISK.MJS: Received dropped:als message:", content); + + // First try to call the piece's receive function if it exists + if (typeof receive === "function") { + console.log("🎵 DISK.MJS: Calling piece receive function"); + try { + receive({ type, content }); + } catch (e) { + console.warn("🎵 DISK.MJS: Piece receive function error:", e); + } + } else { + console.log("🎵 DISK.MJS: No piece receive function found, triggering act event"); + // Fall back to act event pattern + const $api = cachedAPI; + if ($api) { + const data = { + name: content.name, + xmlData: content.xmlData, + type: "dropped:als" + }; + Object.assign(data, { + device: "none", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + } + } + return; + } + + // Handle dropped WAV files - route to piece receive function if available + if (type === "dropped:wav") { + console.log("🎵 DISK.MJS: Received dropped:wav message:", content); + + // First try to call the piece's receive function if it exists + if (typeof receive === "function") { + console.log("🎵 DISK.MJS: Calling piece receive function for WAV"); + try { + receive({ type, content }); + } catch (e) { + console.warn("🎵 DISK.MJS: Piece receive function error for WAV:", e); + } + } else { + console.log("🎵 DISK.MJS: No piece receive function found for WAV, triggering act event"); + // Fall back to act event pattern + const $api = cachedAPI; + if ($api) { + const data = { + name: content.name || content.originalName, + id: content.id, + size: content.size, + type: "dropped:wav" + }; + Object.assign(data, { + device: "none", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + } + } + return; + } + + // 🗣️ An act that fires when an utterance has ended in the Web Speech API. + if (type === "speech:completed") { + actAlerts.push("speech:completed"); + return; + } + + // 🗣️ A cloud utterance that never came back: `content` carries the HTTP + // status (0 for a network drop) and the provider, so a piece can tell + // "sign in" apart from "the voice service is down". + if (type === "speech:error") { + actAlerts.push({ name: "speech:error", content }); + return; + } + + // 🎵 Clock piece sent its cached code for QR display + if (type === "clock:cached") { + cachedClockCode = content?.code || null; + console.log(`🎵 Clock cached code received: *${cachedClockCode}`); + return; + } + + // When inputting text into the prompt. + if ( + type === "prompt:text:replace" || + type === "prompt:text:select" || + type === "prompt:text:cursor" + ) { + const $api = cachedAPI; + const data = { ...content }; + Object.assign(data, { + device: "none", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + return; + } + + // Handles: clipboard:paste:pasted, clipboard:paste:pasted:empty + if (type.startsWith("paste:pasted")) { + actAlerts.push("clipboard:" + type); + return; + } + + if (type === "paste:failed") { + actAlerts.push("clipboard:paste:failed"); + return; + } + + if (type === "copy:copied") { + actAlerts.push("clipboard:copy:copied"); + return; + } + + if (type === "aesthetic-parent:focused") { + actAlerts.push("aesthetic-parent:focused"); + return; + } + + if (type === "copy:failed") { + actAlerts.push("clipboard:copy:failed"); + return; + } + + if (type === "upload:progress") { + serverUploadProgressReporter?.(content); // Report file upload progress if needed. + + // Also forward to current piece (especially video.mjs for tape uploads) + if (booted && receive !== defaults.receive) { + console.log(`📤 Forwarding upload:progress to piece: ${content}`); + try { + receive({ + type: "upload:progress", + content, + is: (name) => name === "upload:progress" + }); + } catch (error) { + console.warn("📤 Error forwarding upload:progress to piece:", error); + } + } + + return; + } + + if (type === "upload:status") { + // Forward backend upload status stages to the active piece. + if (booted && receive !== defaults.receive) { + try { + receive({ + type: "upload:status", + content, + is: (name) => name === "upload:status", + }); + } catch (error) { + console.warn("📤 Error forwarding upload:status to piece:", error); + } + } + return; + } + + if (type === "focus-change") { + if (!cachedAPI) return; // Hacky... 23.04.21.14.59 + const $api = cachedAPI; + if (content !== inFocus) { + inFocus = content; + const data = {}; + Object.assign(data, { + device: "none", + is: (e) => e === (inFocus === true ? "focus" : "defocus"), + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + } + } + + // Max for Live → piece: track color push (bios forwards as this + // message type after Max's live.observer fires into jweb via + // window.acSetLiveTrackColor). Pieces read e.trackColor in act(). + if (type === "live:track-color") { + if (!cachedAPI) return; + const $api = cachedAPI; + const data = { + device: "live", + trackColor: content, + is: (e) => e === "live:track-color", + }; + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure (live:track-color)", e); + } + } + + // Build env info from bios: { packMode, packGit, packDate, latestCommit } + // Piece reads e.env in act() to know whether it's in the packed offline + // bundle or the live URL, and whether its build hash matches the latest + // the server is serving (for an "update available" nudge). + if (type === "env:info") { + if (!cachedAPI) return; + const $api = cachedAPI; + const data = { + device: "env", + env: content, + is: (e) => e === "env:info", + }; + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure (env:info)", e); + } + } + + if (type === "visibility-change") { + // 🧨 Just in case of a regression... 23.06.02.21.12 + // Because the `bios` focus event changed from visibility behavior. + // if (!lastActAPI) return; // Hacky... 23.04.21.14.59 + // const $api = lastActAPI; // Focus change events have an empty API. + // if (content !== inFocus) { + // inFocus = content; + // const data = {}; + // Object.assign(data, { + // device: "none", + // is: (e) => e === (inFocus === true ? "focus" : "defocus"), + // }); + // $api.event = data; + // try { + // act($api); + // } catch (e) { + // console.warn("️ ✒ Act failure...", e); + // } + // } + + // Reset KidLisp timing state when becoming visible again + // This prevents animation "catch up" after tab was hidden + // Skip reset for pieces with background mode — they kept running while hidden. + if (content === true && visible === false && globalKidLispInstance && !pieceBackground) { + // Becoming visible - reset timing expressions + globalKidLispInstance.lastSecondExecutions = {}; + globalKidLispInstance.instantTriggersExecuted = {}; + if (globalKidLispInstance.sequenceCounters) { + globalKidLispInstance.sequenceCounters.clear(); + } + } + + visible = content; + } + + if (type === "before-unload") { + // This has to be synchronous (no workers) to work, and is also often unreliable. + // I should not design around using this event, other than perhaps + // sending a beacon at the end. 22.11.03.14.53 + // See also: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon + + /* + try { + leave({ store, screen, system: $commonApi.system }); // Trigger leave. + } catch (e) { + console.warn("👋 Leave failure...", e); + } + */ + return; + } + + // Get the updated device motion. + if (type === "motion:update") { + $commonApi.motion.on = true; + $commonApi.motion.current = content; + return; + } + + if (type === "motion:enabled") { + $commonApi.motion.on = true; + return; + } + + // 🔷 Tezos wallet state updates from bios.mjs + if (type === "wallet:state") { + // Update the wallet state from bios (full state broadcast) + Object.assign($commonApi.wallet._state, content); + // Clear any error after successful update + if (content.connected) delete $commonApi.wallet._state.error; + log.wallet.debug("Wallet state updated:", $commonApi.wallet._state); + return; + } + + // 🔷 Wallet pairing URI response (for mobile QR code) + if (type === "wallet:pairing-uri-response") { + if ($commonApi.wallet._pairingCallback) { + $commonApi.wallet._pairingCallback(content); + $commonApi.wallet._pairingCallback = null; + } + return; + } + + // 🔷 Wallet sign response + if (type === "wallet:sign-response") { + if ($commonApi.wallet._signCallback) { + $commonApi.wallet._signCallback(content); + $commonApi.wallet._signCallback = null; + } + return; + } + + if (type === "gpu-rendered-once") { + $commonApi.gpuReady = true; + return; + } + + if (type === "gpu-forms-removed") { + // Delete forms from the sent list that have been removed from the GPU scene. + content.forEach((id) => { + formsToClear.push(id); + }); + return; + } + + if (type === "dark-mode") { + darkMode(content.enabled); + return; + } + + if (type === "gpu-config") { + graph.setGpuConfig(content); + return; + } + + if (type === "forms:baked") { + //console.log("🍞 Forms baked:", content); + //noPaint = false; + + // if (content.pixels) { + // graph.paste(content, 0, 0, 1, true); + // } + + // paintFormsResolution?.(); + return; + } + + // Media Recorder Events + + if (type === "recorder:transcode-progress") { + console.log("📼 🚨 DISK.MJS caught transcode-progress EARLY - updating rec.printProgress:", content); + $commonApi.rec.printProgress = content; + if (content === 1) { + send({ type: "signal", content: "recorder:transcoding-done" }); + // TODO: Is this the best place for this signal to be sent? + // Maybe it should go back in the BIOS? 22.08.19.13.44 + } + // DON'T return here - let it continue to the piece forwarding logic below! + // return; // ❌ REMOVED - this was preventing messages from reaching the piece + } + + // Forward recorder export events to the current piece, but only when not in export mode + // During export (when actEvents is undefined), let events pass through directly to main thread + if ( + type === "recorder:export-status" || + type === "recorder:export-progress" || + type === "recorder:transcode-progress" || + type === "recorder:export-complete" || + type === "tape:posted" || + type === "tape:post-error" || + type === "tape:download-progress" || + type === "tape:load-progress" || + type === "tape:preloaded" || + type === "tape:preload-error" || + type === "tape:frames" || + type === "tape:info-reply" || // tape:get-info answer → video.mjs tapeInfo + type === "tape:audio-context-state" || + type === "audio:latency-info" || + type === "audio:probe" || + type === "audio:reinit-complete" || + type === "tape:playback-progress" || + // Video-backed (kind:"mp4") tape playback callbacks → video.mjs. + type === "tape:mp4-ready" || + type === "tape:mp4-playing" || + type === "tape:mp4-paused" || + // Camera-roll clip upload result → chat.mjs (insert !code). + type === "chat:video-tape:posted" + ) { + // Check if actEvents exists without causing ReferenceError + try { + const hasActEvents = typeof actEvents !== 'undefined' && actEvents && Array.isArray(actEvents); + + // If actEvents is available, route to the piece as normal + if (hasActEvents) { + const event = { + is: (eventType) => eventType === type, + type: type, + content: content, + progress: type === "recorder:transcode-progress" ? content : undefined + }; + + actEvents.push(event); + if (debug) console.log("📼 ✅ Used actEvents for export event:", type); + return; // Don't let it pass through to main thread + } + } catch (e) { + // actEvents doesn't exist - that's fine, we'll call receive directly + } + + // If actEvents isn't available, try calling the piece's receive function directly + // But only if the piece is booted AND has a custom receive (not the default) + if (typeof receive === "function" && booted && receive !== defaults.receive) { + const event = { + is: (eventType) => eventType === type, + type: type, + content: content, + progress: type === "recorder:transcode-progress" ? content : undefined + }; + try { + if (!getPackMode()) console.log("📼 ✅ Calling receive directly for export event:", type); + receive(event); + return; // Successfully delivered to piece + } catch (e) { + console.warn("📼 ❌ Error calling receive:", e); + } + } else { + // Log why we're NOT calling receive + if (!getPackMode()) { + const reasons = []; + if (typeof receive !== "function") reasons.push("receive not a function"); + if (!booted) reasons.push("not booted"); + if (receive === defaults.receive) reasons.push("using default receive"); + console.log(`📼 ⏸️ Cannot call receive for ${type}: ${reasons.join(", ")}`); + } + } + + // If we can't deliver it (piece not booted or using default receive), queue it for later + const event = { + is: (eventType) => eventType === type, + type: type, + content: content, + progress: type === "recorder:transcode-progress" ? content : undefined + }; + pendingExportEvents.push(event); + if (!getPackMode()) { + console.log("📼 📥 Queued export event for later delivery:", type, "queue size:", pendingExportEvents.length); + } + return; + } + + if ( + type === "recorder:rolling:started" || + type === "recorder:rolling:resumed" + ) { + $commonApi.rec.recording = true; + $commonApi.rec.cleanMode = content.cleanMode || false; + $commonApi.rec.rollingCallback?.(content.time); + return; + } + + if (type === "recorder:rolling:ended") { + $commonApi.rec.recording = false; + $commonApi.rec.recorded = true; // Also cleared when a recording "slates". + // Don't reset cleanMode here - it's needed during GIF export/playback + + if ($commonApi.rec.cutCallback) { + try { + $commonApi.rec.cutCallback(); + // Clear the callback after execution to prevent duplicate calls + $commonApi.rec.cutCallback = null; + } catch (error) { + console.error(`🎬 ❌ Error in cutCallback:`, error); + console.error(`🎬 ❌ Stack trace:`, error.stack); + } + } else { + console.warn(`🎬 ⚠️ No cutCallback available when rolling ended`); + // Fallback: try to jump to video directly if no callback is set + $commonApi.jump("video"); + } + return; + } + + if (type === "recorder:printing:started") { + return; + } + + if (type === "recorder:printed") { + $commonApi.rec.printed = true; + $commonApi.rec.printCallback?.(content); + return; + } + + if (type === "recorder:presented") { + $commonApi.rec.presenting = true; + $commonApi.rec.presentProgress = 0; + $commonApi.rec.tapeProgress = 0; + // Also surface as an act event — cap uses it to drop its frozen frame + // and reveal the already-playing tape underneath while `video` loads. + actAlerts.push("recorder:presented"); + return; + } + + if (type === "recorder:presented:failure") { + $commonApi.rec.presenting = false; + $commonApi.rec.presentProgress = 0; + $commonApi.rec.tapeProgress = 0; + return; + } + + if (type === "recorder:frames-response") { + // First chunk - initialize accumulation + if (content.totalChunks > 1) { + console.log(`📦 Receiving chunked frames: chunk 1/${content.totalChunks}`); + $commonApi.rec.frameChunks = [content.frames]; + $commonApi.rec.totalChunks = content.totalChunks; + $commonApi.rec.receivedChunks = 1; + $commonApi.rec.rawAudio = content.rawAudio; + } else { + // Single chunk, call callback immediately + $commonApi.rec.framesCallback?.(content); + } + return; + } + + if (type === "recorder:frames-chunk") { + // Subsequent chunks - accumulate + if ($commonApi.rec.frameChunks) { + $commonApi.rec.frameChunks.push(content.frames); + $commonApi.rec.receivedChunks++; + + console.log(`📦 Received chunk ${$commonApi.rec.receivedChunks}/${$commonApi.rec.totalChunks}`); + + // All chunks received - combine and call callback + if ($commonApi.rec.receivedChunks === $commonApi.rec.totalChunks) { + const allFrames = $commonApi.rec.frameChunks.flat(); + console.log(`✅ All ${$commonApi.rec.totalChunks} chunks received, total frames: ${allFrames.length}`); + + $commonApi.rec.framesCallback?.({ + frames: allFrames, + rawAudio: $commonApi.rec.rawAudio + }); + + // Clean up + delete $commonApi.rec.frameChunks; + delete $commonApi.rec.totalChunks; + delete $commonApi.rec.receivedChunks; + delete $commonApi.rec.rawAudio; + } + } + return; + } + + if (type === "recorder:present-progress") { + $commonApi.rec.presentProgress = content; + return; + } + + if (type === "recorder:present-playing") { + $commonApi.rec.playing = true; + return; + } + + if (type === "recorder:present-paused") { + $commonApi.rec.playing = false; + return; + } + + if (type === "recorder:unpresented") { + $commonApi.rec.presenting = false; + $commonApi.rec.presentProgress = 0; + $commonApi.rec.tapeProgress = 0; + return; + } + + if (type === "signal") { + signals.push(content); + return; + } + + if (type === "piece:act-alert") { + if (content !== undefined && content !== null) { + if (typeof content === "string") { + actAlerts.push(content); + } else if (typeof content.name === "string" && content.name.length > 0) { + const { name, ...extra } = content; + if (keys(extra).length > 0) { + actAlerts.push({ name, ...extra }); + } else { + actAlerts.push(name); + } + } + } + return; + } + + // Menu Fighter keeps RTCPeerConnection on the main thread, then forwards + // signaling, channel state, and input packets back through BIOS. Deliver + // those messages through the normal piece event queue so worker pieces can + // consume them in act() without giving arbitrary BIOS messages that power. + if (type === "fight:rtc:event") { + actAlerts.push({ name: type, content }); + return; + } + + if (type === "store:retrieved") { + storeRetrievalResolutions[content.key]?.(content.data); + delete storeRetrievalResolutions[content.key]; + return; + } + + if (type === "store:deleted") { + storeDeletionResolutions[content.key]?.(content.data); + delete storeDeletionResolutions[content.key]; + return; + } + + // USB flash support (Electron only) + if (type === "usb:devices-listed") { + if (usbDeviceListResolution) { + usbDeviceListResolution(content); + usbDeviceListResolution = null; + } + return; + } + + if (type === "usb:flash-progress") { + actAlerts.push({ name: "usb:flash-progress", ...content }); + return; + } + + if (type === "usb:flash-complete") { + if (usbFlashResolution) { + usbFlashResolution({ success: true, ...content }); + usbFlashResolution = null; + } + return; + } + + if (type === "usb:flash-error") { + if (usbFlashResolution) { + usbFlashResolution({ success: false, error: content.error }); + usbFlashResolution = null; + } + return; + } + + if (type === "content-created") { + $commonApi.content.receive(content); + return; + } + + // 📺 A piece's DOM overlay (e.g. chat.mjs's YouTube modal) closed itself on + // the main thread and is letting the piece know via an act alert. + if (type === "youtube:modal:closed") { + actAlerts.push({ name: "youtube:modal:closed" }); + return; + } + + if (type === "leave") { + //const $api = {}; + console.log("🏃‍♂️ Leave:", content); + return; + } + + if (type === "sfx:killed") { + sfxKillReceivers[content.id]?.(); + return; + } + + if (type === "sfx:got-sample-data") { + sfxSampleReceivers[content.id]?.(content.data); + return; + } + + if (type === "sfx:progress:report") { + sfxProgressReceivers[content.id]?.(content); // Resolve the progress report. + return; + } + + if (type === "sfx:got-duration") { + sfxDurationReceivers[content.id]?.(content.duration); + return; + } + + // 🎵 Streaming Audio messages - forward to piece receive function + // (the whole family: playing/paused/stopped/error/state, time-data, + // seeked, frequencies/waveform/peaks-data, speed-data) + if (type.startsWith("stream:")) { + if (typeof receive === "function") { + try { + receive({ type, content }); + } catch (e) { + console.warn("🎵 Stream receive error:", e); + } + } + return; + } + + if (type === "microphone-amplitude") { + microphone.amplitude = content; + return; + } + + if (type === "microphone-waveform") { + microphone.waveform = content; + return; + } + + if (type === "microphone-recording-buffer") { + microphone.recordingBuffer = content; + return; + } + + if (type === "waveforms") { + speaker.waveforms = content; + return; + } + + if (type === "amplitudes") { + speaker.amplitudes = content; + return; + } + + if (type === "frequencies") { + speaker.frequencies = content; + // Extract beat data if it exists in the content + if (content.beat) { + speaker.beat = content.beat; + } + return; + } + + // Handle Game Boy frame data + if (type === "gameboy:frame-data") { + // Store frame data and metadata in gameboy API object + gameboy.frame = content.pixels; + gameboy.romName = content.romName; + gameboy.title = content.title; + gameboy.isGameBoyColor = content.isGameBoyColor; + gameboy.isPlaying = true; + + return; + } + + if (type === "microphone-pitch") { + microphone.pitch = content; + return; + } + + if (type === "microphone-recording:complete") { + microphone.recordingPromise?.resolve(content); + return; + } + + if (type === "microphone-connect:success") { + microphone.connected = true; + actAlerts.push("microphone-connect:success"); + return; + } + + if (type === "microphone-connect:failure") { + microphone.connected = false; + actAlerts.push({ name: "microphone-connect:failure", ...content }); + return; + } + + // 1a. Import // One send (returns afterwards) + // Here we are receiving file data from main thread that was requested + // by $api.upload 😱. We check to see if the upload promise exists and then + // use it and/or throw it away. + if (type === "import" && fileImport) { + if (content.result === "success") { + fileImport?.resolve(content.data); + } else if (content.result === "error") { + console.error("File failed to load:", content.data); + fileImport?.reject(content.data); + } + fileImport = undefined; + return; + } + + // Resolve a web3 connection message. + if (type === "web3-connect-response" && web3Response) { + if (content.result === "success") { + web3Response?.resolve(content.id); + } else if (content.result === "error") { + web3Response?.reject("error"); + } + web3Response = undefined; + return; + } + + // Resolve Tezos wallet connection + if (type === "tezos-connect-response" && tezosConnectResponse) { + if (content.result === "success") { + tezosConnectResponse?.resolve(content.address); + } else { + tezosConnectResponse?.reject(content.error || "Connection failed"); + } + tezosConnectResponse = undefined; + return; + } + + // Resolve Tezos wallet disconnection + if (type === "tezos-disconnect-response" && tezosDisconnectResponse) { + if (content.result === "success") { + tezosDisconnectResponse?.resolve(); + } else { + tezosDisconnectResponse?.reject(content.error || "Disconnect failed"); + } + tezosDisconnectResponse = undefined; + return; + } + + // Resolve Tezos address request + if (type === "tezos-address-response" && tezosAddressResponse) { + if (content.result === "success") { + tezosAddressResponse?.resolve(content.address); + } else { + tezosAddressResponse?.reject(content.error || "Failed to get address"); + } + tezosAddressResponse = undefined; + return; + } + + // Resolve Tezos message signing + if (type === "tezos-sign-response" && tezosSignResponse) { + if (content.result === "success") { + tezosSignResponse?.resolve(content.signature); + } else { + tezosSignResponse?.reject(content.error || "Signing cancelled"); + } + tezosSignResponse = undefined; + return; + } + + // Resolve Tezos contract call + if (type === "tezos-call-response" && tezosCallResponse) { + if (content.result === "success") { + tezosCallResponse?.resolve(content.opHash); + } else { + tezosCallResponse?.reject(content.error || "Contract call failed"); + } + tezosCallResponse = undefined; + return; + } + + // Resolve a gpu message + if (type === "gpu-response" && gpuResponse) { + if (content.result === "success") { + gpuResponse?.resolve(content.data); + } else if (content.result === "error") { + gpuResponse?.reject(content.data); + } + gpuResponse = undefined; + return; + } + + // Resolve a server uploaded file. + if (type === "upload" && serverUpload) { + if (content.result === "success") { + // 🗄️ After S3 upload succeeds, create MongoDB record with short code + // Extract extension from filename to determine media type + const filename = content.data.filename || ""; + const ext = filename.split(".").pop(); + + // Only track paintings and pieces in database + if (!content.data.code && (ext === "png" || ext === "mjs" || ext === "lisp" || ext === "lua")) { + try { + // Call track-media POST to create database record with short code + const trackResponse = await $commonApi.net.userRequest("POST", "/api/track-media", { + slug: content.data.slug, + ext: ext, + }); + + if (trackResponse.status === 401) { + // Session expired - trigger logout + const errorData = await trackResponse.json(); + if (errorData.code === "SESSION_EXPIRED") { + console.warn("🔒 Session expired, logging out..."); + await $commonApi.net.logout(); + console.log("✅ Logged out due to expired session"); + } + return; // Stop processing this upload + } + + if (trackResponse.status === 200) { + const trackData = await trackResponse.json(); + // Add the short code to the response data + content.data.code = trackData.code; + console.log(`✅ ${ext === "png" ? "Painting" : "Piece"} tracked: ${content.data.slug} → #${trackData.code}`); + + // Fire off ATProto sync (don't await - let it happen in background) + if (ext === "png") { + $commonApi.net.userRequest("POST", "/api/sync-painting-atproto", { + slug: content.data.slug, + code: trackData.code, + paintingId: trackData.paintingId, + bucket: content.data.bucket || "user-aesthetic-computer", + }).then(response => { + if (response.status === 200) { + console.log("✅ ATProto sync initiated for", content.data.slug); + } + }).catch(err => { + console.warn("⚠️ ATProto sync failed (non-critical):", err); + }); + } + } else { + console.warn(`⚠️ Failed to track media in database:`, trackResponse.status); + } + } catch (err) { + console.error("❌ Error tracking media in database:", err); + // Don't fail the upload if database tracking fails + } + } + + serverUpload?.resolve(content.data); + } else if (content.result === "error") { + console.error("File failed to load:", content); + serverUpload?.reject(new Error(content.error || "Upload failed")); + } + serverUpload = undefined; + return; + } + + if (type === "zipped" && zipCreation) { + if (content.result === "success") { + zipCreation.resolve(content.data); + } else if (content.result === "error") { + console.error("Zip failed to be created:", content); + zipCreation?.reject(new Error(content.error || "Painting steps upload failed")); + } + zipCreation = undefined; + return; + } + + // Run when a painting record ZIP is succesfully parsed after being + // dragged into the A.C window. + if (type === "painting:record:dropped") { + // Replace the active nopaint record with the loaded one. + // $commonApi.system.nopaint.recording = true; + $commonApi.system.nopaint.record = content; + if ($commonApi.slug !== "painting") $commonApi.jump("painting"); + return; + } + + // Resolve a locally requested file. + if (type === "file-open:response" && fileOpenRequest) { + if (content.result === "success") { + fileOpenRequest?.resolve(content.data); + } else if (content.result === "error") { + console.error("Failed to open file.", content); + fileOpenRequest?.reject(content.data); + } + fileOpenRequest = undefined; + return; + } + + // Resolve a file encoding request. + if (type === "file-encode:response" && fileEncodeRequest) { + if (content.result === "success") { + fileEncodeRequest?.resolve(content.data); + } else if (content.result === "error") { + console.error("Failed to encode file.", content); + fileEncodeRequest?.reject(content.data); + } + fileEncodeRequest = undefined; + return; + } + + // Resolve an authorization request. + if (type === "authorization:response" && authorizationRequests.length) { + const request = authorizationRequests.shift(); + if (content.result === "success") { + request.resolve(content.data); + } else if (content.result === "error") { + request.reject(content.data); + } + return; + } + + // 1b. Video frames. + if (type === "video-frame") { + if (!videoSwitching) activeVideo = content; + return; + } + + if (type === "video-devices") { + videoDeviceCount = content; + $commonApi.cameras = videoDeviceCount; + return; + } + + if (type === "camera:updated") { + videoSwitching = false; + actAlerts.push("camera:mode:" + content); + return; + } + + if (type === "camera:denied") { + actAlerts.push("camera:denied"); + return; + } + + if ( + type === "camera:capabilities" || + type === "camera:torch" || + type === "camera:zoom" + ) { + actAlerts.push({ name: type, content }); + return; + } + + if (type === "recorder:av-sync") { + const status = content?.aligned ? "PASS" : "FAIL"; + console.log( + `🎞️ A/V sync ${status}: ${content?.offsetMs?.toFixed?.(1)}ms ` + + `(tolerance ±${content?.toleranceMs}ms)`, + ); + actAlerts.push({ name: type, content }); + return; + } + + if (type === "recorder:compact") { + actAlerts.push({ name: type, content }); + return; + } + + // 📊 Camera diagnostics from bios — log directly here so they land in + // the piece-runs silo via the patched console. Avoids needing to wire + // every diagnostic type into the actAlerts queue (which only carries + // string keys, not the full content payload we need for debugging). + if (type === "camera:debug" || type === "camera:debug:frame") { + try { + console.log(`📷 ${type}:`, JSON.stringify(content)); + } catch { + console.log(`📷 ${type}:`, content); + } + return; + } + + // 1c. Loading from History + if (type === "history-load") { + if (debug && logs.history) console.log("⏳ History:", content); + $commonApi.load(content, true); + return; + } + + // 1d. Loading Bitmaps + if (type === "loaded-bitmap-success") { + // Logging the bitmap itself makes piece-run telemetry JSON-encode every + // pixel. No Paint's sprite sheets can stall the worker for seconds. + if (debug) console.log("🖼️ Bitmap loaded:", content.url, { + width: content.img?.width, + height: content.img?.height, + }); + preloadPromises[content.url]?.resolve(content); + delete preloadPromises[content]; + return; + } + + if (type === "loaded-bitmap-rejection") { + if (debug) console.error("🖼️ Bitmap load failure:", content); + preloadPromises[content.url]?.reject(content.url); + delete preloadPromises[content.url]; + return; + } + + // 1d2. Loading Animated WebP + if (type === "loaded-animated-webp-success") { + console.log("🎬 Animated WebP loaded:", content.frameCount, "frames"); + preloadPromises[content.url]?.resolve(content); + delete preloadPromises[content.url]; + return; + } + + if (type === "loaded-animated-webp-rejection") { + console.error("🎬 Animated WebP load failure:", content); + preloadPromises[content.url]?.reject(content.error); + delete preloadPromises[content.url]; + return; + } + + // 1e. Loading Sound Effects + if (type === "loaded-sfx-success") { + if (debug && logs.audio) console.log("Sound load success:", content); + // BIOS acknowledges twice — instantly, then again once the buffer is + // decoded and its length is known. The second one is where duration lands. + if (content.duration) sfxDurations[content.sfx] = content.duration; + preloadPromises[content.sfx]?.resolve(content.sfx); + delete preloadPromises[content]; + return; + } + + if (type === "loaded-sfx-rejection") { + if (debug && logs.audio) console.error("Sound load failure:", content); + preloadPromises[content.sfx]?.reject(content.sfx); + delete preloadPromises[content.sfx]; + return; + } + + // 1f. Loading ZIP files. + if (type === "loaded-zip-success") { + if (debug) console.log("🤐 Zip load success:", content.url); + preloadPromises[content.url]?.resolve(content.data); + delete preloadPromises[content.url]; + return; + } + + if (type === "loaded-zip-rejection") { + if (debug) console.warn("🤐 Zip load failure:", content.url); + preloadPromises[content.url]?.reject(content.url); + delete preloadPromises[content.url]; + return; + } + + // Request a repaint (runs when the window is resized.) + if (type === "needs-paint") { + noPaint = false; + return; + } + + if (type === "update:auto-state") { + globalAutoReload = content === true; + $commonApi.needsPaint?.(); + return; + } + + if (type === "reframed") { + // Always update the currentDisplay settings for synchronous + // screen buffer updates. + currentDisplay = { + width: content.innerWidth, + height: content.innerHeight, + subdivisions: content.subdivisions, + pixelRatio: content.pixelRatio, // For the `hd()` native-resolution layer. + }; + $commonApi.display = currentDisplay; + + // IMPORTANT: Update screen dimensions immediately so the next paint uses correct size + if (content.width !== undefined && content.height !== undefined && screen) { + const oldWidth = screen.width; + const oldHeight = screen.height; + const oldBufferSize = screen.pixels.length; + + screen.width = content.width; + screen.height = content.height; + // Create fresh buffer - bios.mjs freeze frame provides visual continuity + // The piece's paint() will fill correct content on next frame + screen.pixels = new Uint8ClampedArray(content.width * content.height * 4); + + if ($commonApi.rec.presenting && (oldWidth !== content.width || oldHeight !== content.height)) { + console.log('📐 REFRAME: Worker dimensions updated', oldWidth, 'x', oldHeight, '→', content.width, 'x', content.height); + console.log(' Buffer:', oldBufferSize, '→', screen.pixels.length, '| Expected:', content.width * content.height * 4); + } + } + + // Only trigger a reframe event if we have already passed `boot` (painted + // at least once) + if (booted) { + reframed = true; + formReframing = true; + } + return; + } + + // Handle QR code fullscreen toggle from hitbox taps + if (type === "qr:toggle-fullscreen") { + toggleQRFullscreen(); + return; + } + + // 1. Beat + if (type === "beat") { + if (!sound) return; // Just in case no `frame` has been sent yet. + try { + beat($activePaintApi); + } catch (e) { + console.warn(" 💗 Beat failure...", e); + } + + send({ type: "beat", content: sound }); + // soundClear?.(); + sound.sounds.length = 0; // Empty the sound command buffer. + sound.bubbles.length = 0; + sound.farts.length = 0; + sound.kills.length = 0; + return; + } + + // 2. Frame + // Where each piece action (boot, sim, paint, etc...) is run. + if (type === "frame") { + // Update loop paused state from bios + loopPaused = content.paused || false; + + // Take hold of a previously worker transferrable screen buffer + // and re-assign it. + let pixels; + if (content.pixels && screen) { + pixels = new Uint8ClampedArray(content.pixels); + // Only use the transferred buffer if it matches current screen dimensions + // After a reframe, the buffer from bios may have old dimensions + const expectedLength = (content.width ?? screen.width) * + (content.height ?? screen.height) * 4; + if (pixels.length === expectedLength) { + screen.pixels = pixels; + } else { + if ($commonApi.rec.presenting) { + console.log('⚠️ FRAME: Ignoring mismatched buffer from bios. Got:', pixels.length, '| Expected:', expectedLength, '| Keeping reframed buffer'); + } + // Screen recreation below also reads this local. A rejected buffer + // must not be reused there with the new frame's width and height. + pixels = undefined; + // Keep the current screen.pixels buffer (from reframe) + } + } + + function getHudEditScrubThreshold() { + const promptLength = Math.max( + 1, + (currentHUDPlainTxt || currentHUDTxt || currentText || "").length, + ); + const blockWidth = + currentHUDLabelBlockWidth || tf?.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; + const baseCaretWidth = blockWidth + 2; + const maxCaretWidth = blockWidth * 3; + if (promptLength <= 4) return baseCaretWidth; + if (promptLength <= 8) return Math.floor(baseCaretWidth * 1.5); + return Math.min(maxCaretWidth, Math.floor(promptLength * blockWidth * 0.25)); + } + + function triggerBackspaceAction(overrideContent) { + $commonApi.sound.synth({ + tone: 800, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + send({ type: "keyboard:unlock" }); + + const content = + overrideContent || currentHUDPlainTxt || currentHUDTxt || currentText; + let promptSlug = "prompt"; + if (content) { + if (lisp.isKidlispSource(content)) { + promptSlug += "~" + lisp.encodeKidlispForUrl(content); + } else { + promptSlug += "~" + content.replace(/~/g, " "); + } + } + + $commonApi.jump(promptSlug); + send({ type: "keyboard:open" }); + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + $commonApi.needsPaint?.(); + } + + function triggerShareAction() { + $commonApi.sound.synth({ + tone: 1800, + beats: 0.15, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + $commonApi.sound.synth({ + tone: 1800 / 2, + beats: 0.15 * 2, + attack: 0.01, + decay: 0.5, + volume: 0.1, + }); + + // Prefer existing cached short codes so we don't re-embed long inline source + const cachedShortCode = getCachedCode(currentCode); + if (cachedShortCode) { + $commonApi.jump("share~$" + cachedShortCode); + } else if (currentOriginalCodeId && currentOriginalCodeId.startsWith("$")) { + $commonApi.jump("share~" + currentOriginalCodeId); + } else { + const textToShare = currentHUDPlainTxt || currentHUDTxt || currentText || ""; + $commonApi.jump("share~" + lisp.encodeKidlispForUrl(textToShare)); + } + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + $commonApi.needsPaint?.(); + } + + // 🌟 Global Keyboard Shortcuts (these could also be seen via `act`) + content.keyboard.forEach((data) => { + if (currentText && currentText.indexOf("botce") > -1) return; // No global keys on `botce`. 23.11.12.23.38 + + // 📼 [Ctrl+R+R] Quick tape - start recording without leaving the piece + // Handle this BEFORE the keyboard:down check since it's a special event + if (data.name === "keyboard:quick-tape" && !getPackMode()) { + console.log("📼 Quick tape: Handler triggered!"); + console.log("📼 Quick tape: currentText =", currentText); + console.log("📼 Quick tape: currentParams =", currentParams); + console.log("📼 Quick tape: $commonApi.rec =", $commonApi.rec); + + // Play a sound to indicate recording started + $commonApi.sound.synth({ + tone: 2000, + duration: 0.05, + attack: 0.01, + decay: 0.5, + volume: 0.25, + }); + + // Set up the recording with default 7 second duration + const defaultDuration = 7; + const kidlispFps = (typeof window !== 'undefined' && window.currentKidlispFps) || null; + + console.log("📼 Quick tape: About to call rec.rolling()..."); + + // Set up recording options and callback + $commonApi.rec.rolling( + { + type: "video", + pieceName: currentText || "quick-tape", + pieceParams: currentParams ? currentParams.join("~") : "", + originalCommand: currentText, + intendedDuration: defaultDuration, + frameMode: false, + frameCount: null, + kidlispFps: kidlispFps, + cleanMode: false, + showTezosStamp: false, + mystery: false + }, + (time) => { + // This callback is invoked after recording starts + console.log(`📼 Quick tape: Timer callback invoked! time=${time}`); + $commonApi.rec.tapeTimerSet(defaultDuration, time, false); + console.log(`📼 Quick tape: Timer set complete`); + }, + ); + + console.log("📼 Quick tape: rec.rolling() called"); + + // Mark as recording + $commonApi.rec.recording = true; + + console.log(`📼 Quick tape: Recording initiated! Duration=${defaultDuration}s`); + return; // Don't process this event further + } + + if (data.name.indexOf("keyboard:down") === 0) { + // [Escape] (Deprecated on 23.05.22.19.33) + // If not on prompt, then move backwards through the history of + // previously loaded pieces in a session. + // if ( + // data.key === "Escape" && + // currentPath !== "aesthetic.computer/disks/prompt" + // ) { + // if (pieceHistoryIndex > 0) { + // send({ type: "back-to-piece" }); + // } else { + // // Load the prompt automatically. + // // $api.load("prompt"); Disabled on 2022.05.07.03.45 + // } + // } + + if ((data.key === "$" || data.key === "Home") && !getPackMode()) { + if (data.ctrl || data.alt) { + const sys = $commonApi.system; + // Make it a painting. + sys.nopaint.replace( + cachedAPI, + graph.cloneBuffer(screen), + "$creenshot", + ); + $commonApi.jump("prompt"); + } else { + downloadScreenshot(); // 🖼️ Take a screenshot. + } + $commonApi.sound.synth({ + tone: 1600, + duration: 0.02, + attack: 0.01, + decay: 0.5, + volume: 0.25, + }); + } + + // ⛈️ Jump back to the `prompt` from anywhere.. + if ( + !getPackMode() && // Disable navigation keys in OBJKT mode + !SOLO_MODE && // Disable navigation keys in solo mode + (data.key === "`" || + data.key === "Enter" || + data.key === "Backspace" || + data.key === "Escape") && + system !== "prompt" && + system !== "world" && + currentText !== "chat" && + currentText !== "laer-klokken" && + currentText !== "laklok" && + currentText !== "aa" && + currentText !== "mail" && // composing — it leaves on its own terms + currentText !== "amail" && + currentText !== "sign" && + currentText !== "jas" && + currentText !== "nopaint" && // owns Enter/Escape and locks input while saving + // numrank keeps Backspace/Enter for gameplay; ` and Escape still leave. + !( + currentText === "numrank" && + (data.key === "Backspace" || data.key === "Enter") + ) && + currentPath !== "aesthetic.computer/disks/prompt" + ) { + // If recording, Escape should stop the tape instead of jumping to prompt + if (data.key === "Escape" && $commonApi.rec.recording) { + console.log("🎬 Escape pressed during recording - stopping tape"); + + // Cut the recording first + $commonApi.rec.cut(); + + // Play the sound AFTER a brief delay so it doesn't get recorded + setTimeout(() => { + $commonApi.sound.synth({ + tone: 600, + duration: 0.15, + attack: 0.01, + decay: 0.8, + volume: 0.2, + }); + }, 100); // 100ms delay to ensure recording has stopped + + return; // Don't proceed with navigation + } + // 🎬 Stop demoplay on Escape + if (data.key === "Escape" && typeof $commonApi.system.stopDemoplay === "function") { + leaving = false; + $commonApi.system.stopDemoplay(); + return; + } + // Stop merry pipeline for both Escape and Backspace + let merryOriginalCommand = null; + if (data.key === "Escape" || data.key === "Backspace") { + const stopMerry = $commonApi.system.stopMerryPipeline; + if (typeof stopMerry === "function") { + // Capture original command before stopping (for backspace editing) + if (data.key === "Backspace" && $commonApi.system.merry?.originalCommand) { + merryOriginalCommand = $commonApi.system.merry.originalCommand; + } + // Reset leaving flag to allow merry stop to jump + leaving = false; + stopMerry({ + reason: data.key === "Escape" ? "escape" : "backspace", + jumpAfter: false, + jumpTarget: null, + cutTape: true, + }); + } else if ($commonApi.system.merry) { + $commonApi.system.merry.running = false; + delete $commonApi.system.merry; + } + } + + $commonApi.sound.synth({ + tone: data.key === "Backspace" ? 800 : 1200, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + + send({ type: "keyboard:unlock" }); + + // 🌐 Branded domain: Escape/backtick/Enter navigates to aesthetic.computer + if (data.key !== "Backspace" && redirectIfBrandedDomain()) return; + + const promptSlug = "prompt"; + if (!labelBack || data.key === "Backspace" || data.key === "Escape") { + if (data.key === "Backspace") { + triggerBackspaceAction(merryOriginalCommand); + } else { + $commonApi.jump(promptSlug); + send({ type: "keyboard:open" }); + } + } else { + if ($commonApi.history.length > 0) { + send({ type: "back-to-piece" }); + // $commonApi.jump( + // $commonApi.history[$commonApi.history.length - 1], + // ); + } else { + $commonApi.jump(promptSlug); + send({ type: "keyboard:open" }); + } + } + } + + // [Tab] Toggle HUD label and QR overlay visibility with smooth animation + // Disabled in pack mode (OBJKT bundles) + // Only works in KidLisp pieces (to avoid conflict with autocomplete) + if (data.key === "Tab" && !getPackMode()) { + const isKidLispPiece = detectKidLispPiece({ currentPath, currentHUDTxt, currentText, cleanText: currentHUDTxt }); + if (isKidLispPiece) { + const currentTime = performance.now(); + const timeSinceLastTab = currentTime - (hudAnimationState.lastTabTime || 0); + const isDoubleTap = timeSinceLastTab < 300; // 300ms double-tap window + + hudAnimationState.lastTabTime = currentTime; + // Skip sound only for embedded/inline KidLisp (kidlisp.com editor, PJ KidLisp) + const isEmbedded = detectEmbeddedKidLisp({ currentPath, currentHUDTxt, currentText }); + toggleHUDVisibility(isDoubleTap, isEmbedded); + } + } + + // [Shift] Toggle QR code fullscreen mode for KidLisp pieces + // if (data.key === "Shift") { + // console.log("⌨️ [Shift Key] Received! getPackMode():", getPackMode()); + // } + if (data.key === "Shift" && !getPackMode()) { + toggleQRFullscreen(); + } + + // [Ctrl + X] + // Enter and exit fullscreen mode. + if (data.key === "x" && data.ctrl && currentText !== "notepat" && !getPackMode()) { + send({ type: "fullscreen-enable" }); + } + + // [Ctrl + D] + // Toggle auto-density override — force full density even when FPS is low. + if (data.key === "d" && data.ctrl && globalKidLispInstance) { + if (globalKidLispInstance.autoDensityOverride) { + // Re-enable auto-density + globalKidLispInstance.autoDensityOverride = false; + if (typeof window !== 'undefined') window.acAutoDensityOverride = false; + globalKidLispInstance.enableAutoDensity(); + } else { + // Force full density — disable auto-scaling + globalKidLispInstance.autoDensityOverride = true; + if (typeof window !== 'undefined') window.acAutoDensityOverride = true; + globalKidLispInstance.disableAutoDensity(); + } + } + } + }); + + // Add 'loading' status to $commonApi. + $commonApi.loading = loading; // Let the piece know if we are already + // loading another piece. + + // Globalize any background music data, retrievable via bgm.data + $commonApi.bgm.data = { + amplitude: content.audioMusicAmplitude, + sample: content.audioMusicSampleData, + }; + + // Synthetic audio for headless captures — feed KidLisp audio globals + if (SPOOF_AUDIO_MODE && content.audioMusicAmplitude > 0) { + const norm = content.audioMusicAmplitude / 255; // 0–1 + updateKidLispAudio({ + amp: norm * 10, + leftAmp: norm * 10, + rightAmp: norm * 10, + __source: "spoof", + }); + } + + // Hand-tracking + if (content.hand) $commonApi.hand = { mediapipe: content.hand }; + + // Pens + if (content.pen) { + const primaryPointer = help.findKeyAndValue( + content.pen.pointers, + "isPrimary", + true, + ); + + // Returns all [pens] if n is undefined, or can return a specific pen by 1 based index. + // [pens] are sorted by `pointerIndex` + + // TODO: Including "help.findKeyAndValue" seems to bring a lot of + // allocation here because it keeps the whole API around? + // Re-test this when pointers is not empty! 22.11.12.20.02 + const pointers = content.pen.pointers; + const pointersValues = Object.values(pointers); + + // Make all available dragBoxes into `Box` instances. + pointersValues.forEach((p) => { + if (p.dragBox) p.dragBox = geo.Box.from(p.dragBox); + }); + + const pens = pointersValues.reduce((arr, value) => { + arr[value.pointerNumber] = value; + return arr; + }, []); + + // if (pens.length > 0 && debug) + // console.log("Pens:", pens, content.pen.events); + + $commonApi.pens = function (n) { + if (n === undefined) return pens; + return help.findKeyAndValue(pointers, "pointerNumber", n - 1) || {}; + }; + + if (pointersValues.length > 1 && primaryPointer) + primaryPointer.multipen = true; // Set a flag for multipen activity on main pen API object. + + $commonApi.pen = primaryPointer; + + if ( + screen && + primaryPointer && + (primaryPointer.delta?.x !== 0 || primaryPointer.delta?.y !== 0) + ) { + // Skip sending fairy points in KidLisp pieces. + const isKidlisp = detectKidLispPiece({ currentPath, currentHUDTxt, currentText }) || + (currentPath && currentPath.endsWith('.lisp')); + if (!isKidlisp) { + udp?.send("fairy:point", { + x: primaryPointer.x / screen.width, + y: primaryPointer.y / screen.height, + }); + } + } + } + + // 🕶️ VR Pen + $commonApi.pen3d = content.pen3d?.pen; + + // Add upload event to allow the main thread to open a file chooser. + // type: Accepts N mimetypes or file extensions as comma separated string. + // Usage: upload(".jpg").then((data) => ( ... )).catch((err) => ( ... )); + $commonApi.sideload = (type) => { + const prom = new Promise((resolve, reject) => { + fileImport = { resolve, reject }; + }); + send({ type: "import", content: type }); + return prom; + }; + + // 🤖 Sim // no send + $commonApi.seconds = function (s) { + return s * 120; // TODO: Get 120 dynamically from the Loop setting. 2022.01.13.23.28 + }; + + // 🔈 Sound + // TODO: Most of the $sound api doesn't need to be generated per + // frame. 24.01.14.15.19 + + // For reference in `freq` below. + const noteFrequencies = { + c: 16.35, + "c#": 17.32, + db: 17.32, + d: 18.35, + "d#": 19.45, + eb: 19.45, + e: 20.6, + f: 21.83, + "f#": 23.12, + gb: 23.12, + g: 24.5, + "g#": 25.96, + ab: 25.96, + a: 27.5, + "a#": 29.14, + bb: 29.14, + b: 30.87, + }; + + const $sound = { + get time() { + const currentTime = content.audioTime; + // Add comprehensive logging for audio timing (throttled to avoid spam) + // if (debug && Math.floor(currentTime * 100) % 100 === 0) { // Log every 10ms when rounded + // console.log(`🎵 AUDIO_TIME: ${currentTime.toFixed(6)}s (getter called from ${(new Error().stack.split('\n')[2] || 'unknown').trim()})`); + // } + return currentTime; + }, + // 🎹 DAW state (populated via daw:tempo, daw:transport, daw:phase, daw:samplerate messages from M4L) + // Uses persistentDawState which survives frame updates (unlike $commonApi.sound which is recreated) + get daw() { + return persistentDawState; + }, + // 🎛️ Spreadnob state (populated via spreadnob:* messages from M4L) + get spreadnob() { + return persistentSpreadnobState; + }, + // Get the bpm with bpm() or set the bpm with bpm(newBPM). + bpm: function (newBPM) { + if (newBPM) sound.bpm = newBPM; + return sound.bpm; + }, + enabled: () => { + return AUDIO_SAMPLE_RATE > 0; + }, + // Compute the frequency of a musical note. + // 🗒️ Can take a number or formatted octave string such as 5C# or even C#5 for C sharp in fifth octave. + freq: function (input) { + // console.log("🎵 Note to check:", input); + // Return if it's just a number or parses as one. + if (typeof input === "number") return input; + if (input === null || input === undefined) return null; + + const trimmed = String(input).trim(); + if (trimmed === "") return null; + + if (!isNaN(parseFloat(trimmed)) && isFinite(trimmed)) return Number(trimmed); + + let octave = 4; + let note = trimmed.toLowerCase(); // Downcase everything. + + // Handle special tokens that shouldn't resolve to a frequency + if (note === "rest" || note === "pause" || note === "_" || note === "speech") return null; + + const prefixMatch = note.match(/^(-?\d+)([a-z#/+\-]+)$/); + const suffixMatch = note.match(/^([a-z#/+\-]+)(-?\d+)$/); + + if (prefixMatch) { + octave = parseInt(prefixMatch[1], 10); + note = prefixMatch[2]; + } else if (suffixMatch) { + note = suffixMatch[1]; + octave = parseInt(suffixMatch[2], 10); + } + + // Remove trailing + or - markers and other notation artifacts + note = note.replace(/[+\-]+$/g, ""); + + // Replace notepat "s"/"f" suffixes with standard accidentals when appropriate + note = note.replace(/^([a-g])s$/, "$1#").replace(/^([a-g])f$/, "$1b"); + + const frequency = noteFrequencies[note]; // Look up freq for the note. + if (!frequency) throw new Error("Note not found in the list"); + + // Calculate the frequency for the given octave + const finalFreq = frequency * Math.pow(2, octave); + return finalFreq; + }, + // Calculate a musical note from a frequency. + note: function (frequency) { + let closestNote = "", + minDiff = Infinity; + for (let octave = 0; octave <= 8; octave++) { + for (let note in noteFrequencies) { + const noteFrequency = noteFrequencies[note] * Math.pow(2, octave); + const diff = Math.abs(frequency - noteFrequency); + if (diff < minDiff) { + minDiff = diff; + closestNote = note + octave; + } + } + } + + return closestNote.toUpperCase(); + }, + // MIDI + midi: { + connect: () => send({ type: "midi:connect" }), + // Convert MIDI note number to note string + note: function (midiNumber) { + const noteNames = [ + "C", + "C#", + "D", + "D#", + "E", + "F", + "F#", + "G", + "G#", + "A", + "A#", + "B", + ]; + const octave = floor(midiNumber / 12) - 1; + const noteIndex = midiNumber % 12; + return noteNames[noteIndex] + octave; + }, + }, + // Rendering + paint: { + bars: function paintSound( + { ink, box, screen, num }, + amplitude, + waveform, + x, + y, + width, + height, + color, + options = { noamp: false, nobounds: false }, + ) { + const yMid = round(y + (height - 2) / 2), + yMax = round((height - 2) / 2); + let lw = options.noamp ? 0 : 4; // levelWidth; + const xStep = (width - lw) / waveform.length; + + // Vertical bounds. + if (!options.nobounds) { + ink("yellow") + .line(x + lw, y, x + width - 1, y) + .line(x + lw, y + height, x + width - 1, y + height); + } + + // Level meter. + if (!options.noamp) { + ink("black").box(x, y, lw, height + 1); + ink("green").box(x, y + height, lw, -amplitude * height); + } + + // Filled waveform + const waves = waveform.map((v, i) => { + if (v < -1) v = -1; + if (v > 1) v = 1; + return [x + lw + i * xStep, yMid + v * yMax]; + }); + + ink(options.secondaryColor || "black").box( + x + lw, + y + 1, + width - lw, + height - 1, + ); + + ink(options.primaryColor || "white"); + + let remainder = 0; + let totalWidthCovered = 0; + + waves.forEach((point, index) => { + let bx = x + lw + totalWidthCovered; + if (bx >= x + width) return; + // Compute the pixel-aligned width for the current bar. + let barWidth = Math.floor(xStep + remainder); + remainder = (xStep + remainder) % 1; // Collect the fractional remainder. + // Ensure we don't exceed the full width for the last bar. + if (index === waves.length - 1 || bx + barWidth >= x + width) + barWidth = x + width - bx; + box( + bx, + y + point[1] + 1 - y, + barWidth, + y + (height - 1) - point[1], + ); + totalWidthCovered += barWidth; + }); + + // Waveform + // ink("lime", 255).poly( + // waveform.map((v, i) => [x + lw + i * xStep, yMid + v * yMax]), + // ); + + // TODO: Fill a point above this line and below. + // ink("blue").flood(x + 7, y + 1); + // ink("teal").flood(x + 7, y + height - 2); + + // const my = screen.height - mic.amplitude * screen.height; + // ink("yellow", 128).line(0, my, screen.width, my); // Horiz. line for amplitude. + }, + + // Paints a waveform with a bounding box based on amplitude. + waveform: function paintWaveform( + { ink }, + amplitude, + waveform, + x, + y, + width, + height, + color = "yellow", + options, + ) { + if (waveform?.length < 1) return; + const direction = options?.direction || "left-to-right"; + if (direction === "left-to-right") { + const xStep = width / (waveform.length - 1); + + const yMid = y + height / 2, + yMax = height / 2; + + ink(color, 128).poly( + waveform.map((v, i) => { + const p = [x + i * xStep, yMid + (v || 0) * yMax]; + return p; + }), + ); + } else if (direction === "bottom-to-top") { + const yStep = height / (waveform.length - 1); + const xMid = x + width / 2, + xMax = width; + + ink("blue", 128).poly( + waveform.map((v, i) => { + const p = [xMid + (v || 0) * xMax, y + height - i * yStep]; + return p; + }), + ); + } else { + console.warn("🌊 Unsupported direction."); + } + }, + + // Renders an "AUDIO ENGINE OFF" badge when the audio context is + // suspended. Returns { visible, x, y, width, height } so callers + // can react to its layout. + audioEngineBadge: function paintAudioEngineBadge( + { ink, screen }, + speaker, + x, + y, + options = {}, + ) { + const waveforms = speaker?.waveforms?.left; + const amp = speaker?.amplitudes?.left; + const ready = + waveforms && + typeof waveforms.length === "number" && + waveforms.length > 0 && + typeof amp === "number" && + Number.isFinite(amp); + + if (ready) return { visible: false, x: 0, y: 0, width: 0, height: 0 }; + + const maxWidth = + options.maxWidth != null ? options.maxWidth : screen.width - 20; + const padding = 4; + const badgeHeight = options.height || 12; + const align = options.align || "center"; // "center" | "left" | "right" + + // Responsive text choices + const texts = ["AUDIO ENGINE OFF", "ENGINE OFF", "OFF"]; + let chosen = texts[0]; + for (const t of texts) { + // Approximate glyph width for MatrixChunky8 is 6px per char + 1px gap + const tw = t.length * 7; + if (tw + padding * 2 <= maxWidth) { + chosen = t; + break; + } + } + const textWidth = chosen.length * 7; + const totalWidth = textWidth + padding * 2; + + let bx = x; + if (align === "center") { + bx = x != null ? x : Math.floor((screen.width - totalWidth) / 2); + } else if (align === "right") { + bx = (x != null ? x : screen.width) - totalWidth; + } + + ink(180, 0, 0, 240).box(bx, y, totalWidth, badgeHeight); + ink(255, 255, 0).write( + chosen, + { x: bx + padding, y: y + 3 }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + + return { visible: true, x: bx, y, width: totalWidth, height: badgeHeight }; + }, + }, + }; + + $sound.microphone = microphone; + $sound.speaker = speaker; + $sound.gameboy = gameboy; + $sound.sampleRate = AUDIO_SAMPLE_RATE; + + // TODO: Generalize square and bubble calls. + // TODO: Move this stuff to a "sound" module. + sound.bpm = content.audioBpm; + + // Clear synchronized audio triggers. + // soundClear = () => { + // sound.sounds.length = 0; + // sound.bubbles.length = 0; + // sound.kills.length = 0; + // }; + + // Trigger a named audio sample to playback in the `bios`. + // options: { volume: 0-n, pan: 0-2?, loop: Bool, ...(there is more) } + + $sound.getSampleData = async function getSampleData(id) { + const prom = new Promise((resolve, reject) => { + sfxSampleReceivers[id] = resolve; + return { resolve, reject }; + }); + send({ type: "sfx:get-sample-data", content: { id } }); + return prom; + }; + + $sound.registerSample = function registerSample(id, data, sampleRate) { + if (!id || !data) return; + send({ type: "sfx:register", content: { id, data, sampleRate } }); + }; + + // 🔄 Live buffer update - updates sample data for currently playing sounds + $sound.updateSample = function updateSample(id, data, sampleRate) { + if (!id || !data) return; + send({ type: "sfx:update-sample", content: { id, data, sampleRate } }); + }; + + soundTime = content.audioTime; + + $sound.play = function play(sfx, options, callbacks) { + const id = sfx + "_" + $sampleCount; // A *unique id for this sample. + $sampleCount += 1n; + + send({ type: "sfx:play", content: { sfx, id, options } }); + + const playingSound = { + options, // Allow the options passed to BIOS to be inspected. + startedAt: soundTime, // performance.now(), + killed: false, + kill: (fade) => { + send({ type: "sfx:kill", content: { id, fade } }); + }, + progress: async () => { + if (playingSound.killed) return { progress: 0 }; + const prom = new Promise((resolve, reject) => { + sfxProgressReceivers[id] = (progressData) => { + // Enhanced logging for audio progress tracking + if (debug && progressData?.progress !== undefined) { + const timeElapsed = soundTime - playingSound.startedAt; + // console.log(`🎵 AUDIO_PROGRESS: id=${id.substring(id.lastIndexOf('_') + 1)}, progress=${progressData.progress.toFixed(6)}, elapsed=${timeElapsed.toFixed(3)}s, started=${playingSound.startedAt.toFixed(3)}s, current=${soundTime.toFixed(3)}s`); + + // Detect potential timing issues - compare in consistent units + if (progressData.progress > 0 && timeElapsed > 0 && progressData.duration) { + // Convert audio progress to actual time for comparison + const actualAudioTime = progressData.progress * progressData.duration; + const timeDrift = Math.abs(actualAudioTime - timeElapsed); + const driftPercent = (timeDrift / progressData.duration) * 100; + + if (driftPercent > 5) { // 5% drift threshold + console.warn(`🎵 SYNC_DRIFT: actual=${actualAudioTime.toFixed(3)}s, expected=${timeElapsed.toFixed(3)}s, drift=${timeDrift.toFixed(3)}s (${driftPercent.toFixed(1)}%)`); + } + } + } + resolve(progressData); + }; + return { resolve, reject }; + }); + send({ type: "sfx:progress", content: { id } }); + return prom; + }, + update: function (properties) { + if (properties.shift || properties.sampleSpeed !== undefined) { + send({ + type: "sfx:update", + content: { id, properties }, + }); + } + }, + }; + + sfxKillReceivers[id] = () => { + callbacks?.kill?.(); + playingSound.killed = true; + }; + + return playingSound; + }; + + $sound.skip = function () { + send({ type: "beat:skip" }); + }; + + $sound.at = function (timeToRun, callback) { + // TODO: Finish this implementation. + // timeToRun; + // content.audioTime; + }; + + // How long a preloaded sound is, in seconds, or undefined until BIOS has + // decoded it. A plain read, unlike `getDuration` — nothing to await and + // nothing to hang on, so a piece can ask again next frame. + $sound.duration = function duration(id) { + return sfxDurations[id]; + }; + + $sound.getDuration = async function getDuration(id) { + const prom = new Promise((resolve, reject) => { + sfxDurationReceivers[id] = resolve; + return { resolve, reject }; + }); + send({ type: "sfx:get-duration", content: { id } }); + return prom; + }; + + $sound.synth = function synth({ + tone = 440, // hz, or musical note + type = "square", // "sine", "triangle", "square", "sawtooth", "custom" + // "noise-white" <-ignores tone + duration = 0.1, // In seconds... (where beats is a shortcut) + beats = undefined, // 🧧 Should this be deprecated? + attack = 0.01, // How quickly the sound starts. + decay = 0.9, // A multiplier for when the sound fades. + volume, + pan = 0, + immediate = false, // Bypass the next frame batch for latency-critical input. + probe = null, // Opt-in main-thread → AudioWorklet profiling metadata. + generator = null, // Custom waveform generator function for type "custom" + } = {}) { + const id = soundId; + if (volume === undefined) volume = 1; + if (duration === "🔁") duration = Infinity; // First emoji in the API. 24.07.03.02.26 + if (beats === undefined && duration !== undefined) + beats = (duration * sound.bpm) / 60; + + tone = $sound.freq(tone); + // console.log("⛈️ Tone:", tone); + // Add generator to sound data for custom type + const soundData = { id, type, tone, beats, attack, decay, volume, pan, probe }; + if (type === "custom" && generator) { + soundData.generator = generator.toString(); // Convert function to string for postMessage + } + + if (immediate) send({ type: "synth:trigger", content: soundData }); + else sound.sounds.push(soundData); + soundId += 1n; + let seconds; + if (beats === undefined && duration !== undefined) seconds = duration; + else seconds = (60 / sound.bpm) * beats; + // console.log("Beats:", beats, "Duration:", duration, "Seconds:", seconds, "BPM:", sound.bpm); + + const end = soundTime + seconds; + + return { + startedAt: soundTime, // performance.now(), + id, + kill: function (fade) { + if (immediate) send({ type: "synth:kill", content: { id, fade } }); + else sound.kills.push({ id, fade }); + }, + progress: function (time) { + return 1 - max(0, end - time) / seconds; + }, + update: function (properties) { + if (properties.tone) properties.tone = $sound.freq(properties.tone); + send({ + type: "synth:update", + content: { id, properties }, + }); + }, + updateGenerator: function (newGenerator) { + if (type === "custom") { + send({ + type: "update-generator", + content: { id, generator: newGenerator.toString() }, // Convert function to string + }); + } + }, + }; + }; + $sound.bubble = function ({ radius, rise, volume = 1, pan = 0 } = {}) { + const id = soundId; + sound.bubbles.push({ id, radius: radius, rise, volume, pan }); + soundId += 1n; + + return { + startedAt: soundTime, + id, + kill: function (fade) { + sound.kills.push({ id, fade }); + }, + update: function (properties) { + send({ + type: "bubble:update", + content: { id, properties }, + }); + }, + enableSustain: function () { + send({ + type: "bubble:update", + content: { id, properties: { sustain: true } }, + }); + }, + disableSustain: function () { + send({ + type: "bubble:update", + content: { id, properties: { sustain: false } }, + }); + }, + }; + }; + + $sound.fart = function ({ pressure = 1, pitch = 60, rasp = 0.5, volume = 1, pan = 0 } = {}) { + const id = soundId; + sound.farts = sound.farts || []; + sound.farts.push({ id, pressure, pitch, rasp, volume, pan }); + soundId += 1n; + + return { + startedAt: soundTime, + id, + kill: function (fade) { + sound.kills.push({ id, fade }); + }, + update: function (properties) { + send({ + type: "fart:update", + content: { id, properties }, + }); + }, + enableSustain: function () { + send({ + type: "fart:update", + content: { id, properties: { sustain: true } }, + }); + }, + disableSustain: function () { + send({ + type: "fart:update", + content: { id, properties: { sustain: false } }, + }); + }, + }; + }; + + $sound.kill = function (id, fade) { + sound.kills.push({ id, fade }); + }; + + // 🏠 Room/Reverb API - global reverb effect + $sound.room = { + // Toggle room reverb on/off + toggle: function () { + send({ type: "room:toggle" }); + }, + // Enable room reverb + on: function () { + send({ type: "room:set", content: { enabled: true } }); + }, + // Disable room reverb + off: function () { + send({ type: "room:set", content: { enabled: false } }); + }, + // Set room parameters: { enabled, mix (0-1), feedback (0-0.95) } + set: function (options = {}) { + send({ type: "room:set", content: options }); + }, + // Get current room state (async via callback) + get: function () { + send({ type: "room:get" }); + }, + }; + + // 🧩 Glitch API - global raw synth effect + $sound.glitch = { + // Toggle glitch on/off + toggle: function () { + send({ type: "glitch:toggle" }); + }, + // Enable glitch + on: function () { + send({ type: "glitch:set", content: { enabled: true } }); + }, + // Disable glitch + off: function () { + send({ type: "glitch:set", content: { enabled: false } }); + }, + // Set glitch parameters: { enabled, mix (0-1), crush (2-12), rate (Hz), jitter (0-1) } + set: function (options = {}) { + send({ type: "glitch:set", content: options }); + }, + // Get current glitch state (async via callback) + get: function () { + send({ type: "glitch:get" }); + }, + }; + + $commonApi.sound = $sound; + + // System beep. + $commonApi.beep = (tone = 1200) => { + $sound.synth({ + tone, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.25, + }); + }; + + // Act & Sim (Occurs after first boot and paint, `boot` occurs below.) + if (booted && paintCount > 0n /*&& !leaving*/) { + let $api = {}; + keys($commonApi).forEach((key) => ($api[key] = $commonApi[key])); + keys($updateApi).forEach((key) => ($api[key] = $updateApi[key])); + keys(painting.api).forEach((key) => ($api[key] = painting.api[key])); + $api.api = $api; // Add a reference to the whole API. + + // 🎮 Add gamepad helper API (separate event streams per gamepad) + $api.gamepads = createGamepadAPI(content.gamepads); + + // Set the robo API context so it can send events + $api.robo.setAPI($api); + + $api.inFocus = inFocus; + + // 🔒 Apply security restrictions for untrusted pieces + if (pieceMetadata && shouldRestrictPiece(pieceMetadata)) { + $api = createRestrictedApi($api, pieceMetadata); + } + + cachedAPI = $api; // Remember this API for any other acts outside + // of this loop, like a focus change or custom act broadcast. + + // Use screen.width/height instead of content.width/height to get the most up-to-date dimensions + // content.width/height can be stale if a reframe just happened + $api.screen = { + width: screen.width, + height: screen.height, + pixels: screen.pixels, + }; + + $api.cursor = (code) => { + cursorCode = code; + send({ type: "cursor:set", content: { style: code } }); + }; + + // 📻 Signaling + $api.signal = (content) => { + send({ type: "signal", content }); + }; + + // Deprecated on 23.07.01.15.31 (Remove later if no regressions.) + // if (initialSim) { + // console.log("initial", initialSim, content.updateCount, 'paintcount', paintCount); + // simCount += 1n; + // $api.simCount = simCount; + // try { + // sim($api); + // } catch (e) { + // console.warn("🧮 Sim failure...", e); + // } + // initialSim = false; + // } else + + if (content.updateCount > 0 && paintCount > 0n) { + // Run `sim` the number of times as requested from `bios`. + for (let i = content.updateCount; i--; ) { + simCount += 1n; + $api.simCount = simCount; + try { + sim($api); + noticeTimer?.step(); // Globally tick the noticeTimer if it exists. + // ⌛ Run through all the global hourglass timers. + for (let i = hourGlasses.length - 1; i >= 0; i--) { + hourGlasses[i].step(); + if (hourGlasses[i].complete && !hourGlasses[i].autoFlip) + hourGlasses.splice(i, 1); + } + $api.rec.tapeTimerStep($api); + } catch (e) { + console.warn("🧮 Sim failure...", e); + } + } + } + + // 🌟 Act + // *Device Event Handling* + + // TODO: Shouldn't all these events come in as part of one array to + // keep their order of execution across devices? + // TODO: Could "device" be removed in favor of "device:event" strings and + // if needed, a device method? + + // Window Events + + // Reframing the piece... (resizing the window). + if (reframed === true) { + + $api.event = { + device: "none", + is: (e) => e === "reframed", + }; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + reframed = false; + + // Global reframings. + // currentPromptButton?.reposition({ + // left: 6, + // bottom: 6, + // screen: $api.screen, + // }); + } + + // If a disk failed to load, then notify the disk that loaded it + // by checking to see if loadFailure has anything set. + if (loadFailure) { + $api.event = { + error: loadFailure, + is: (e) => e === "load-error", + }; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + send({ type: "load-failure" }); + loadFailure = undefined; + } + + // Signaling + if (signals.length) { + const data = { signal: signals }; + Object.assign(data, { + device: "none", + is: (e) => e === "signal", + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + signals.length = 0; + } + + // Keyboard Paste Event + // if (content.clipboardText) { + // const data = { text: content.clipboardText }; + // Object.assign(data, { + // device: "none", + // is: (e) => e === "pasted:text", + // }); + // $api.event = data; + // try { + // act($api); + // } catch (e) { + // console.warn("️ ✒ Act failure...", e); + // } + // } + + // *** Pen Events *** + // Ingest all pen input events by running act for each event. + // TODO: I could also be transforming pen coordinates here... + // TODO: Keep track of lastPen to see if it changed. + const startTime = performance.now(); + let penEventCount = 0; + + content.pen?.events.forEach((data) => { + penEventCount++; + Object.assign(data, { + device: data.device, + hudButtonActive: currentHUDButtonActive, // Add global HUD button flag + is: (e) => { + let [name, pointer] = e.split(":"); + // 👆 Swipe events carry a *direction* in the second slot + // (swipe:left / swipe:right / swipe:up / swipe:down), not a pointer + // index. Bare `swipe` (or `swipe:any`) matches any direction. + if (data.name === "swipe") { + if (name !== "swipe") return false; + if (!pointer || pointer === "any") return data.isPrimary === true; + return data.direction === pointer && data.isPrimary === true; + } + if (pointer) { + if (pointer === "any") { + return name === data.name; + } else { + return name === data.name && data.index === parseInt(pointer); + } + } else { + return name === data.name && data.isPrimary === true; + } + }, + }); + + //console.log(data) + $api.event = data; + // 🌐🖋️️ Global pen events. + try { + // Always check to see if there was a tap on the corner. + const { event: e, jump, send, sound, system, piece } = $api; + let originalColor; + let masked = false; + + if ( + e.is("touch:5") && + piece !== "notepat" && + piece !== "stample" && + piece !== "toss" + ) { + sound.synth({ + tone: 1600, + duration: 0.02, + attack: 0.01, + decay: 0.5, + volume: 0.25, + }); + system.nopaint.replace( + cachedAPI, + graph.cloneBuffer(screen), + "$creenshot", + ); + jump("prompt"); + } + + // Corner prompt button. + if (!TV_MODE) { + currentHUDButton?.act(e, { + // Passive rollover — the label tints and shakes like a + // TextButton so it reads as pressable before you commit. + hover: () => { + currentHUDHovered = true; + $api.needsPaint(); + }, + leave: () => { + currentHUDHovered = false; + $api.needsPaint(); + }, + down: () => { + originalColor = currentHUDTextColor; + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + currentHUDTextColor = [0, 255, 0]; + send({ type: "keyboard:enabled" }); // Enable keyboard flag. + send({ type: "keyboard:unlock" }); + $api.needsPaint(); + + // Set global flag to block other button interactions + currentHUDButtonActive = true; + // Track that this was a direct touch on the HUD button + currentHUDButtonDirectTouch = currentHUDButton.box.contains(e); + + // Mask unless we are in the camera. + if ($api.slug !== "camera") masked = true; + + $api.sound.synth({ + tone: 600, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.25, + }); + }, + push: (btn) => { + // Block HUD label navigation in solo mode + if (SOLO_MODE) return; + + const fallbackShareWidth = tf.blockWidth * "share ".length; + const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); + const editWidth = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); + + // 📦 PACK mode: no scrub-to-share, tap refreshes page + if (getPackMode()) { + currentHUDButtonActive = false; + currentHUDButtonDirectTouch = false; + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + $api.sound.synth({ + tone: 1200, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + if (typeof window !== 'undefined') window.location.reload(); + masked = true; + $api.needsPaint(); + return; + } + + // If scrubbed to reveal "share", jump to share piece + if (currentHUDScrub >= shareWidth) { + currentHUDButtonActive = false; + currentHUDButtonDirectTouch = false; + triggerShareAction(); + return; + } + + // If scrubbed left to edit threshold, trigger backspace-to-edit + if (currentHUDScrub <= -editWidth) { + currentHUDButtonActive = false; + currentHUDButtonDirectTouch = false; + triggerBackspaceAction(); + return; + } + + // If scrubbed but not to max, cancel the push + if ( + (currentHUDScrub > 0 && currentHUDScrub < shareWidth) || + (currentHUDScrub < 0 && currentHUDScrub > -editWidth) + ) { + btn.actions.cancel?.(); + return; + } + + // Clear global HUD button flags + currentHUDButtonActive = false; + currentHUDButtonDirectTouch = false; + + $api.sound.synth({ + tone: 1200, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + + // 🌐 Branded domain: tapping HUD label navigates to aesthetic.computer + if (redirectIfBrandedDomain()) return; + + if (!labelBack) { + jump("prompt"); + } else { + labelBack = false; // Reset `labelBack` after jumping. + if ($commonApi.history.length > 0) { + send({ type: "back-to-piece" }); + } else { + jump("prompt"); + } + } + $api.needsPaint(); + masked = true; + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + }, + scrub: (btn) => { + if (getPackMode()) return; // No scrub-to-share in bundled mode. + if (piece === "share") return; // No need to share scrub while in share. + if (currentHUDQR || currentHUDQRCells?.length || hudAnimationState.qrFullscreen) return; // Disable swipe-to-share when QR is shown. + + // Only allow scrubbing if this was a direct touch on the HUD button + if (!currentHUDButtonDirectTouch) return; + + if (btn.over || currentHUDScrub !== 0) { + const deltaX = Number(e?.delta?.x) || 0; + currentHUDScrub += deltaX; + } + + const fallbackShareWidth = (currentHUDLabelBlockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH) * "share ".length; + const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); + const editWidth = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); + + if (currentHUDScrub >= 0) { + const fallbackWidth = hudAnimationState.labelWidth + ? Math.max(hudAnimationState.labelWidth - currentHUDScrub, 0) + : currentHUDLabelBlockWidth * (currentHUDPlainTxt?.length || currentHUDTxt.length); + const baseWidth = currentHUDLabelMeasuredWidth ?? fallbackWidth; + btn.box.w = baseWidth + currentHUDScrub; + // console.log(btn.b); + } + + if (currentHUDScrub > shareWidth) currentHUDScrub = shareWidth; + if (currentHUDScrub < -editWidth) currentHUDScrub = -editWidth; + + let nextReadyState = null; + if (currentHUDScrub >= shareWidth) nextReadyState = "share"; + if (currentHUDScrub <= -editWidth) nextReadyState = "edit"; + + if (nextReadyState !== currentHUDScrubReadyState && nextReadyState !== null) { + $api.sound.synth({ + tone: nextReadyState === "share" ? 1600 : 900, + beats: 0.07, + attack: 0.01, + decay: 0.2, + volume: 0.12, + }); + } + currentHUDScrubReadyState = nextReadyState; + + if (currentHUDScrub >= shareWidth) { + currentHUDScrub = shareWidth; + currentHUDTextColor = [0, 255, 255]; + } else if (currentHUDScrub <= -editWidth) { + currentHUDScrub = -editWidth; + currentHUDTextColor = [255, 255, 0]; + } else if (currentHUDScrub > 0) { + currentHUDTextColor = [0, 200, 200]; + } else if (currentHUDScrub < 0) { + currentHUDTextColor = [255, 120, 180]; + } else if (currentHUDScrub === 0) { + if (btn.over) { + currentHUDTextColor = [0, 255, 0]; + } else { + currentHUDTextColor = [255, 0, 0]; + } + } + + $api.needsPaint(); + }, + cancel: () => { + currentHUDTextColor = originalColor; + + // Clear global HUD button flags + currentHUDButtonActive = false; + currentHUDButtonDirectTouch = false; + + const fallbackShareWidth = tf.blockWidth * "share ".length; + const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); + const editWidth = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); + if (currentHUDScrub === shareWidth) { + triggerShareAction(); + return; + } + + if (currentHUDScrub === -editWidth) { + triggerBackspaceAction(); + return; + } + + if (currentHUDScrub !== 0) { + $api.sound.synth({ + tone: 260, + beats: 0.09, + attack: 0.01, + decay: 0.2, + volume: 0.12, + }); + } + + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + // TODO: This might break on pieces where the keyboard is already + // open. + send({ type: "keyboard:disabled" }); // Disable keyboard flag. + send({ type: "keyboard:lock" }); + $api.needsPaint(); + $api.sound.synth({ + tone: 200, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + }, + rollover: (btn) => { + if (btn) { + send({ type: "keyboard:unlock" }); + if (btn.down) { + currentHUDTextColor = [0, 255, 0]; + } + } + }, + rollout: () => { + // console.log("rolled out..."); + currentHUDTextColor = [200, 80, 80]; + send({ type: "keyboard:lock" }); + }, + }); + } + + if (!masked && !TV_MODE) act($api); // Run the act function for all pen events (skip in TV mode). + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }); + + // *** 3D Pen Events *** + content.pen3d?.events?.forEach((data) => { + Object.assign(data, { + is: (e) => { + let [prefix, event, pointer] = e.split(":"); + if ( + prefix === "3d" && + event === data.name && + (pointer === undefined || parseInt(pointer) === data.pointer) + ) + return true; + }, + }); + $api.event = data; + try { + if (!TV_MODE) act($api); // Skip 3D pen events in TV mode + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }); + + // if (penEventCount > 0) { + // const processingTime = performance.now() - startTime; + //if (processingTime > 5) { // Only log if processing took more than 5ms + //console.log("🖋️ Pen event processing:", { avgPerEvent: (processingTime / penEventCount).toFixed(2) + "ms" + //}); + //} + // } + + // 🎬 Inject demoplay synthetic keyboard events + if ($commonApi.system.demoplay?.syntheticKeyboard?.length) { + const synth = $commonApi.system.demoplay.syntheticKeyboard.splice(0); + content.keyboard = [...(content.keyboard || []), ...synth]; + } + + // Ingest all keyboard input events by running act for each event. + content.keyboard?.forEach((data) => { + Object.assign(data, { + device: "keyboard", + is: (e) => { + const parts = e.split(":"); + if (parts.length > 2) { + // Check for an exact match if `keyboard:action:?` + return data.name === e; + } else { + // Or a subtring match if `keyboard:action` + return data.name.indexOf(e) === 0; + } + }, + }); + $api.event = data; + try { + if (!TV_MODE) act($api); // Execute piece shortcut (skip in TV mode). + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }); + + // Ingest all gamepad input events by running act for each event. + content.gamepad?.forEach((data) => { + Object.assign(data, { + device: "gamepad", + is: (e) => data.name.indexOf(e) === 0, + }); + $api.event = data; + try { + if (!TV_MODE) act($api); // Execute piece shortcut (skip in TV mode). + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }); + + // *** UI Cancel Interactions *** (For edge detection) + if (content.type === "ui:cancel-interactions" && content.content) { + const cancelData = { + name: "ui:cancel-interactions", + x: content.content.x, + y: content.content.y, + pointer: content.content.pointer, + reason: content.content.reason, + is: (e) => e === "ui:cancel-interactions", + }; + + $api.event = cancelData; + try { + if (!TV_MODE) act($api); // Execute cancel for all buttons (skip in TV mode) + } catch (e) { + console.warn("️ ✒ Act failure (UI cancel)...", e); + } + } + + // *** Act Alerts *** (Custom events defined in here.) + // These do not run in the initial loader / preview piece. + actAlerts.forEach((action) => { + // Check if `name`'s not a string, and if not, attach arbitrary data. + let name, + extra = {}; + if (typeof action !== "string") { + // Make extra be an object with every key on action other than 'name'. + ({ name, ...extra } = action); + } else { + name = action; + } + + const data = { + name, + is: (e) => e === name, + of: (e) => name.startsWith(e), + ...extra, + }; + + // TODO: All all fields from 'extra' into 'data'. + + $api.event = data; + try { + // In TV mode, only allow certain system events, not user input events + const allowInTvMode = name.startsWith('speech:') || + name.startsWith('dark-mode') || + name.startsWith('light-mode') || + name.startsWith('microphone-') || + name.startsWith('aesthetic-parent:'); + + if (!TV_MODE || allowInTvMode) { + act($api); + } + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }); + // 🎬 Forward actAlerts to demoplay conductor for wait-for actions + if ($commonApi.system.demoplay?.eventCallback) { + actAlerts.forEach((action) => { + const name = typeof action === "string" ? action : action.name; + if (name) $commonApi.system.demoplay.eventCallback(name); + }); + } + //if (actAlerts.length > 0) console.log(actAlerts, booted); + actAlerts.length = 0; // Clear act alerts. + } + + // 🖼 Paint + if (content.needsRender) { + let $api = {}; + keys($commonApi).forEach((key) => ($api[key] = $commonApi[key])); + keys(painting.api).forEach((key) => ($api[key] = painting.api[key])); + $api.api = $api; // Add a reference to the whole API. + + // 🎮 Add gamepad helper API (separate event streams per gamepad) + $api.gamepads = createGamepadAPI(content.gamepads); + + // Set the robo API context so it can send events + $api.robo.setAPI($api); + + // 🔒 Apply security restrictions for untrusted pieces + if (pieceMetadata && shouldRestrictPiece(pieceMetadata)) { + $api = createRestrictedApi($api, pieceMetadata); + } + + cachedAPI = $api; // Remember this API for any other acts outside + // of this loop, like a focus change or custom act broadcast. + + // Object.assign($api, $commonApi); + // Object.assign($api, painting.api); + + // Use piece-level frame counter that only increments when piece actually paints + // This ensures frame-based calculations (like in kidlisp) work correctly with FPS limiting + $api.paintCount = pieceFrameCount; + + $api.inFocus = content.inFocus; + + // Make a screen buffer or resize it automatically if it doesn't exist. + + if ( + !screen || + screen.width !== content.width || + screen.height !== content.height + ) { + const hasScreen = screen !== undefined; + + screen = { + pixels: + pixels || new Uint8ClampedArray(content.width * content.height * 4), + width: content.width, + height: content.height, + load: function load(name) { + if (store[name]?.pixels) { + this.pixels = new Uint8ClampedArray(store[name].pixels); + this.width = store[name].width; + this.height = store[name].height; + $commonApi.resize(this.width, this.height); + + // 🎨 Broadcast screen resize from load operation + if ($commonApi.broadcastPaintingUpdate) { + $commonApi.broadcastPaintingUpdate("resized", { + width: this.width, + height: this.height, + source: "screen_load" + }); + } + + return true; + } else { + return false; + } + }, + save: function save(name) { + store[name] = { + pixels: new Uint8ClampedArray(this.pixels), + width: this.width, + height: this.height, + }; + }, + }; + + screen[hasScreen ? "resized" : "created"] = true; // Screen change type. + + // 🎨 Broadcast screen resize to other tabs + if (hasScreen && $commonApi.broadcastPaintingUpdate) { + $commonApi.broadcastPaintingUpdate("resized", { + width: screen.width, + height: screen.height, + source: "screen_resize" + }); + } + + // TODO: Add the depth buffer back here. + // Reset the depth buffer. + // TODO: I feel like this is causing a memory leak... + graph.depthBuffer.length = screen.width * screen.height; + graph.depthBuffer.fill(Number.MAX_VALUE); + + graph.writeBuffer.length = 0; //screen.width * screen.height; + // graph.writeBuffer.fill(0); + } + + // TODO: Disable the depth buffer for now... it doesn't need to be + // regenerated on every frame. + // TODO: This only needs to run if 'form' is running in a piece. 25.03.20.19.27 + graph.depthBuffer.fill(Number.MAX_VALUE); // Clear depthbuffer. + graph.writeBuffer.length = 0; //fill(0); // Clear writebuffer. + + $api.screen = screen; + $api.screen.center = { x: screen.width / 2, y: screen.height / 2 }; + + // Snapshot true screen state each frame (after content.pixels transfer). + // page(sub-buffer) mutates screen in place; _mainScreenObject lets page() + // detect by identity when we're returning to the main screen and restore. + _trueScreenPixels = screen.pixels; + _trueScreenWidth = screen.width; + _trueScreenHeight = screen.height; + _mainScreenObject = screen; + + $api.fps = function (newFps) { + // Use piece-level timing instead of changing global render loop + if (newFps === undefined || newFps === null) { + if (pieceFPS !== null) { + pieceFPS = null; // Remove FPS limit only if it was previously set + } + } else { + // Only initialize timing if FPS wasn't set before, or if it's a different value + if (pieceFPS === null) { + // First time setting FPS - initialize timing + pieceFPS = newFps; + lastPaintTime = 0; // This will trigger "first frame" logic on next paint + } else if (pieceFPS !== newFps) { + // FPS changed to a different value - reset timing + pieceFPS = newFps; + lastPaintTime = 0; // This will trigger "first frame" logic on next paint + } + // If pieceFPS === newFps, do nothing (cache logic) + } + // Don't send fps-change to global render loop anymore + // send({ type: "fps-change", content: newFps }); + }; + + $api.cursor = (code) => { + cursorCode = code; + send({ type: "cursor:set", content: { style: code } }); + }; + + graph.setBuffer(screen); + + // API Stops being modified here... + /*if (!$activePaintApi)*/ $activePaintApi = $api; + + // Add paintings cache to API for KidLisp ready? function + $activePaintApi.paintings = paintings; + + // 🎯 Initialize global KidLisp instance with the main API + initializeGlobalKidLisp($api); + + // TODO: Set bpm from boot. + /* + $api.sound = { + time: content.time, + bpm: function (newBPM) { + if (newBPM) { + content.bpm[0] = newBPM; + } + return content.bpm[0]; + }, + }; + */ + + // TODO: Boot's painting is currently bound by whatever dirtyBox gets + // set to at the end of `paint`. + + // Run boot only once before painting for the first time. + if (paintCount === 0n && loading === false) { + const dark = await store.retrieve("dark-mode"); // Read dark mode. + if (dark === true || dark === false) $commonApi.dark = dark; + + // System specific preloaders. + //if ($commonApi?.system?.name === "nopaint" || currentText === "prompt") { + + // Create a new painting if one doesn't already exist. + if (!store["painting"]) { + store["painting"] = + (await store.retrieve("painting", "local:db")) || + painting.api.painting(screen.width, screen.height, ($) => { + $.wipe(64); + }); + + store["painting:resolution-lock"] = await store.retrieve( + "painting:resolution-lock", + "local:db", + ); + + store["painting:transform"] = await store.retrieve( + "painting:transform", + "local:db", + ); + + addUndoPainting(store["painting"]); + } + + const sys = $commonApi.system; + sys.painting = store["painting"]; + if (!Object.hasOwn(store, "painting:piece")) { + store["painting:piece"] = await store.retrieve("painting:piece", "local:db"); + } + sys.nopaint.piece = store["painting:piece"] || null; + + // Set the painting record if one is in storage. + if (!sys.nopaint.recording) { + sys.nopaint.record = + (await store.retrieve("painting:record", "local:db")) || []; + + if (sys.nopaint.record.length === 0) { + $commonApi.system.nopaint.startRecord("new"); + } + + sys.nopaint.recording = sys.nopaint.record.length > 0; + } + + sys.nopaint.translation = + store["painting:transform"]?.translation || sys.nopaint.translation; + sys.nopaint.zoomLevel = + store["painting:transform"]?.zoom || sys.nopaint.zoomLevel; + + try { + const bootStartTime = performance.now(); + diskTimings.bootStarted = Math.round(bootStartTime - diskTimingStart); + // Silent: boot() starting + + // Reset zebra cache at the beginning of boot to ensure consistent state + $api.num.resetZebraCache(); + + // Notify boot progress + // console.log("📢 Disk sending boot-log: running boot"); + send({ + type: "boot-log", + content: "running boot" + }); + + if (system === "nopaint") nopaint_boot({ ...$api, params: $api.params, colon: $api.colon }); + await boot($api); + if (system === "nopaint" && !/^nopaint(?:[:~]|$)/.test($api.slug || "")) { + sys.nopaint.syncWip($api).catch((error) => console.warn("Painting WIP:", error.message)); + } + const bootEndTime = performance.now(); + diskTimings.bootComplete = Math.round(bootEndTime - diskTimingStart); + // Silent: boot() completed + booted = true; + log.boot.success("Boot completed, booted =", booted, "pending events:", pendingExportEvents.length); + + // 📨 Flush any pending export events that arrived during boot + if (pendingExportEvents.length > 0) { + log.boot.debug(`Flushing ${pendingExportEvents.length} pending export events`); + log.boot.verbose("receive === defaults.receive:", receive === defaults.receive); + log.boot.verbose("typeof receive:", typeof receive); + const eventsToFlush = [...pendingExportEvents]; + pendingExportEvents = []; + eventsToFlush.forEach(event => { + if (typeof receive === "function" && receive !== defaults.receive) { + try { + log.boot.verbose("Flushing queued event:", event.type); + receive(event); + } catch (e) { + console.warn("📼 ❌ Error flushing event:", event.type, e); + } + } + }); + } + } catch (e) { + console.warn("🥾 Boot failure...", e); + } + send({ type: "disk-loaded-and-booted" }); + } + + // Paint a frame, which can return false to enable caching via noPaint and by + // default returns undefined (assume a repaint). + // Once paint returns false and noPaint is marked true, `needsPaint` must be called. + // Note: Always marked false on a disk's first frame. + + let painted = false; + let dirtyBox; + + // Render a thumbnail instead of the piece. + if (previewMode) { + try { + // Assign a default resolution on first preview, + // which can be over-ridden using `resolution` inside the + // `preview` function. + if (firstPreviewOrIcon) { + if (currentSearch === "preview") { + $api.resolution(1200 / 8, 630 / 8, 0); + } else { + $api.resolution( + ...currentSearch + .split("=")[1] + .split("x") + .map((n) => floor(parseInt(n) / 8)), + 0, + ); + } + firstPreviewOrIcon = false; + } + + preview($api); + painting.paint(true); + painted = true; + paintCount += 1n; + } catch (err) { + console.warn("🖼️ Preview failure...", err); + previewMode = false; + previewOrIconMode = previewMode || iconMode; + } + } else if (iconMode) { + // Render a favicon instead of the piece. + try { + if (firstPreviewOrIcon) { + $api.resolution(128, 128, 0); + if (currentSearch === "icon") { + $api.resolution(128, 128, 0); + } else { + console.log("Current:", currentSearch); + $api.resolution( + ...currentSearch + .split("=")[1] + .split("x") + .map((n) => parseInt(n)), + 0, + ); + } + firstPreviewOrIcon = false; + } + icon($api); + painting.paint(true); + painted = true; + paintCount += 1n; + } catch (err) { + console.warn("🪷 Icon failure...", err); + iconMode = false; + previewOrIconMode = previewMode || iconMode; + } + } + + // Attempt a paint. + if ( + previewMode === false && + iconMode === false && + (noPaint === false || scream || fairies.length > 0 || (system === "nopaint" && nopaintPerf)) && + booted + ) { + let paintOut; + + // Restore kidlisp's accumulated pan state from previous frame + $api.loadpan(); + + try { + // 📓 Bake any painting from the nopaint system before anything else. + if (system === "nopaint" && $api.system?.nopaint) { + const np = $api.system.nopaint; + // No Paint: baking + + if ( + (brush || filter) && + $api.pen?.drawing /*&& currentHUDButton.down === false*/ + ) { + const brushFilterApi = { ...$api }; + // Add top-level 'color' word that maps to system.nopaint.color + brushFilterApi.color = np.color; + if (currentHUDButton.down === false) { + brushFilterApi.pen = np.brush; + if (brush) { + // $api.page($api.system.nopaint.buffer); + $api.page($api.system.painting); + // 🔥 + // TODO: Use the pen data here to get an interpolation, + // then pan to each interpolated point and repaint. + // console.log( + // "🖌️ Brush:", + // brushFilterApi.pen, + // "🖊️ Pen:", + // $api.pen, + // ); + brush(brushFilterApi); + } + if (filter) { + $api.page($api.system.painting); + filter(brushFilterApi); + } + $api.page(screen); + } + } + + // 🎯 BAKING MOVED TO ACT PHASE: Removed duplicate baking logic + // Baking now happens immediately in act() to fix race conditions + if (np.is("painting") || np.needsPresent) { + np.present($api); // No Paint: prepaint + } + } // All: Paint + + // 🎞️ Piece-level FPS timing control + const now = performance.now(); + let shouldPaint = true; + + if (pieceFPS !== null && pieceFPS > 0) { + const targetFrameTime = 1000 / pieceFPS; // milliseconds per frame + + // If this is the first paint (lastPaintTime === 0), always allow it + if (lastPaintTime === 0) { + lastPaintTime = now; + shouldPaint = true; + } else { + const timeSinceLastPaint = now - lastPaintTime; + + if (timeSinceLastPaint < targetFrameTime) { + shouldPaint = false; // Skip this frame + } else { + lastPaintTime = now; // Update last paint time + shouldPaint = true; + } + } + } + + // Only call paint() if timing allows or no FPS limit is set + if (shouldPaint) { + // Reset zebra cache at the start of each frame so it can advance once per frame + $api.num.resetZebraCache(); + + // Always call paint() - piece paints underneath, GOL overlays on top + paintOut = paint($api); // Returns `undefined`, `false`, or `DirtyBox`. + graph.gpuLogTick(); // 📊 GPU effect logging every 8 frames + // Increment piece frame counter only when we actually paint + pieceFrameCount++; + + // 🎬 Piece Transition: Also capture noise16 frame as fallback (for initial page load) + if (paint === defaults.paint && $api.screen?.pixels) { + // Capture the noise16 state in case no piece was running before (initial load) + golTransition.overlayPixels = new Uint8ClampedArray($api.screen.pixels); + golTransition.width = $api.screen.width; + golTransition.height = $api.screen.height; + } + + // 🎬 Piece Transition: When first piece paint happens, switch to reveal phase + // Skip for KidLisp pieces - they have their own rendering that conflicts + const isKidLispPiece = currentPath && ( + lisp.isKidlispSource(currentPath) || + currentPath === "(...)" || + currentPath.includes("$") || + currentPath.endsWith('.lisp') + ); + + // Skip transitions entirely if TRANSITION_TYPE is "none" + if (TRANSITION_TYPE !== "none" && pieceFrameCount === 1 && paint !== defaults.paint && golTransition.overlayPixels && $api.screen?.pixels && !isKidLispPiece) { + const screenW = $api.screen.width; + const screenH = $api.screen.height; + const overlayW = golTransition.width; + const overlayH = golTransition.height; + + // If dimensions differ, scale the overlay to match the new screen + if (screenW !== overlayW || screenH !== overlayH) { + console.log(`🎬 Transition: Scaling overlay from ${overlayW}x${overlayH} to ${screenW}x${screenH}`); + golTransition.overlayPixels = scaleOverlayPixels( + golTransition.overlayPixels, + overlayW, overlayH, + screenW, screenH + ); + golTransition.width = screenW; + golTransition.height = screenH; + // Re-initialize with new dimensions if needed + if (golTransition.active) { + initGOLCells(screenW, screenH); + } + } + + // If transition was already started during load, switch to reveal phase + // Otherwise start it now (fallback for cases where capture didn't happen) + if (golTransition.active) { + transitionPieceLoaded(); // Switch from loading to reveal phase + } else { + // Fallback: start transition now if it wasn't started during load + initGOLCells(golTransition.width, golTransition.height); + golTransition.generation = 0; + golTransition.active = true; + golTransition.phase = "revealing"; // Skip loading, go straight to reveal + console.log(`🎬 Transition: Started ${TRANSITION_TYPE} (direct reveal)`, golTransition.width, "x", golTransition.height); + } + } + + // Log first piece paint + if (pieceFrameCount === 1) { + diskTimings.firstPaint = Math.round(performance.now() - diskTimingStart); + // Silent: first piece paint + } + + // 🎨 Signal to hide boot canvas when piece's paint takes over from default noise16 + // Only signal once when piece has a custom paint function (not defaults.paint) + // Note: pieceFrameCount may already be >1 if defaults.paint ran while the module + // was loading (e.g., KidLisp pieces that take several seconds to initialize). + // Use acPieceReady itself as the "already fired" guard instead of pieceFrameCount. + if (paint !== defaults.paint && !(typeof window !== "undefined" && window.acPieceReady)) { + send({ type: "piece-paint-ready" }); + // 🔥 Global signal for Puppeteer/Oven to detect piece is ready for screenshot + if (typeof window !== "undefined") { + window.acPieceReady = true; + window.acPieceReadyTime = Date.now(); + const isPackMode = + (typeof window !== "undefined" && window.acPACK_MODE) || + (typeof globalThis !== "undefined" && globalThis.acPACK_MODE); + if (!isPackMode) { + console.log("🟢 acPieceReady = true (first paint complete)"); + } + } + } + + // TODO: Remove old embedded layer rendering - using simplified approach now + // globalThis.renderKidlispProgrammaticLayers(); + } else { + // When skipping paint, use undefined to ensure continuous rendering + // This maintains the render loop while skipping the actual paint call + paintOut = undefined; + } + + // Store paintOut for next frame (only when we actually painted) + if (shouldPaint) { + lastPaintOut = paintOut; + } + + // Save kidlisp's accumulated pan state for next frame + $api.savepan(); + // Reset pan for system UI rendering + $api.unpan(); + } catch (e) { + console.warn("🎨 Paint failure...", e); + } + + // `DirtyBox` and `undefined` always set `noPaint` to `true`. + noPaint = + paintOut === false || (paintOut !== undefined && paintOut !== true); + + // 🧬 GOL Transition: Force pixels to be sent while transition is active + if (golTransition.active) { + noPaint = false; // Override - GOL modified pixels, must send them + } + + + // Run everything that was queued to be painted, then devour paintLayers. + //await painting.paint(); + + // Upper layer. + const { page, layer, ink, needsPaint, pieceCount } = $api; + + // Restore screen if page(sub-buffer) calls during paint left it corrupted. + // Brushes like smear call page(system.painting) which mutates screen.pixels/width/height. + // page(screen) at the end of those brushes can't restore because screen is already corrupted. + if (_trueScreenPixels && screen.pixels !== _trueScreenPixels) { + screen.pixels = _trueScreenPixels; + screen.width = _trueScreenWidth; + screen.height = _trueScreenHeight; + } + + page($api.screen); // Make sure we're on the right bufer. + + layer(1000); // Always make sure this stuff draws on top. + + // const piece = $api.slug?.split("~")[0]; + // if ( + // !previewMode && + // !iconMode && + // !hideLabel && + // system !== "prompt" && + // piece !== "textfence" && + // piece !== "bleep" && + // piece !== undefined && + // piece.length > 0 && + // piece !== "painting" && + // pieceCount > 0 + // ) { + // currentPromptButton = + // currentPromptButton || + // new $api.ui.TextButton("Back", { + // left: 6, + // bottom: 6, + // screen: $api.screen, + // }); + // currentPromptButton.paint($api); + // } + + // 😱 Scream - Paint a scream if it exists. + // TODO: Should this overlay after the fact and not force a paint? 23.05.23.19.21 + // Yes probably, because of layering issues? + if (scream || screaming) { + ink("yellow").write(scream, { x: 6, y: 18 }, "red"); + //ink("red").write(scream, { x: 6 + 1, y: 18 + 1 }); + + /* + ink(255) + .wipe(255, 0, 0) + .write( + scream, + { center: "xy", size: 3, thickness: 1 }, + undefined, + $api.screen.width - 8, + needsPaint(), + ); + */ + if (!screaming) { + screaming = true; + clearTimeout(screamingTimer); + screamingTimer = setTimeout(() => { + screaming = false; + scream = null; + }, 1000); + } + } + + // 🧚 Ambient Pen Points - Paint if they exist. + fairies.forEach(({ x, y }) => { + ink().point(x * screen.width, y * screen.height); + }); + if (fairies.length > 0) { + needsPaint(); + // if (system === "nopaint") $api.system.nopaint.needsPresent = true; + } + fairies.length = 0; + + // 🔴 Show a cross-piece "Recording" indicator. + // Currently only implemented for `painting:record`. 23.08.20.21.36 + if ( + $api.system?.nopaint?.recording && + !hideLabel && + pieceHistoryIndex > -1 && + !loading + ) { + // ink("red").box(screen.width - 3, 1, 2); + } + + // Show a notice if necessary. + if (notice) { + ink(noticeColor[0]) + //.pan(help.choose(-1, 0, 1), help.choose(-1, 0, 1)) + .write( + notice, + { center: "xy", size: 2 }, + // { center: "x", y: 32, size: 2 }, + noticeColor[1], + $api.screen.width - 8, + noticeOpts?.wrap === "char" ? false : true, + ); + //.unpan(); + } + + layer(0); + + painting.paint(true); + + // 🎬 Piece Transition: Step and apply overlay AFTER painting.paint() executes + // This ensures the overlay is composited on TOP of the piece's rendered frame + if (golTransition.active && screen?.pixels) { + // Check dimensions still match + if (screen.width !== golTransition.width || screen.height !== golTransition.height) { + console.warn("🎬 Transition: Screen dimensions changed! Aborting."); + golTransition.active = false; + golTransition.overlayPixels = null; + golTransition.blockData = null; + golTransition.dripOffsets = null; + golTransition.dripSpeeds = null; + golTransition.dripDirections = null; + } else { + // Step the transition + const stillActive = golStep(screen.pixels); + + // Apply overlay on top of piece's paint + applyGOLOverlay(screen.pixels); + + // End transition when complete + if (!stillActive) { + console.log(`🎬 Transition: ${TRANSITION_TYPE} complete!`); + golTransition.active = false; + golTransition.overlayPixels = null; + golTransition.blockData = null; + golTransition.dripOffsets = null; + golTransition.dripSpeeds = null; + golTransition.dripDirections = null; + } + } + } + + painted = true; + if (system === "video") console.log('🟢 DISK: painted set to TRUE, paintCount:', paintCount); + paintCount = paintCount + 1n; + + // Check for frame-based recording completion (runs on every painted frame) + if ($api.rec.tapeFrameMode && $api.rec.tapeFrameTarget > 0) { + const currentFrame = Number(paintCount); + const framesPassed = currentFrame - $api.rec.tapeFrameStart; + + $api.rec.tapeProgress = framesPassed / $api.rec.tapeFrameTarget; + + if (framesPassed >= $api.rec.tapeFrameTarget) { + console.log(`🎬 Frame-based recording complete: ${framesPassed}/${$api.rec.tapeFrameTarget} frames`); + + // Clear failsafe since normal timer completed + if ($api.rec.failsafeTimeout) { + clearTimeout($api.rec.failsafeTimeout); + $api.rec.failsafeTimeout = null; + } + + // Reset frame-based recording state + $api.rec.tapeProgress = 0; + $api.rec.tapeFrameMode = false; + $api.rec.tapeFrameStart = 0; + $api.rec.tapeFrameTarget = 0; + + // Trigger cut and jump to video + if (typeof $api.rec.cut === 'function') { + $api.rec.cut(() => { + $commonApi.jump("video"); + }); + } else { + log.tape.warn("Cut function not available"); + $commonApi.jump("video"); + } + } + } + + if (paintOut) dirtyBox = paintOut; + + delete screen.resized; // Remove status from screen after painting. + delete screen.created; + + //console.log("bake") + //send({ type: "3d-bake" }); + } else { + if (system === "video") { + console.log('❌ DISK: PAINT BLOCK SKIPPED - previewMode:', previewMode, 'iconMode:', iconMode, 'noPaint:', noPaint, 'booted:', booted); + } + } + + // 🏷️ corner-label: Draw any Global UI / HUD in an overlay buffer that will get + // composited by the other thread. + + // TODO: ❤️‍🔥 Why is this being composited by a different thread? + // Also... where do I put a scream? + + // System info label (addressability). + let label; + const piece = currentHUDTxt?.split("~")[0]; + const defo = 6; // Default offset + + + + if ( + !previewMode && + !iconMode && + !hideLabel && + piece !== undefined && + piece.length > 0 + ) { + + // Use plain text for width calculation to avoid counting color codes + const textForWidthCalculation = currentHUDPlainTxt || currentHUDTxt; + + // Double-check: strip color codes directly if they're still present + // Handle multiple color code formats: \colorname\, \255,255,255\, etc. + const colorCodeRegex = /\\[^\\]*\\/g; // Matches \anything\ format + let cleanText = textForWidthCalculation.replace(colorCodeRegex, ''); + + // Also try removing RGB color codes like \255,255,255\ if any remain + cleanText = cleanText.replace(/\\\d+,\d+,\d+(,\d+)?\\/g, ''); + + // Remove any remaining single backslashes that might be color code remnants + cleanText = cleanText.replace(/\\/g, ''); + + // Analyze visible lines for accurate sizing + const rawLines = cleanText.split('\n'); + const visibleLines = rawLines.map((line) => line.replace(/\s+$/, '')); + const visibleLineCount = visibleLines.length; + + const defaultTypeface = tf; + const matrixTypeface = typefaceCache.get("MatrixChunky8"); + + const isKidlispPiece = detectKidLispPiece({ + currentPath, + currentHUDTxt, + currentText, + cleanText, + }); + + // 🎨 KidLisp HUD color: green when running, yellow when paused + if (isKidlispPiece && !currentHUDTextColor) { + if (loopPaused) { + currentHUDTextColor = [255, 220, 80]; // Yellow when paused (matches pause button #ffd93d) + } else { + currentHUDTextColor = [128, 255, 128]; // Green when running + } + } + + // 🔤 Font Preloading: Ensure MatrixChunky8 starts loading early if available + if (matrixTypeface && !matrixTypeface.__loadPromise) { + ensureTypefaceLoaded(matrixTypeface); + } + + const measureLineWidth = (line, typeface) => { + if (!line || !line.length || !typeface) return 0; + const advances = typeface.data?.advances; + const defaultAdvance = typeface.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; + let width = 0; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + width += advances && advances[ch] !== undefined ? advances[ch] : defaultAdvance; + } + return width; + }; + + const buildLayout = (typeface) => { + if (!typeface) return null; + + const fontIdentifier = typeface === defaultTypeface ? undefined : typeface.name; + const blockWidth = typeface.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; + const blockHeight = typeface.blockHeight || DEFAULT_TYPEFACE_BLOCK_HEIGHT; + const lineWidths = visibleLines.map((line) => measureLineWidth(line, typeface)); + const longestLineWidth = lineWidths.reduce((max, width) => Math.max(max, width), 0); + // 🎨 Use screen width minus padding for KidLisp wrapping + // Account for left margin (HUD_LABEL_TEXT_MARGIN) in measurement bounds + const bounds = isKidlispPiece ? ($api.screen.width - 6 - HUD_LABEL_TEXT_MARGIN) : Math.max($api.screen.width * 4, 1000); + + const naturalBounds = $api.text.box( + cleanText, + { x: 0, y: 0 }, + bounds, + 1, + true, // Enable wrapping for proper measurement + fontIdentifier, + ); + + const wrappedLines = Array.isArray(naturalBounds?.lines) + ? naturalBounds.lines.map((line) => { + if (typeof line === "string") return line; + return line?.join?.(" ") || ""; + }) + : visibleLines; + const wrappedLineWidths = wrappedLines.map((line) => + measureLineWidth((line || "").replace(/\s+$/, ""), typeface), + ); + const wrappedLineStep = blockHeight + 1; + const wrappedLastLineIndex = Math.max(0, wrappedLines.length - 1); + const wrappedLastLineWidth = wrappedLineWidths[wrappedLastLineIndex] || 0; + const wrappedLastLineY = wrappedLastLineIndex * wrappedLineStep; + + if (fontIdentifier === "MatrixChunky8") { + for (const line of visibleLines) { + for (let i = 0; i < line.length; i++) { + typeface.glyphs?.[line[i]]; + } + } + } + + return { + typeface, + fontIdentifier, + blockWidth, + blockHeight, + lineWidths, + longestLineWidth, + textBoxWidth: naturalBounds?.box?.width ?? 0, + textBoxHeight: naturalBounds?.box?.height ?? 0, + naturalBounds, + wrappedLines, + wrappedLineWidths, + wrappedLastLineWidth, + wrappedLastLineY, + }; + }; + + const defaultLayout = buildLayout(defaultTypeface); + let selectedLayout = defaultLayout; + + // 🔤 Force MatrixChunky8 if hud.tinyLabel() was called + if (forceTinyHudLabel && matrixTypeface) { + const matrixLayout = buildLayout(matrixTypeface); + if (matrixLayout) { + selectedLayout = matrixLayout; + } + } + + // Disabled: MatrixChunky8 breakpoint removed per user request + // if ( + // isKidlispPiece && + // matrixTypeface && + // defaultLayout && + // defaultLayout.longestLineWidth > $api.screen.width + // ) { + // const matrixLayout = buildLayout(matrixTypeface); + // if (matrixLayout) { + // selectedLayout = matrixLayout; + // } + // } + + if (!selectedLayout && matrixTypeface) { + selectedLayout = buildLayout(matrixTypeface); + } + + if (!selectedLayout) { + selectedLayout = defaultLayout; + } + + const selectedTypeface = selectedLayout?.typeface || defaultTypeface; + const selectedHudFont = selectedLayout?.fontIdentifier; + const hudBlockWidth = selectedLayout?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH; + const hudBlockHeight = selectedLayout?.blockHeight ?? DEFAULT_TYPEFACE_BLOCK_HEIGHT; + const textBounds = selectedLayout?.naturalBounds; + const textBoxWidth = selectedLayout?.textBoxWidth ?? 0; + const textBoxHeight = selectedLayout?.textBoxHeight ?? 0; + const longestVisibleLineWidth = selectedLayout?.longestLineWidth ?? 0; + const wrappedLineCount = textBounds?.lines?.length ?? visibleLineCount; + const measuredTextHeight = textBoxHeight || (wrappedLineCount * hudBlockHeight); + currentHUDTextBoxWidth = textBounds?.box?.width || 0; + currentHUDTextBoxHeight = textBounds?.box?.height || 0; + currentHUDTextHeight = measuredTextHeight; + const wrappedLastLineWidth = selectedLayout?.wrappedLastLineWidth ?? 0; + const wrappedLastLineY = selectedLayout?.wrappedLastLineY ?? 0; + + // 🔤 Font Loading Check: If MatrixChunky8 was selected but not loaded, skip rendering HUD + // This prevents pop-in where font_1 renders briefly before MatrixChunky8 glyphs load + let skipHudRender = false; + if (selectedHudFont === "MatrixChunky8" && matrixTypeface) { + // Check if actual glyph data exists (glyphs are loaded on-demand via Proxy) + // Test a common character like '$' to verify glyphs are accessible + // A real glyph will have properties like dwidth, advance, or resolution + // The Proxy returns null for missing glyphs (typeof null === 'object') + const dollarGlyph = matrixTypeface.glyphs && matrixTypeface.glyphs['$']; + const hasGlyphs = dollarGlyph && dollarGlyph !== null && + (dollarGlyph.dwidth || dollarGlyph.advance !== undefined || dollarGlyph.resolution); + + if (!hasGlyphs) { + // Trigger loading if not started + if (!matrixTypeface.__loadPromise) { + ensureTypefaceLoaded(matrixTypeface); + } + + // Wait for font to load + if (matrixTypeface.__loadPromise) { + matrixTypeface.__loadPromise.then(() => { + matrixTypeface.__loaded = true; + // Trigger repaint once loaded + if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { + window.$activePaintApi.needsPaint(); + } + }).catch((err) => { + console.warn("Failed to load MatrixChunky8:", err); + matrixTypeface.__loaded = true; // Mark as loaded to prevent infinite waiting + }); + } + // Skip rendering this frame - wait for font to load + skipHudRender = true; + } + } + + // Only proceed with HUD rendering if font is ready + if (!skipHudRender) { + + const measuredShareWidth = measureLineWidth("share ", selectedTypeface); + const fallbackShareWidth = hudBlockWidth * "share ".length; + const effectiveShareWidth = measuredShareWidth > 0 + ? Math.max(measuredShareWidth, fallbackShareWidth) + : fallbackShareWidth; + const measuredEditWidth = measureLineWidth("edit ", selectedTypeface); + const fallbackEditWidth = hudBlockWidth * "edit".length; + const dynamicEditWidth = getHudEditScrubThreshold(); + const effectiveEditWidth = Math.max( + measuredEditWidth > 0 ? Math.max(measuredEditWidth, fallbackEditWidth) : fallbackEditWidth, + dynamicEditWidth, + ); + currentHUDShareWidth = effectiveShareWidth; + currentHUDEditWidth = effectiveEditWidth; + const shareWidth = effectiveShareWidth; + currentHUDLeftPad = shareWidth; + const scrubExtension = Math.max(0, currentHUDScrub); + const editExtension = currentHUDScrub < 0 ? effectiveEditWidth : 0; + + currentHUDLabelFontName = selectedHudFont; + currentHUDLabelBlockWidth = hudBlockWidth; + currentHUDLabelBlockHeight = hudBlockHeight; + + // Prefer visible line width for KidLisp pieces to avoid oversized buffers + // When wrapping is enabled, use textBoxWidth (wrapped width) instead + let w = isKidlispPiece ? textBoxWidth : textBoxWidth; + if (!w || w <= 0) { + w = textBoxWidth; + } + + // Add extra padding to buffer width to prevent font_1 character cutoff + const bufferWidthPadding = 6; // Extra pixels for wider glyphs + w += bufferWidthPadding; + + // 📡 LAN badge superscript - add width for device letter if connected to dev session server + // Hide in kidlisp.com embedded mode (NOAUTH_MODE) + // 🔇 Disabled for now (@jeffrey) — hide the cyan A/B/C/D/E device-letter + // window badge in both dev and production. Restore by uncommenting below. + // const showLanBadge = devIdentity && devIdentity.host && !globalThis.NOAUTH_MODE; + const showLanBadge = false; + const lanBadgePadding = showLanBadge ? 10 : 0; // Space for letter + margin + w += lanBadgePadding; + + // ✨ Custom superscript (e.g. ".com") - add width for text + const showCustomSuperscript = !!currentHUDSuperscript; + const customSuperscriptPadding = showCustomSuperscript ? (currentHUDSuperscript.length * 5 + 3) : 0; + w += customSuperscriptPadding; + + // 📱 HUD QR code - generate cells and calculate width + let hudQRSize = 0; + let hudQRPadding = 0; + // Debug: Log QR state on first few frames (disabled for cleaner output) + // if (pieceFrameCount < 3) { + // console.log("📱 HUD QR render check - frame:", pieceFrameCount, "currentHUDQR:", JSON.stringify(currentHUDQR), "currentHUDQRCells:", !!currentHUDQRCells); + // } + if (currentHUDQR) { + // Generate QR cells if not cached + if (!currentHUDQRCells) { + try { + currentHUDQRCells = qr(currentHUDQR, { errorCorrectLevel: ErrorCorrectLevel.L }).modules; + console.log("📱 QR cells generated:", currentHUDQRCells?.length, "for URL:", currentHUDQR); + } catch (e) { + console.error("HUD QR generation failed:", e); + currentHUDQRCells = null; + } + } + if (currentHUDQRCells) { + hudQRSize = currentHUDQRCells.length; // 1px per cell + hudQRPadding = 4; // Gap between QR and text + } + } + const hudQRTotalWidth = hudQRSize > 0 ? hudQRSize + hudQRPadding + 2 : 0; // +2 for border + + // Final text dimensions: KidLisp width uses visible line metrics, height from text box + let h = measuredTextHeight; + + if (piece === "video") w = screen.width; + + const baseLabelWidth = w + shareWidth; + + // Use natural text dimensions for buffer - preserve original layout + // Add QR code width to buffer if present + let bufferW = baseLabelWidth + scrubExtension + editExtension + hudQRTotalWidth; + let bufferH = h; + + // Ensure buffer height fits QR code if present + if (hudQRSize > 0 && bufferH < hudQRSize + 4) { + bufferH = hudQRSize + 4; // QR size + padding + } + + // Ensure minimum buffer size for readability + const minTextWidth = Math.max(isKidlispPiece ? textBoxWidth : textBoxWidth, 50); + const minimumAllowedWidth = shareWidth + Math.max(minTextWidth, 0); + const minBufferH = Math.max(hudBlockHeight, 20); // At least one line height + + if (bufferW < minimumAllowedWidth + scrubExtension + editExtension) { + bufferW = minimumAllowedWidth + scrubExtension + editExtension; + } + if (bufferH < minBufferH) { + bufferH = minBufferH; + } + + // Fix height calculation for KidLisp pieces when wordWrap=false + if (isKidlispPiece) { + // When wordWrap=false, text.box doesn't calculate height correctly for multi-line text + // Calculate proper height based on actual line count + const properHeight = measuredTextHeight; + if (properHeight > bufferH) { + bufferH = properHeight; + } + } + + const hudDescenderPadding = Math.max(2, Math.round(hudBlockHeight * 0.2)); + bufferH += hudDescenderPadding; + + // DEBUG: Log buffer dimensions in copy-pasteable format (reduced frequency) + if (false && isKidlispPiece && currentHUDTxt && pieceFrameCount % 120 === 0) { + console.log(`HUD_BUFFER: bufferW=${bufferW} bufferH=${bufferH} textBoxW=${textBounds.box.width} textBoxH=${textBounds.box.height} longestLinePx=${longestVisibleLineWidth} lines=${visibleLineCount} fontHeight=${hudBlockHeight} expectedH=${visibleLineCount * hudBlockHeight}`); + console.log(`HUD_TEXT: "${cleanText.replace(/\n/g, '\\n').substring(0, 150)}"`); + } + + + + // Store actual dimensions for animation calculations + currentHUDLabelMeasuredWidth = baseLabelWidth; + hudAnimationState.labelWidth = bufferW; + hudAnimationState.labelHeight = bufferH; + h = bufferH; + + // Ease the hover drift in and out (clamped dt so a stalled tab can't + // leap), then keep painting until it has fully settled. + { + const now = performance.now(); + const dt = Math.min(50, now - currentHUDHoverClock); + currentHUDHoverClock = now; + currentHUDHoverAmount = + currentHUDHovered && !currentHUDButton?.down + ? Math.min(1, currentHUDHoverAmount + dt / HUD_DRIFT_EASE_IN_MS) + : Math.max(0, currentHUDHoverAmount - dt / HUD_DRIFT_EASE_OUT_MS); + } + if (currentHUDHovered || currentHUDHoverAmount > 0) $api.needsPaint(); + + label = $api.painting(bufferW, bufferH, ($) => { + // Ensure label renders with clean pan state + $.unpan(); + // Ensure label renders without any active mask from the piece + $.unmask(); + + // Clean rendering - no debug elements + + + + let c; + if (currentHUDTextColor) { + c = num.shiftRGB(currentHUDTextColor, [255, 255, 255], 0.75); + } else if (currentHUDStatusColor) { + c = currentHUDStatusColor; + } else { + c = [255, 200, 240]; + } + if (piece !== "video") { + let text = currentHUDTxt; + if (currentHUDTxt.split(" ")[1]?.indexOf("http") !== 0) { + text = currentHUDTxt?.replaceAll("~", " "); + // Handle command separators: replace § with spaces for display + // but keep actual newlines as newlines + text = text?.replaceAll("§", " "); + } + + // 📱 Render QR code to the LEFT of the text if present + let qrOffset = 0; + if (currentHUDQR && currentHUDQRCells) { + const qrCells = currentHUDQRCells; + const qrSize = qrCells.length; + // Position QR at the start of visible area (after share button padding) + // Include scrub offset so QR moves with the label when swiping to "share" + // Move left by 4px for tighter fit (up is handled by label Y offset) + // Inset from the corner so frameless desktop panes (macOS + // rounded corners) never clip the QR's quiet zone. + const qrX = currentHUDLeftPad + currentHUDScrub + 2; + const qrY = 4; + + // White background (1px border around QR) + $.ink(255, 255, 255).box(qrX, qrY, qrSize + 2, qrSize + 2); + + // Draw QR code cells + for (let y = 0; y < qrSize; y++) { + for (let x = 0; x < qrSize; x++) { + if (qrCells[y][x]) { + $.ink(0, 0, 0).box(qrX + 1 + x, qrY + 1 + y, 1, 1); + } + } + } + + qrOffset = qrSize + 10; // QR width + border + breathing room + } + + const baseX = currentHUDLeftPad + qrOffset; + // ✨ Rollover: tint toward the press green and let each character + // drift on its own spring (hudDriftX/Y) — the "I'm alive" cue a + // TextButton gives before a press, loosened up per letter. + const hoverEase = hudDriftEase(currentHUDHoverAmount); + // Keep text fixed while scrubbing left; only right-scrub shifts label content. + const hudTextX = baseX + HUD_LABEL_TEXT_MARGIN + Math.max(0, currentHUDScrub); + const hudTextY = 4; // Aligned with the QR (corner-radius safe) + const typefaceNameForWrite = selectedHudFont; + const hasColorCodes = textContainsColorCodes(text); + const baseTextColor = + hoverEase > 0 + ? num.shiftRGB( + graph.findColor(currentHUDTextColor || "white"), + [0, 255, 0], + 0.4 * hoverEase, + ) + : currentHUDTextColor || "white"; + + // 🎨 Character wrapping for KidLisp HUD prompts + // Wrap based on screen width minus padding (6px total: 2px margin + 4px padding) + // Subtract the left margin since text starts at x=HUD_LABEL_TEXT_MARGIN + const wrapBounds = isKidlispPiece ? ($api.screen.width - 6 - HUD_LABEL_TEXT_MARGIN) : undefined; + + // Drift places glyphs itself, so it takes any label that fits on + // one line — including kidlisp-flagged ones (a piece named after + // a kidlisp word, like `line`, gets wrapBounds too). A label that + // really wraps keeps the tint and lets text.box break its lines. + const fitsOneLine = + !text?.includes("\n") && + (selectedLayout?.wrappedLines?.length ?? 1) <= 1; + if (hoverEase > 0 && (wrapBounds === undefined || fitsOneLine)) { + drawHudLabelDrift($, text, { + x: hudTextX, + y: hudTextY, + typefaceName: typefaceNameForWrite, + typeface: selectedTypeface, + textColor: baseTextColor, + lineStep: hudBlockHeight + 1, + t: currentHUDHoverClock, + ease: hoverEase, + }); + } else { + drawHudLabelText($, text, { + x: hudTextX, + y: hudTextY, + typefaceName: typefaceNameForWrite, + textColor: baseTextColor, + shadowColor: "black", + shadowOffsetX: 1, + shadowOffsetY: 1, + preserveColors: hasColorCodes, + bounds: wrapBounds, + // wordWrap defaults to true when bounds is set, enabling character wrapping + }); + } + + // ✨ Custom Superscript (e.g. ".com") - draw after the text in cyan + if (showCustomSuperscript) { + const firstLine = text?.split('\n')[0] || text; + const firstLineWidth = cachedAPI.text.width( + stripColorCodes(firstLine), + selectedTypeface + ); + + const superscriptX = hudTextX + firstLineWidth + 2; + // Baseline-align the compact suffix with the larger HUD label. + const suffixBlockHeight = 8; // MatrixChunky8 + const superscriptY = hudTextY + Math.max( + 0, + (currentHUDLabelBlockHeight || 10) - suffixBlockHeight, + ); + + // Special handling for ".com" - color the "." based on connection status + if (currentHUDSuperscript === ".com") { + const dotWidth = cachedAPI.text.width(".", "MatrixChunky8"); + + // Drop shadow (1px down/right) matching the HUD label shadow + $.ink("black"); + $.write(".", { x: superscriptX, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); + $.write("com", { x: superscriptX + dotWidth + 1, y: superscriptY + 1 }, undefined, undefined, false, "MatrixChunky8"); + + // Color dot based on socket connection: green if connected, red if not + if (socket?.connected) { + $.ink(0, 255, 100); // Green for connected + } else { + $.ink(255, 100, 100); // Red/salmon for disconnected + } + $.write(".", { x: superscriptX - 1, y: superscriptY - 1 }, undefined, undefined, false, "MatrixChunky8"); + + // Render "com" in bright cyan + $.ink(0, 220, 255); // Bright cyan + $.write("com", { x: superscriptX + dotWidth, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); + } else { + // Default rendering for other superscripts (with drop shadow) + $.ink("black"); + $.write(currentHUDSuperscript, { x: superscriptX + 1, y: superscriptY + 1 }, undefined, undefined, false, "MatrixChunky8"); + $.ink(0, 220, 255); // Bright cyan + $.write(currentHUDSuperscript, { x: superscriptX, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); + } + } + + // 📡 LAN Badge Superscript - draw device letter after the text (+ custom superscript offset) + if (showLanBadge) { + const deviceLetter = devIdentity.letter || + (devIdentity.name ? devIdentity.name.charAt(0).toUpperCase() : null) || + String.fromCharCode(65 + (devIdentity.connectionIndex || 0)); // A, B, C... + + // Get the first line of text for positioning the superscript + const firstLine = text?.split('\n')[0] || text; + const firstLineWidth = cachedAPI.text.width( + stripColorCodes(firstLine), + selectedTypeface + ); + + // Position superscript to the right of first line (after custom superscript if present) + const superscriptX = hudTextX + firstLineWidth + 2 + customSuperscriptPadding; + const superscriptY = 0; // Top of the line (superscript position) + + // Draw letter in cyan using MatrixChunky8 + $.ink(0, 220, 255); // Bright cyan matching original badge + $.write(deviceLetter, { x: superscriptX, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); + } + + // 🔗 Draw "share" text in the LEFT reveal area when scrubbing + if (currentHUDScrub > 0) { + // Slide "share" in from the left as the scrub progresses. + const shareTextX = + currentHUDLeftPad + currentHUDScrub - shareWidth + HUD_LABEL_TEXT_MARGIN; + const shareTextY = 0; + + drawHudLabelText($, "share", { + x: shareTextX, + y: shareTextY, + typefaceName: undefined, // Use default font, not MatrixChunky8 + textColor: [0, 255, 255], + shadowColor: "black", + preserveColors: false, + }); + } + + // ⬅️ Draw backspace/edit reveal on right when scrubbing left + if (currentHUDScrub < 0) { + const scrubMagnitude = Math.abs(currentHUDScrub); + const editThreshold = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); + const editProgress = Math.min(1, scrubMagnitude / editThreshold); + const lastLineWidth = Math.max( + 1, + wrappedLastLineWidth || + currentHUDTextBoxWidth || + (stripColorCodes(text || "").length * hudBlockWidth), + ); + const caretWidth = Math.max(2, Math.round(hudBlockWidth * 0.9)); + const caretHeight = Math.max(hudBlockHeight, 6); + const caretX = Math.min( + Math.max(hudTextX + lastLineWidth + 2, 0), + Math.max(bufferW - caretWidth - 2, 0), + ); + const unclampedCaretY = Math.max(0, hudTextY + wrappedLastLineY); + const caretY = Math.min(unclampedCaretY, Math.max(bufferH - caretHeight - 1, 0)); + const filledCols = Math.max(1, Math.round(caretWidth * editProgress)); + const editColor = editProgress >= 1 ? [255, 170, 210] : [255, 107, 157]; + + // Draw per-column with shadows and a fly-in offset from the right. + for (let col = 0; col < filledCols; col++) { + const t = filledCols > 1 ? col / (filledCols - 1) : 1; + const flyIn = Math.round((1 - t) * (caretWidth - filledCols)); + const cx = caretX + col + flyIn; + $.ink(0, 0, 0, 180).box(cx + 1, caretY + 1, 1, caretHeight); + $.ink(...editColor).box(cx, caretY, 1, caretHeight); + } + } + } else { + $.ink(0).line(1, 1, 1, h - 1); + $.ink(c).line(0, 0, 0, h - 2); + } + }); + + if (piece === "video") currentHUDOffset = { x: 0, y: 6 }; + if (!currentHUDOffset) currentHUDOffset = { x: defo, y: defo }; + + // Apply animation effects to HUD label position + if (hudAnimationState.animating) { + const currentTime = performance.now(); + const elapsed = currentTime - hudAnimationState.startTime; + const progress = Math.min(elapsed / hudAnimationState.duration, 1.0); + + // Easing function for smooth macOS-style animation (ease-out) + const easeOut = 1 - Math.pow(1 - progress, 3); + + // Use actual calculated dimensions from when HUD was last drawn + const hudWidth = hudAnimationState.labelWidth || 120; // fallback to default + const hudHeight = hudAnimationState.labelHeight || 40; // fallback to default + const qrSize = hudAnimationState.qrSize || 80; + + // Calculate slide distances: ensure full content slides off-screen + // Add extra padding (20px) to guarantee complete disappearance + // For very large labels, cap the slide distance to keep animation smooth + const maxSlideDistance = 400; // Maximum distance to slide for smooth animation + const hudSlideX = Math.max(-(hudWidth + 20), -maxSlideDistance); // Cap slide distance + const hudSlideY = Math.max(-(hudHeight + 20), -maxSlideDistance); // Cap slide distance + const qrSlideX = qrSize + 20; // Slide right by full size + padding + const qrSlideY = qrSize + 20; // Slide down by full size + padding + + if (hudAnimationState.visible) { + // Animating IN: fade from 0 to 1, slide from respective corners to position + hudAnimationState.opacity = easeOut; + // HUD slides in from top-left corner + hudAnimationState.slideOffset = { + x: (1 - easeOut) * hudSlideX, // Slide in from left using actual width + y: (1 - easeOut) * hudSlideY // Slide in from top using actual height + }; + // QR slides in from bottom-right corner + hudAnimationState.qrSlideOffset = { + x: (1 - easeOut) * qrSlideX, // Slide in from right using actual size + y: (1 - easeOut) * qrSlideY // Slide in from bottom using actual size + }; + } else { + // Animating OUT: fade from 1 to 0, slide to respective corners + hudAnimationState.opacity = 1 - easeOut; + // HUD slides out to top-left corner + hudAnimationState.slideOffset = { + x: easeOut * hudSlideX, // Slide out to left using actual width + y: easeOut * hudSlideY // Slide out to top using actual height + }; + // QR slides out to bottom-right corner + hudAnimationState.qrSlideOffset = { + x: easeOut * qrSlideX, // Slide out to right using actual size + y: easeOut * qrSlideY // Slide out to bottom using actual size + }; + } + + // End animation when progress reaches 1.0 + if (progress >= 1.0) { + hudAnimationState.animating = false; + + // Ensure final values are exact + if (hudAnimationState.visible) { + hudAnimationState.opacity = 1.0; + hudAnimationState.slideOffset = { x: 0, y: 0 }; + hudAnimationState.qrSlideOffset = { x: 0, y: 0 }; + } else { + hudAnimationState.opacity = 0.0; + hudAnimationState.slideOffset = { x: hudSlideX, y: hudSlideY }; + hudAnimationState.qrSlideOffset = { x: qrSlideX, y: qrSlideY }; + } + } + } + + currentHUDButton = + currentHUDButton || + new $api.ui.Button({ + x: 0, + y: 0, + w: currentHUDLabelMeasuredWidth, // FIXED: Don't add currentHUDOffset.x to button width + h: h, // Use just the calculated height without extra y-offset + }); + + // Update button position to match label position (with slide offset) + const finalX = currentHUDOffset.x + hudAnimationState.slideOffset.x - currentHUDLeftPad; + const finalY = currentHUDOffset.y + hudAnimationState.slideOffset.y; + // Match hitbox to full rendered HUD label buffer (including scrub reveal areas). + const hitboxWidth = Math.max( + 1, + Math.round(hudAnimationState.labelWidth || bufferW || currentHUDLabelMeasuredWidth || 1), + ); + const hitboxHeight = Math.max( + 1, + Math.round(hudAnimationState.labelHeight || bufferH || currentHUDTextHeight || currentHUDTextBoxHeight || h || 1), + ); + currentHUDButton.box.x = finalX; + currentHUDButton.box.y = finalY; + currentHUDButton.box.w = hitboxWidth; + currentHUDButton.box.h = Math.max(1, Math.round(hitboxHeight)); + + // Mark HUD button to bypass the global HUD active check (it checks itself) + currentHUDButton.noEdgeDetection = true; + // Prevent HUD button from being activated by dragging from other buttons + currentHUDButton.noRolloverActivation = true; + + // Disable button when HUD is not visible (hidden with Tab key) + currentHUDButton.disabled = !hudAnimationState.visible && !hudAnimationState.animating; + + // $commonApi.hud.currentLabel = { + // text: currentHUDTxt, + // btn: currentHUDButton, + // }; + } + } // End of skipHudRender check - close the if (!skipHudRender) block + + // Return frame data back to the main thread. + let sendData = { width: screen.width, height: screen.height }; + + // Tack on the merry progress bar at the TOP if a pipeline is running + if ($api.system.merry && $api.system.merry.running) { + const merry = $api.system.merry; + + // Calculate progress based on elapsed time (use UTC-synced clock if available) + const now = $commonApi.clock?.time?.()?.getTime?.() || Date.now(); + if (merry.startTime && merry.currentPieceStart) { + const totalElapsed = (now - merry.startTime) / 1000; // in seconds + const pieceElapsed = (now - merry.currentPieceStart) / 1000; + merry.progress = Math.min(1, totalElapsed / merry.totalDuration); + merry.pieceProgress = Math.min(1, pieceElapsed / merry.pipeline[merry.currentIndex].duration); + } + + const mainScreenWidth = screen.width; + + // Create merry progress bar painting with SEGMENTED colored blocks per piece + const merryProgressBarPainting = $api.painting(mainScreenWidth, 1, ($) => { + $.unmask(); // Ensure progress bar renders without piece mask + const animFrame = Number($api.paintCount || 0n); + + // Check for transition flash (use UTC-synced time) + const flashNow = $commonApi.clock?.time?.()?.getTime?.() || Date.now(); + let flashIntensity = 0; + if (merry.transitionFlash && merry.transitionFlash.active) { + const elapsed = flashNow - merry.transitionFlash.startTime; + if (elapsed < merry.transitionFlash.duration) { + // Ease out the flash (starts bright, fades quickly) + const progress = elapsed / merry.transitionFlash.duration; + flashIntensity = 1 - Math.pow(progress, 2); // Quadratic ease out + } else { + // Flash complete + merry.transitionFlash.active = false; + flashIntensity = 0; + } + } + + // Fill with black backdrop (or white during flash) + if (flashIntensity > 0) { + const bgBrightness = Math.floor(255 * flashIntensity); + $.ink(bgBrightness, bgBrightness, bgBrightness, 255).box(0, 0, mainScreenWidth, 1); + } else { + $.ink(0, 0, 0, 255).box(0, 0, mainScreenWidth, 1); + } + + // Define colors for each piece (cycle through palette) + const pieceColors = [ + { r: 100, g: 255, b: 100 }, // Bright green + { r: 100, g: 200, b: 255 }, // Cyan + { r: 255, g: 200, b: 100 }, // Orange + { r: 255, g: 100, b: 200 }, // Pink + { r: 200, g: 100, b: 255 }, // Purple + { r: 255, g: 255, b: 100 }, // Yellow + ]; + + // Calculate pixel width for each piece based on duration + let xOffset = 0; + merry.pipeline.forEach((piece, index) => { + const pieceWidthRatio = piece.duration / merry.totalDuration; + const pieceWidth = Math.floor(mainScreenWidth * pieceWidthRatio); + + // Determine if this piece is completed, current, or upcoming + const isCompleted = index < merry.currentIndex; + const isCurrent = index === merry.currentIndex; + const isUpcoming = index > merry.currentIndex; + + // Get color for this piece + const color = pieceColors[index % pieceColors.length]; + + // Draw this piece's segment + for (let x = xOffset; x < xOffset + pieceWidth && x < mainScreenWidth; x++) { + let baseR, baseG, baseB, alpha; + + if (isCompleted) { + // Completed pieces: Fade out progressively (darker as we move away from them) + const completedAge = merry.currentIndex - index; // How many pieces ago + const fadeFactor = Math.max(0.1, 1 - (completedAge * 0.2)); // Fade by 20% per step + baseR = Math.floor(color.r * fadeFactor * 0.25); // Start at 25% brightness + baseG = Math.floor(color.g * fadeFactor * 0.25); + baseB = Math.floor(color.b * fadeFactor * 0.25); + alpha = 255; + } else if (isCurrent) { + // Current piece: Show progress within this segment + const pieceProgress = merry.pieceProgress || 0; + const localX = x - xOffset; + const progressPoint = Math.floor(pieceWidth * pieceProgress); + + if (localX <= progressPoint) { + // Filled part of current piece - bright with pulsing + const pulse = Math.sin(animFrame * 0.3) * 0.2 + 0.8; + baseR = Math.floor(color.r * pulse); + baseG = Math.floor(color.g * pulse); + baseB = Math.floor(color.b * pulse); + alpha = 255; + + // Leader pixel - extra bright white + if (localX === progressPoint) { + const leaderPulse = Math.sin(animFrame * 0.6) * 0.3 + 0.7; + baseR = Math.floor(255 * leaderPulse); + baseG = Math.floor(255 * leaderPulse); + baseB = Math.floor(255 * leaderPulse); + } + } else { + // Unfilled part of current piece - dim preview + baseR = Math.floor(color.r * 0.3); + baseG = Math.floor(color.g * 0.3); + baseB = Math.floor(color.b * 0.3); + alpha = 255; + } + } else if (isUpcoming) { + // Upcoming pieces: Warm up as turn approaches + const stepsUntil = index - merry.currentIndex; // How many pieces away + const nextIsThis = stepsUntil === 1; + + if (nextIsThis) { + // Next piece warms up based on current piece progress + const warmup = merry.pieceProgress || 0; // 0 to 1 + const baseBrightness = 0.15; // Starting dim + const targetBrightness = 0.5; // Warm up to this + const brightness = baseBrightness + (targetBrightness - baseBrightness) * warmup; + + // Add warm orange/yellow glow as it heats up + const warmGlow = warmup * 0.3; + baseR = Math.floor(color.r * brightness + 255 * warmGlow); + baseG = Math.floor(color.g * brightness + 200 * warmGlow); + baseB = Math.floor(color.b * brightness + 100 * warmGlow * 0.5); + baseR = Math.min(255, baseR); + baseG = Math.min(255, baseG); + baseB = Math.min(255, baseB); + alpha = 255; + } else { + // Further away pieces: Very dim, getting dimmer with distance + const distanceFade = Math.max(0.05, 1 - (stepsUntil - 1) * 0.15); + baseR = Math.floor(color.r * 0.15 * distanceFade); + baseG = Math.floor(color.g * 0.15 * distanceFade); + baseB = Math.floor(color.b * 0.15 * distanceFade); + alpha = 255; + } + } + + // Fade zone overlay: blend toward next piece color at end of each segment + if (merry.fadeDuration > 0 && merry.pipeline.length > 1) { + const fadeZonePixels = Math.max(2, Math.floor(pieceWidth * merry.fadeDuration / piece.duration)); + const pixelOffset = x - xOffset; + if (pixelOffset >= pieceWidth - fadeZonePixels) { + const t = (pixelOffset - (pieceWidth - fadeZonePixels)) / Math.max(1, fadeZonePixels - 1); + const nextIdx = (index + 1) % merry.pipeline.length; + const nc = pieceColors[nextIdx % pieceColors.length]; + const maxCh = Math.max(1, color.r, color.g, color.b); + const bright = Math.max(baseR, baseG, baseB) / maxCh; + const nR = Math.floor(nc.r * bright); + const nG = Math.floor(nc.g * bright); + const nB = Math.floor(nc.b * bright); + baseR = Math.min(255, Math.floor(baseR * (1 - t) + nR * t)); + baseG = Math.min(255, Math.floor(baseG * (1 - t) + nG * t)); + baseB = Math.min(255, Math.floor(baseB * (1 - t) + nB * t)); + } + } + + // Apply transition flash boost to current piece + if (isCurrent && flashIntensity > 0) { + const boost = 1 + flashIntensity * 0.8; // Up to 80% brighter + baseR = Math.min(255, Math.floor(baseR * boost)); + baseG = Math.min(255, Math.floor(baseG * boost)); + baseB = Math.min(255, Math.floor(baseB * boost)); + } + + $.ink(baseR, baseG, baseB, alpha).box(x, 0, 1, 1); + } + + xOffset += pieceWidth; + }); + }); + + if (merryProgressBarPainting && merryProgressBarPainting.pixels && merryProgressBarPainting.pixels.length > 0) { + sendData.merryProgressBar = { + x: 0, + y: 0, // Position at TOP of screen (1px tall) + img: { + width: merryProgressBarPainting.width, + height: merryProgressBarPainting.height, + pixels: merryProgressBarPainting.pixels + } + }; + + // 🎄⏰ Add UTC time overlay for sync debugging + const utcDate = $commonApi.clock?.time?.() || new Date(); + const utcTimeStr = utcDate.toISOString().split('T')[1].split('.')[0]; // HH:MM:SS + const utcMs = utcDate.getMilliseconds().toString().padStart(3, '0'); + const cyclePos = ((now % (merry.totalDuration * 1000)) / 1000).toFixed(2); + sendData.merryUTC = { + time: `${utcTimeStr}.${utcMs}`, + cycle: cyclePos, + piece: merry.pipeline[merry.currentIndex]?.piece || '?', + index: merry.currentIndex + 1, + total: merry.pipeline.length + }; + } + } + + // 🎬 Demoplay text card overlay + if ($commonApi.system.demoplay?.card) { + const card = $commonApi.system.demoplay.card; + const elapsed = Date.now() - card.startTime; + const totalDur = card.duration; + const fadeIn = card.fadeIn || 500; + const fadeOut = card.fadeOut || 500; + + // Calculate opacity with fade in/out + let opacity = 1; + if (elapsed < fadeIn) { + opacity = elapsed / fadeIn; + } else if (elapsed > totalDur - fadeOut) { + opacity = Math.max(0, (totalDur - elapsed) / fadeOut); + } + card.opacity = opacity; + + const sw = screen.width; + const sh = screen.height; + const cardPainting = $api.painting(sw, sh, ($) => { + $.unmask(); + // Semi-transparent background + $.ink(0, 0, 0, Math.floor(opacity * 180)); + $.box(0, 0, sw, sh); + // White text, centered + $.ink(255, 255, 255, Math.floor(opacity * 255)); + const lines = card.text.split("\n"); + const lineHeight = 10; + const startY = Math.floor((sh - lines.length * lineHeight) / 2); + lines.forEach((line, i) => { + $.write(line, { center: "x", y: startY + i * lineHeight }); + }); + }); + + if (cardPainting?.pixels?.length > 0) { + sendData.demoplayCard = { + x: 0, + y: 0, + img: { + width: cardPainting.width, + height: cardPainting.height, + pixels: cardPainting.pixels, + }, + }; + } + } else if (sendData.demoplayCard) { + delete sendData.demoplayCard; + } + + // Tack on the tape progress bar pixel buffer if necessary. + if (!$api.rec.cleanMode && ($api.rec.tapeProgress || ($api.rec.recording && $api.rec.tapeTimerDuration))) { + const progress = $api.rec.tapeProgress || 0; + + // Determine if we should show progress bar at all + const isFrameBased = $api.rec.tapeFrameMode && $api.rec.tapeFrameTarget > 0; + const frameCount = $api.rec.tapeFrameTarget || 1; + + // Hide progress bar for ALL frame-based recordings + if (isFrameBased) { + // Skip creating progress bar entirely for frame recordings + } else { + // IMPORTANT: Access the main screen buffer OUTSIDE the painting context + // because inside painting(), $api.screen refers to the painting's own buffer, not the main screen + const mainScreenPixels = screen.pixels; // This is the actual frame content + const mainScreenWidth = screen.width; + const mainScreenHeight = screen.height; + + const isShortRecording = isFrameBased ? frameCount <= 120 : ($api.rec.tapeTimerDuration || 0) <= 1; + + let currentProgressWidth; + if (isFrameBased) { + // For frame-based recording, create discrete segments + const framesPassed = Math.floor(progress * frameCount); + const segmentWidth = Math.max(1, Math.floor(mainScreenWidth / frameCount)); + currentProgressWidth = framesPassed * segmentWidth; + } else { + // For time-based recording, use smooth progress + currentProgressWidth = Math.floor(mainScreenWidth * progress); + } + + // Create tape progress bar painting with VHS-style red glow + const tapeProgressBarPainting = $api.painting(mainScreenWidth, 2, ($) => { + $.unmask(); // Ensure tape progress bar renders without piece mask + // Animation frame for VHS effects - increased speed for more vibes + const animFrame = Number($api.paintCount || 0n); + + // Helper function to sample pixel color from above the progress bar + const sampleColorFromAbove = (x) => { + // Skip sampling during early frames for performance (first ~5 frames) + const frameCount = Number($api.paintCount || 0n); + if (frameCount < 5 || !mainScreenPixels || mainScreenPixels.length === 0) { + return { r: 0, g: 0, b: 0 }; // Return black during startup + } + + // Sample from a few pixels above the progress bar position + const sampleY = Math.max(0, screen.height - 10); // Sample 10px above progress bar + const pixelIndex = (sampleY * screen.width + x) * 4; + + if (pixelIndex >= 0 && pixelIndex < mainScreenPixels.length - 3) { + return { + r: mainScreenPixels[pixelIndex], + g: mainScreenPixels[pixelIndex + 1], + b: mainScreenPixels[pixelIndex + 2] + }; + } + // Fallback to black if can't sample + return { r: 0, g: 0, b: 0 }; + }; + + // Special color override for first and last frames + const isFirstFrame = progress <= 0.01; // First 1% of progress + const isLastFrame = progress >= 0.99; // Last 1% of progress + + // Calculate smooth alpha fade - progress bar fades from 25% to 75% for longer clean content viewing + // Skip fade animation for short recordings + let progressBarAlpha = 1.0; // Default to fully visible + + if (!isShortRecording) { + if (progress >= 0.20 && progress <= 0.30) { + // Fade out from 20% to 30% (10% fade-out period) + progressBarAlpha = 1.0 - ((progress - 0.20) / 0.10); + } else if (progress > 0.30 && progress < 0.70) { + // Fully hidden from 30% to 70% (40% hidden period) + progressBarAlpha = 0.0; + } else if (progress >= 0.70 && progress <= 0.80) { + // Fade in from 70% to 80% (10% fade-in period) + progressBarAlpha = (progress - 0.70) / 0.10; + } + } + + // Fill entire bar with black backdrop using fade alpha + $.ink(0, 0, 0, Math.floor(progressBarAlpha * 255)).box(0, 0, mainScreenWidth, 2); + + if (isFrameBased) { + // Draw segmented progress for frame-based recording + const segmentWidth = Math.max(1, Math.floor(mainScreenWidth / frameCount)); + const framesPassed = Math.floor(progress * frameCount); + const currentProgressWidth = framesPassed * segmentWidth; + + // Draw VHS-style progress bar pixel by pixel (same as time-based but with discrete frame spacing and no leader pixel) + for (let x = 0; x < mainScreenWidth; x++) { + let baseR, baseG, baseB; + + if (x < currentProgressWidth) { + // FILLED AREA - VHS red with analog glow and scan lines + + if (isFirstFrame) { + // FIRST FRAME - Fully green across entire bar + baseR = 0; + baseG = 255; + baseB = 0; + } else if (isLastFrame) { + // LAST FRAME - Fully red across entire bar + baseR = 255; + baseG = 0; + baseB = 0; + } else { + // NORMAL FRAMES - Enhanced color sampling from buffer above + const sampledColor = sampleColorFromAbove(x); + + // Sample additional colors for more variety + const sampledColor2 = sampleColorFromAbove(Math.max(0, x - 3)); + const sampledColor3 = sampleColorFromAbove(Math.min(mainScreenWidth - 1, x + 3)); + + // Mix multiple sampled colors for richer palette + const mixedR = Math.floor((sampledColor.r + sampledColor2.r + sampledColor3.r) / 3); + const mixedG = Math.floor((sampledColor.g + sampledColor2.g + sampledColor3.g) / 3); + const mixedB = Math.floor((sampledColor.b + sampledColor2.b + sampledColor3.b) / 3); + const mixedColor = { r: mixedR, g: mixedG, b: mixedB }; + + // Base VHS red intensity - reduced to let more color through + let redIntensity = 200; // Reduced from 255 for more color mixing + + // Use shared VHS effects calculation + const effects = calculateVHSEffects(x, animFrame); + + // Combine all VHS effects with brighter base + redIntensity = Math.floor(redIntensity * effects.scanLine * effects.analogGlow * effects.tracking * effects.secondaryGlow); + + // Create VHS red color + const vhsR = Math.max(180, Math.min(255, redIntensity)); // Reduced min from 200 + const vhsG = Math.floor(vhsR * 0.05); // Very minimal green + const vhsB = Math.floor(vhsR * 0.02); // Very minimal blue + + // Use shared blending function (55% sampled, 45% VHS red for more color influence) + const blended = blendColorWithVHS(mixedColor, vhsR, vhsG, vhsB, 0.55); + baseR = blended.r; + baseG = blended.g; + baseB = blended.b; + } + + // Use proper alpha blending for filled area (no special leader pixel treatment for frame-based) + $.ink(baseR, baseG, baseB, Math.floor(progressBarAlpha * 255)).box(x, 0, 1, 2); + } else { + // UNFILLED AREA - Keep black background (already filled above) + // No need to redraw black pixels, they're already set + } + } + } else { + // Draw VHS-style progress bar pixel by pixel (original time-based logic) + for (let x = 0; x < mainScreenWidth; x++) { + let baseR, baseG, baseB; + + // Leading edge pixel - bright glowing leader + const isLeaderPixel = x === currentProgressWidth - 1 && currentProgressWidth > 0; + + if (x < currentProgressWidth) { + // FILLED AREA - VHS red with analog glow and scan lines + + if (isFirstFrame) { + // FIRST FRAME - Fully green across entire bar + baseR = 0; + baseG = 255; + baseB = 0; + } else if (isLastFrame) { + // LAST FRAME - Fully red across entire bar + baseR = 255; + baseG = 0; + baseB = 0; + } else { + // NORMAL FRAMES - Enhanced color sampling from buffer above + const sampledColor = sampleColorFromAbove(x); + + // Sample additional colors for more variety + const sampledColor2 = sampleColorFromAbove(Math.max(0, x - 3)); + const sampledColor3 = sampleColorFromAbove(Math.min(mainScreenWidth - 1, x + 3)); + + // Mix multiple sampled colors for richer palette + const mixedR = Math.floor((sampledColor.r + sampledColor2.r + sampledColor3.r) / 3); + const mixedG = Math.floor((sampledColor.g + sampledColor2.g + sampledColor3.g) / 3); + const mixedB = Math.floor((sampledColor.b + sampledColor2.b + sampledColor3.b) / 3); + const mixedColor = { r: mixedR, g: mixedG, b: mixedB }; + + // Base VHS red intensity - reduced to let more color through + let redIntensity = 200; // Reduced from 255 for more color mixing + + // Use shared VHS effects calculation + const effects = calculateVHSEffects(x, animFrame); + + // Combine all VHS effects with brighter base + redIntensity = Math.floor(redIntensity * effects.scanLine * effects.analogGlow * effects.tracking * effects.secondaryGlow); + + // Create VHS red color + const vhsR = Math.max(180, Math.min(255, redIntensity)); // Reduced min from 200 + const vhsG = Math.floor(vhsR * 0.05); // Very minimal green + const vhsB = Math.floor(vhsR * 0.02); // Very minimal blue + + // Use shared blending function (55% sampled, 45% VHS red for more color influence) + const blended = blendColorWithVHS(mixedColor, vhsR, vhsG, vhsB, 0.55); + baseR = blended.r; + baseG = blended.g; + baseB = blended.b; + } + + // Special leader pixel treatment + if (isLeaderPixel) { + if (isFirstFrame) { + // First frame - bright green leader + baseR = 0; + baseG = 255; + baseB = 0; + } else if (isLastFrame) { + // Last frame - bright red leader + baseR = 255; + baseG = 0; + baseB = 0; + } else { + // Check if we're in the fade period (20%-80%) to enable special blinking + const isInFadePeriod = progress >= 0.20 && progress <= 0.80; + + if (isInFadePeriod && !isShortRecording) { + // During fade period - cycle through yellow, lime, and other colors for visibility + const colorCycle = Math.floor(animFrame * 0.3) % 4; // Slower color cycling + + switch (colorCycle) { + case 0: + baseR = 255; baseG = 255; baseB = 0; // Yellow + break; + case 1: + baseR = 0; baseG = 255; baseB = 0; // Lime + break; + case 2: + baseR = 255; baseG = 128; baseB = 0; // Orange + break; + case 3: + baseR = 0; baseG = 255; baseB = 255; // Cyan + break; + } + } else { + // Normal behavior outside fade period - super bright white-hot leader with pulsing + const leaderPulse = Math.sin(animFrame * 0.6) * 0.2 + 0.8; + + baseR = 255; + baseG = Math.floor(255 * leaderPulse); // Bright white-hot leader + baseB = Math.floor(255 * leaderPulse); + } + } + } + + // Apply alpha fade to final colors, with beacon-like leader pixel + let finalAlpha = progressBarAlpha; + if (isLeaderPixel) { + if (progress >= 0.20 && progress <= 0.80 && !isShortRecording) { + // During fade period - beacon-like signal marker (strong but not full opacity) + finalAlpha = 0.8; // Bright beacon for progress indication + } else { + // Outside fade period - still visible beacon + finalAlpha = Math.min(progressBarAlpha, 0.9); // Strong beacon at 90% opacity + } + } + + // Use proper alpha blending for filled area + $.ink(baseR, baseG, baseB, Math.floor(finalAlpha * 255)).box(x, 0, 1, 2); + } else { + // UNFILLED AREA - Keep black background (already filled above) + // No need to redraw black pixels, they're already set + } + } + } + }); + + // Ensure the painting was created successfully before adding to sendData + if (tapeProgressBarPainting && tapeProgressBarPainting.pixels && tapeProgressBarPainting.pixels.length > 0) { + // Structure the data to match what bios.mjs expects (same as label format) + sendData.tapeProgressBar = { + x: 0, + y: screen.height - 2, // Position at bottom of screen (2px tall) + img: { + width: tapeProgressBarPainting.width, + height: tapeProgressBarPainting.height, + pixels: tapeProgressBarPainting.pixels + } + }; + } else { + console.warn("🎬 Tape progress bar painting FAILED to create:", { + painting: !!tapeProgressBarPainting, + pixels: !!tapeProgressBarPainting?.pixels, + pixelsLength: tapeProgressBarPainting?.pixels?.length + }); + } + } // Close the else block for frame count >= 60 + } + + maybeLeave(); + + // TODO: Write this up to the data in `painting`. + sendData.TwoD = { code: twoDCommands }; + if (overlay2D) sendData.overlay = overlay2D; // 🪟 vector UI display-list → uiCtx + + // Attach a label buffer if necessary. + // Hide label when QR is in fullscreen mode or in device mode (device.html has its own overlay) + if (label && !hudAnimationState.qrFullscreen && !DEVICE_MODE) { + const finalX = currentHUDOffset.x + hudAnimationState.slideOffset.x - currentHUDLeftPad; + // Move label up by 4px when QR is present for tighter fit + const qrYOffset = currentHUDQR ? -4 : 0; + const finalY = currentHUDOffset.y + hudAnimationState.slideOffset.y + qrYOffset; + + sendData.label = { + x: finalX, + y: finalY, + opacity: hudAnimationState.opacity, + img: (({ width, height, pixels }) => ({ width, height, pixels }))( + label, + ), + }; + + // DEBUG: Add hitbox visualization overlay + if (globalThis.debugHudHitbox && currentHUDButton) { + const hitboxWidth = currentHUDButton.box.w; + const hitboxHeight = currentHUDButton.box.h; + const blinkFrame = Number($api.paintCount || 0n); + const blinkOn = (blinkFrame % 30) < 15; + const outerAlpha = blinkOn ? 200 : 80; + const innerAlpha = blinkOn ? 120 : 40; + + const hitboxOverlay = $api.painting(hitboxWidth, hitboxHeight, ($) => { + $.unpan(); + $.unmask(); // Ensure hitbox overlay renders without piece mask + // Draw a blinking green border to show the hitbox + $.ink(0, 255, 0, outerAlpha).box(0, 0, hitboxWidth, hitboxHeight, "outline"); + if (hitboxWidth > 2 && hitboxHeight > 2) { + $.ink(0, 255, 0, innerAlpha).box(1, 1, hitboxWidth - 2, hitboxHeight - 2, "outline"); + } + }); + + sendData.hitboxDebug = { + x: currentHUDButton.box.x, + y: currentHUDButton.box.y, + opacity: hudAnimationState.opacity, + img: (({ width, height, pixels }) => ({ width, height, pixels }))( + hitboxOverlay, + ), + }; + } + } + + // Generate QR code overlay for KidLisp pieces + let qrOverlay; + + // Skip QR/HUD overlay in device mode - device.kidlisp.com has its own DOM overlay + // DEVICE_MODE is set from bios init message (can't use location.search in worker) + const isDeviceModeQR = DEVICE_MODE; + + // Clear QR cache if caching is disabled to prevent memory buildup + if (isQROverlayCacheDisabled() && qrOverlayCache.size > 0) { + qrOverlayCache.clear(); + } + + // Detect if this is a KidLisp piece + const sourceCode = currentText || currentHUDTxt; // Use plain currentText first, then fall back to HUD text + + // Use centralized KidLisp detection for QR code generation + // But exclude proper .lisp files which should not show source HUD + const isInlineKidlispPiece = (currentPath && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || + currentPath === "(...)" || + // Detect cached KidLisp pieces by $code format + (sourceCode && sourceCode.startsWith("$")) || + (currentPath && currentPath.includes("/disks/$")) || + // Use the centralized KidLisp detection that includes comma syntax + (sourceCode && lisp.isKidlispSource(sourceCode)); + + // Detect if this is a clock piece with a cached code + const isClockPieceWithCode = (currentPath === "clock" || currentPath?.endsWith("/clock") || currentPath?.includes("/disks/clock")) && cachedClockCode; + + // Debug: log clock detection (disabled - too verbose) + // if (cachedClockCode) { + // console.log("🎵 [QR Debug] Clock QR check:", { currentPath, cachedClockCode, isClockPieceWithCode, hideLabel, hudVisible: hudAnimationState.visible }); + // } + + // For clock pieces with cached code, always show the QR (don't require HUD visible) + // For KidLisp pieces, require HUD to be visible/animating/fullscreen + const shouldShowQRForKidLisp = (isInlineKidlispPiece && sourceCode) && (hudAnimationState.visible || hudAnimationState.animating || hudAnimationState.qrFullscreen); + const shouldShowQRForClock = isClockPieceWithCode; // Always show for clock pieces + + // Skip ALL pixelated HUD overlays in device mode (device.html has DOM-based overlays) + if (!isDeviceModeQR && (shouldShowQRForKidLisp || shouldShowQRForClock) && !hideLabel) { + // console.log("🎵 [QR Debug] Entering QR generation for clock"); + try { + // For clock pieces with cached code, use the cached clock code directly + // For KidLisp pieces, determine cachedCode from sourceCode + let cachedCode; + let sigil = "$"; // Default sigil for KidLisp + + if (isClockPieceWithCode) { + // Clock piece: use the cached clock code with * sigil + cachedCode = cachedClockCode; + sigil = "*"; + } else if (sourceCode.startsWith("$")) { + // For $code format, the sourceCode IS the cached code (without $) + cachedCode = sourceCode.substring(1).split(":")[0]; // Remove the $ prefix and any :fps colon params + // console.log("🔑 [QR Debug] Using $code format, cachedCode:", cachedCode); + } else { + // For regular KidLisp source, check if it has been cached + cachedCode = getCachedCode(sourceCode); + // console.log("🔍 [QR Debug] Checked cache, cachedCode:", cachedCode); + + // If not cached yet but QR fullscreen is active (shift was pressed), + // trigger caching and request a repaint when complete + if (!cachedCode && hudAnimationState.qrFullscreen) { + // console.log("⏳ [QR Debug] Code not cached, triggering caching..."); + const kidlispInstance = getGlobalKidLisp(); + if (kidlispInstance) { + // console.log("✅ [QR Debug] Got KidLisp instance, calling cacheKidlispSource"); + // Trigger caching asynchronously + kidlispInstance.cacheKidlispSource(sourceCode, $commonApi).then(() => { + // Check if code was successfully cached + const newCachedCode = getCachedCode(sourceCode); + if (newCachedCode) { + //console.log(`🔄 Code cached after shift press: $${newCachedCode}`); + // Trigger repaint to show the QR code + if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { + window.$activePaintApi.needsPaint(); + } + } + }).catch(err => { + console.warn("Failed to cache KidLisp code:", err); + }); + } + } + } + + if (cachedCode) { + // Send the cached code to main thread for tape naming (only for KidLisp) + if (sigil === "$") { + send({ type: "kidlisp:cached-code", content: cachedCode }); + } + + // Use cache key based on sigil + cached code to avoid regenerating the same QR + const cacheKey = `qr_${sigil}${cachedCode}`; + + // Get the font and ensure it's properly loaded before proceeding + const font = typefaceCache.get("MatrixChunky8"); + + // Check if ALL glyphs in the label text are loaded + // The label will be sigil + cachedCode (e.g., "$wipe", "*bako", etc.) + // We need to verify every character's glyph is loaded to avoid pop-in + const labelText = `${sigil}${cachedCode}`; + let allGlyphsLoaded = false; + + if (font && font.glyphs) { + allGlyphsLoaded = true; + const missingGlyphs = []; + + for (const char of labelText) { + const glyph = font.glyphs[char]; + // console.log('[MatrixChunky8] Glyph check:', { char, charCode: char.charCodeAt(0), glyph: glyph ? 'exists' : 'missing', glyphType: typeof glyph }); + // A real glyph has properties like dwidth, advance, or resolution + // The Proxy returns null for missing glyphs + const isRealGlyph = glyph && glyph !== null && !glyph.isPlaceholder && + (glyph.dwidth || glyph.advance !== undefined || glyph.resolution); + + if (!isRealGlyph) { + allGlyphsLoaded = false; + missingGlyphs.push(char); + // console.log('[MatrixChunky8] Missing glyph:', char, 'charCode:', char.charCodeAt(0)); + } + } + + // if (!allGlyphsLoaded) { + // console.log('[QR] MatrixChunky8 glyphs pending:', { + // labelText, + // missingGlyphs: missingGlyphs.join('') || 'unknown' + // }); + // } + } + + const shouldShowQR = allGlyphsLoaded; + + // Track when glyphs first become available to delay caching by one frame + // This ensures the painting callback has completed rendering before we cache + if (font && shouldShowQR && font.__qrGlyphsReadyFrame === undefined) { + // First frame where glyphs are loaded - mark the frame number + font.__qrGlyphsReadyFrame = (typeof window !== "undefined" && window.frameCount) || 0; + } + + // Safe to cache if glyphs were ready at least 1 frame ago (or we don't have frameCount) + const currentFrame = (typeof window !== "undefined" && window.frameCount) || 0; + const safeToCache = shouldShowQR && ( + font?.__qrGlyphsReadyFrame === undefined || + currentFrame > font.__qrGlyphsReadyFrame + ); + + // If font exists but glyphs not loaded yet, trigger loading and request repaint + if (font && !shouldShowQR) { + font.__qrGlyphsReadyFrame = undefined; // Reset the ready frame tracking + if (!font.__loadPromise) { + ensureTypefaceLoaded(font); + } + if (font.__loadPromise) { + font.__loadPromise.then(() => { + font.__loaded = true; + // Clear QR cache for this code so it regenerates with proper font + qrOverlayCache.delete(cacheKey); + // Trigger repaint to show QR once font is ready + if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { + window.$activePaintApi.needsPaint(); + } + }).catch((err) => { + console.warn("Failed to load MatrixChunky8 for QR:", err); + font.__loaded = true; // Mark as loaded to prevent infinite waiting + }); + } + } + + + // Check if this QR overlay is already cached (unless caching is disabled) + const isQRCacheDisabled = isQROverlayCacheDisabled(); + const hasQRCache = qrOverlayCache.has(cacheKey); + + // Declare variables for QR positioning and sizing (used in both cached and fresh QR paths) + let overlayWidth, overlayHeight, startX, startY; + + + // Render QR from cache if available, or generate fresh + // Note: We render even if glyphs aren't loaded yet - they'll pop in on repaint + if (!isQRCacheDisabled && hasQRCache) { + + const cachedQrData = qrOverlayCache.get(cacheKey); + + // Store QR dimensions in animation state for proper bounding box animations + hudAnimationState.qrSize = Math.max(cachedQrData.width, cachedQrData.height); + + if (hudAnimationState.qrFullscreen) { + // Fullscreen mode: integer scaling only for pixel-perfect QR + const originalWidth = cachedQrData.width; + const originalHeight = cachedQrData.height; + + // Calculate maximum integer scale that fits screen with padding + const padding = Math.min(screen.width, screen.height) * 0.1; // 10% padding + const maxWidth = screen.width - padding * 2; + const maxHeight = screen.height - padding * 2; + + const maxScaleX = Math.floor(maxWidth / originalWidth); + const maxScaleY = Math.floor(maxHeight / originalHeight); + const scale = Math.min(maxScaleX, maxScaleY, 8); // Cap at 8x scale + + // Use integer scaling for pixel-perfect results + overlayWidth = originalWidth * scale; + overlayHeight = originalHeight * scale; + + // Calculate text dimensions for canvas sizing + const codeText = `${sigil}${cachedCode}`; + const fontSize = 16; // Fixed 2x scale (8px base font * 2 = 16px) + const textPadding = 8; + + // Calculate actual text width using font advances for proper sizing + const font = typefaceCache.get("MatrixChunky8"); + const advances = font?.data?.advances || {}; + let actualTextWidth = 0; + for (const char of codeText) { + const charWidth = advances[char] || 4; // Default 4px per char if no advance data + actualTextWidth += charWidth; + } + // Scale up by 2x for our size: 2 scaling + actualTextWidth *= 2; + + const textWidth = actualTextWidth + textPadding * 2; // Add padding + const textHeight = fontSize + textPadding * 2; + + // Use full screen canvas to position QR code (centered) and text + const canvasHeight = screen.height; + const canvasWidth = screen.width; + + // Center QR on screen (ensure integer coordinates) + const qrX = Math.floor((screen.width - overlayWidth) / 2); + const qrY = Math.floor((screen.height - overlayHeight) / 2); + + // Position starts at top-left of screen since we're using full screen canvas + startX = 0; + startY = 0; + + // Create scaled QR overlay with styled code text + qrOverlay = $api.painting(canvasWidth, canvasHeight, async ($) => { + $.unmask(); // Ensure QR overlay renders without piece mask + // Draw scaled QR with integer scaling (centered in canvas) + + for (let y = 0; y < originalHeight; y++) { + for (let x = 0; x < originalWidth; x++) { + const srcIndex = (y * originalWidth + x) * 4; + + if (srcIndex < cachedQrData.basePixels.length) { + const r = cachedQrData.basePixels[srcIndex]; + const g = cachedQrData.basePixels[srcIndex + 1]; + const b = cachedQrData.basePixels[srcIndex + 2]; + + $.ink(r, g, b); + // Draw scale x scale pixel block + $.box(qrX + x * scale, qrY + y * scale, scale, scale); + } + } + } + + // Add styled code text positioned at top-left corner of screen + const textX = 10; // Small margin from left edge of screen + const textY = 10; // Small margin from top edge of screen + + // Draw black shadow + $.ink("black"); + $.write(codeText, { x: textX + 1, y: textY + 1, size: 2 }); + + // Draw white text on black background - try MatrixChunky8 first, fallback to default + $.ink("white"); + const matrixFont = typefaceCache.get("MatrixChunky8"); + if (matrixFont && matrixFont.glyphs) { + try { + $.write(codeText, { x: textX, y: textY, size: 2 }, undefined, undefined, false, "MatrixChunky8"); + } catch (error) { + // Fallback to default font if MatrixChunky8 fails + $.write(codeText, { x: textX, y: textY, size: 2 }); + } + } else { + // Use default font if MatrixChunky8 not available + $.write(codeText, { x: textX, y: textY, size: 2 }); + } + + // Add "TAP TO CLOSE" instruction at bottom center of screen + const closeText = "TAP TO CLOSE"; + const closeTextY = screen.height - 20; // 20px from bottom + const closeTextX = Math.floor(screen.width / 2) - 30; // Rough center + + // Draw black shadow for close text + $.ink("black"); + $.write(closeText, { x: closeTextX + 1, y: closeTextY + 1, size: 1 }); + + // Draw white close text + $.ink("white"); + $.write(closeText, { x: closeTextX, y: closeTextY, size: 1 }); + }); + } else { + // Normal mode: use original positioning + const margin = 4; + overlayWidth = cachedQrData.width; + overlayHeight = cachedQrData.height; + startX = screen.width - overlayWidth - margin; + startY = screen.height - overlayHeight - margin; + + // Create fresh overlay for transfer from cached data + qrOverlay = { + width: cachedQrData.width, + height: cachedQrData.height, + pixels: new Uint8ClampedArray(cachedQrData.basePixels) // Fresh copy for transfer + }; + } + + // Add QR overlay to sendData with animation effects + sendData.qrOverlay = { + x: startX + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.x), + y: startY + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.y), + opacity: hudAnimationState.opacity, + img: qrOverlay + }; + + // Add clickable hitbox for QR code + if (hudAnimationState.qrFullscreen) { + // Fullscreen QR: make entire QR tappable to go back + send({ + type: "button:hitbox:add", + content: { + label: "qr-fullscreen", + box: { + x: startX, + y: startY, + w: overlayWidth, + h: overlayHeight + }, + message: "qr-fullscreen-tap" + } + }); + } else { + // Corner QR: make it tappable to go fullscreen + if (!SOLO_MODE) { + send({ + type: "button:hitbox:add", + content: { + label: "qr-corner", + box: { + x: startX + hudAnimationState.qrSlideOffset.x, + y: startY + hudAnimationState.qrSlideOffset.y, + w: overlayWidth, + h: overlayHeight + }, + message: "qr-corner-tap" + } + }); + } + } + + } else { + + // Always use prompt.ac for QR codes (even in dev/local) + let url = "https://prompt.ac"; + + // Use the cached nanoid code with appropriate sigil prefix for a short URL + // $ for KidLisp, * for clock melodies + url += `/${sigil}${cachedCode}`; + + // Generate QR code with medium error correction for better scannability + const cells = qr(url, { errorCorrectLevel: ErrorCorrectLevel.M }).modules; + + // Calculate size and position for bottom-right corner with 4px margin + const margin = 4; + const cellSize = 1; // 1 pixel per cell for smallest 1:1 size + const qrSize = cells.length * cellSize; + + // Store QR dimensions in animation state for proper bounding box animations + hudAnimationState.qrSize = qrSize; + + // Position in bottom-right corner + let startX = screen.width - qrSize - margin - 1; // Account for shadow width + const textHeight = 12; // Space for MatrixChunky8 8px font with shadow (8px + 4px padding) + const totalHeight = qrSize + textHeight; + let startY = screen.height - totalHeight - margin; // Move 1px closer to bottom for balanced margins + + // Create QR overlay using painting API with extra space for shadow + const textAreaHeight = 9; + const qrOffsetY = textAreaHeight; // QR starts right after text area (no gap) + const canvasHeight = qrOffsetY + qrSize + 2; // Ensure room for bottom shadow + const generatedQR = $api.painting(qrSize + 1, canvasHeight, async ($) => { + $.unmask(); // Ensure QR renders without piece mask + // Draw QR code at offset position to make room for text above + for (let y = 0; y < cells.length; y++) { + for (let x = 0; x < cells.length; x++) { + const isBlack = cells[y][x]; + if (isBlack) { + $.ink("black"); + } else { + $.ink("white"); + } + $.box(x * cellSize, (y * cellSize) + qrOffsetY, cellSize); // Add qrOffsetY to move QR down + } + } + + // QR text style configuration + const useBackdrop = true; // Set to true for backdrop style, false for shadow style + + // Prepare text for rendering (using sigil variable: $ for KidLisp, * for clock) + const codeToRender = `${sigil}${cachedCode}`; + + // Calculate actual rendered width for mathematical centering + // Get character advances from the font definition + const advances = font?.data?.advances || typefaceCache.get("MatrixChunky8")?.data?.advances || {}; + + // Calculate text dimensions and positioning + let actualTextWidth = 0; + for (const char of codeToRender) { + const charWidth = advances[char] || 4; + actualTextWidth += charWidth; + } + + // Text area configuration - be more generous with sizing + const textPaddingLeft = 1; // Minimal left padding + const textPaddingRight = 1; // Minimal right padding for safety + const textAreaWidth = actualTextWidth + textPaddingLeft + textPaddingRight; + const textAreaHeight = 10; // Increased height (was 9) + + // Position text area above QR code (which is now at qrOffsetY) + const textAreaX = qrSize - textAreaWidth; // Still flush right + const textAreaY = 0; // At the top of the canvas + + // Text position within the text area (right-aligned with left padding only) + const textX = textAreaX + textPaddingLeft; // Only left padding + const textY = textAreaY + 1; // Vertical centering within smaller text area (was 2) + + // Render text with appropriate style + if (useBackdrop) { + // Draw black background for text area (sized to fit text) + $.ink("black"); // Black background + $.box(textAreaX, textAreaY, textAreaWidth, textAreaHeight); + + // Render white text (no rotation for now) + $.ink("white"); // White text on black background + + if (getPackMode()) { + // In OBJKT mode, try MatrixChunky8 first, fall back to default if not available + let matrixFont = typefaceCache.get("MatrixChunky8"); + if (matrixFont) { + + // Check if this is a proper Typeface instance with all methods + if (!matrixFont.getGlyph || typeof matrixFont.getGlyph !== 'function') { + // Create a new proper Typeface instance + const newMatrixFont = new Typeface("MatrixChunky8"); + await newMatrixFont.load($commonApi.net.preload); + // Copy over existing glyphs data if any + if (matrixFont.glyphs) { + Object.assign(newMatrixFont.glyphs, matrixFont.glyphs); + } + matrixFont = newMatrixFont; + typefaceCache.set("MatrixChunky8", matrixFont); + } + + // Check if glyphs actually work by testing a specific character + const testGlyph = matrixFont.glyphs['$']; + // MatrixChunky8 uses BDF structure: {resolution, offset, baselineOffset, advance, commands, bbx} + const glyphsWorking = testGlyph && typeof testGlyph === 'object' && + (testGlyph.pixels || testGlyph.commands || testGlyph.resolution); + + if (!glyphsWorking) { + // Create simple fallback glyphs for common characters + const fallbackGlyph = { + resolution: [6, 8], + pixels: [ + [0, 1, 1, 1, 1, 0], + [1, 0, 0, 0, 0, 1], + [1, 0, 1, 1, 0, 1], + [1, 0, 1, 1, 0, 1], + [1, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 1], + [0, 1, 1, 1, 1, 0], + [0, 0, 0, 0, 0, 0] + ] + }; + + // DO NOT replace glyphs object - it's a Proxy that loads on-demand + // Just populate common characters if they're missing + if (matrixFont.glyphs) { + const commonChars = '$rozeabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + for (const char of commonChars) { + if (!matrixFont.glyphs[char]) { + matrixFont.glyphs[char] = fallbackGlyph; + matrixFont[char] = fallbackGlyph; // Also set directly on font object + } + } + } + } + + // Check if font has reasonable glyph data before rendering + const testChar = matrixFont.glyphs && (matrixFont.glyphs['$'] || matrixFont.glyphs['A'] || matrixFont.glyphs['a']); + const hasValidGlyphs = testChar && (testChar.pixels || testChar.commands || testChar.resolution); + if (hasValidGlyphs) { + $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); + } else { + // Fallback to default font if MatrixChunky8 isn't ready + $.write(codeToRender, { x: textX, y: textY }); + } + } else { + // Fallback to default font if MatrixChunky8 not available + $.write(codeToRender, { x: textX, y: textY }); + } + } else { + // Use MatrixChunky8 font in normal mode if available + const matrixFont = typefaceCache.get("MatrixChunky8"); + + // Check if MatrixChunky8 glyphs are actually loaded and ready + let useMatrixFont = false; + if (matrixFont && matrixFont.glyphs) { + // Check if ALL glyphs in codeToRender are loaded + useMatrixFont = true; + for (const char of codeToRender) { + const glyph = matrixFont.glyphs[char]; + const isLoaded = glyph && !glyph.isPlaceholder && (glyph.pixels || glyph.commands || glyph.resolution); + if (!isLoaded) { + useMatrixFont = false; + break; + } + } + } + + // Only render text if MatrixChunky8 is ready (will trigger repaint when it loads) + if (useMatrixFont) { + $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); + } + // Otherwise skip rendering text - it will pop in on next repaint + } + } else { + // Shadow style: black shadow first, then white text + // Draw black shadow (1px offset) - no rotation for now + $.ink("black"); + + if (getPackMode()) { + // In OBJKT mode, try MatrixChunky8 first, fall back to default if not available + const matrixFont = typefaceCache.get("MatrixChunky8"); + if (matrixFont && matrixFont.glyphs) { + const testChar = matrixFont.glyphs['$'] || matrixFont.glyphs['A'] || matrixFont.glyphs['a']; + const hasValidGlyphs = testChar && (testChar.pixels || testChar.commands || testChar.resolution); + if (hasValidGlyphs) { + $.write(codeToRender, { x: textX + 1, y: textY + 1 }, undefined, undefined, false, "MatrixChunky8"); + } else { + $.write(codeToRender, { x: textX + 1, y: textY + 1 }); + } + } else { + $.write(codeToRender, { x: textX + 1, y: textY + 1 }); + } + } else { + // Check if MatrixChunky8 glyphs are loaded for shadow + const matrixFont = typefaceCache.get("MatrixChunky8"); + let useMatrixFont = false; + if (matrixFont && matrixFont.glyphs) { + const testGlyph = matrixFont.glyphs['$'] || matrixFont.glyphs['a']; + useMatrixFont = !!(testGlyph && (testGlyph.pixels || testGlyph.commands || testGlyph.resolution)); + } + + // Only render if MatrixChunky8 is ready + if (useMatrixFont) { + $.write(codeToRender, { x: textX + 1, y: textY + 1 }, undefined, undefined, false, "MatrixChunky8"); + } + } + + // Draw white text on top - no rotation for now + $.ink("white"); + if (getPackMode()) { + // In OBJKT mode, try MatrixChunky8 first, fall back to default if not available + const matrixFont = typefaceCache.get("MatrixChunky8"); + if (matrixFont && matrixFont.glyphs) { + const testChar = matrixFont.glyphs['$'] || matrixFont.glyphs['A'] || matrixFont.glyphs['a']; + const hasValidGlyphs = testChar && (testChar.pixels || testChar.commands || testChar.resolution); + if (hasValidGlyphs) { + $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); + } else { + $.write(codeToRender, { x: textX, y: textY }); + } + } else { + $.write(codeToRender, { x: textX, y: textY }); + } + } else { + // Check if MatrixChunky8 glyphs are loaded for white text + const matrixFont = typefaceCache.get("MatrixChunky8"); + let useMatrixFont = false; + if (matrixFont && matrixFont.glyphs) { + const testGlyph = matrixFont.glyphs['$'] || matrixFont.glyphs['a']; + useMatrixFont = !!(testGlyph && (testGlyph.pixels || testGlyph.commands || testGlyph.resolution)); + } + + // Only render if MatrixChunky8 is ready + if (useMatrixFont) { + $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); + } + } + } + + // Draw gray shadow along the right side and bottom of the entire overlay + $.ink("gray", 128); + // Right shadow - offset 1px down from top like a drop shadow + $.box(qrSize, 1, 1, qrOffsetY + qrSize); + // Bottom shadow - offset 1px from left edge like a drop shadow + $.box(1, qrOffsetY + qrSize, qrSize, 1); + + // Add a subtle white highlight to indicate the QR is clickable (corner only) + // This creates a "button-like" appearance + $.ink("white", 64); // Semi-transparent white + // Top highlight line + $.line(0, qrOffsetY, qrSize - 1, qrOffsetY); + // Left highlight line + $.line(0, qrOffsetY, 0, qrOffsetY + qrSize - 1); + }); + + // Don't cache QR overlay if font isn't fully loaded yet (text label needs to re-render) + // Cache the base QR data (not the transferable pixels) + const qrData = { + width: generatedQR.width, + height: generatedQR.height, + basePixels: new Uint8ClampedArray(generatedQR.pixels) // Keep a safe copy for caching + }; + + // Store QR dimensions in animation state for proper bounding box animations + hudAnimationState.qrSize = Math.max(qrData.width, qrData.height); + + if (hudAnimationState.qrFullscreen) { + // Fullscreen mode: integer scaling only for pixel-perfect QR + const originalQrSize = cells.length; + + // Calculate maximum integer scale that fits screen with padding + const padding = Math.min(screen.width, screen.height) * 0.1; // 10% padding + const maxSize = Math.min(screen.width, screen.height) - padding * 2; + + const maxCellSize = Math.floor(maxSize / originalQrSize); + const cellSize = Math.max(2, Math.min(maxCellSize, 12)); // Integer cell size, 2-12px + const finalQrSize = originalQrSize * cellSize; + + overlayWidth = finalQrSize; + overlayHeight = finalQrSize; + + // Calculate text dimensions for canvas sizing + const codeText = `${sigil}${cachedCode}`; + const fontSize = 16; // Fixed 2x scale (8px base font * 2 = 16px) + const textPadding = 8; + + // Calculate actual text width using font advances for proper sizing + const font = typefaceCache.get("MatrixChunky8"); + const advances = font?.data?.advances || {}; + let actualTextWidth = 0; + for (const char of codeText) { + const charWidth = advances[char] || 4; // Default 4px per char if no advance data + actualTextWidth += charWidth; + } + // Scale up by 2x for our size: 2 scaling + actualTextWidth *= 2; + + const textWidth = actualTextWidth + textPadding * 2; // Add padding + const textHeight = fontSize + textPadding * 2; + + // Use full screen canvas to position QR code (centered) and text (top-left) + const canvasHeight = screen.height; + const canvasWidth = screen.width; + + // Position QR code in center of screen + const qrX = Math.floor((screen.width - finalQrSize) / 2); + const qrY = Math.floor((screen.height - finalQrSize) / 2); + + // Position starts at top-left of screen since we're using full screen canvas + startX = 0; + startY = 0; + + // Generate fullscreen canvas with centered QR and top-left text + const fullscreenQR = $api.painting(canvasWidth, canvasHeight, ($) => { + $.unmask(); // Ensure fullscreen QR renders without piece mask + // Draw QR code centered on screen + for (let y = 0; y < cells.length; y++) { + for (let x = 0; x < cells.length; x++) { + const isBlack = cells[y][x]; + if (isBlack) { + $.ink("black"); + } else { + $.ink("white"); + } + $.box(qrX + x * cellSize, qrY + y * cellSize, cellSize, cellSize); + } + } + + // Draw text in absolute top-left corner of screen + const codeText = `${sigil}${cachedCode}`; + const textX = 10; // Small margin from left edge of screen + const textY = 10; // Small margin from top edge of screen + + // Draw black shadow offset by 1px + $.ink("black"); + $.write(codeText, { x: textX + 1, y: textY + 1, size: 2 }); // 2x scale for shadow + + // Draw white text on top + $.ink("white"); + $.write(codeText, { x: textX, y: textY, size: 2 }); // 2x scale for main text + + // Add "TAP TO CLOSE" instruction at bottom center of screen + const closeText = "TAP TO CLOSE"; + const closeTextY = screen.height - 20; // 20px from bottom + const closeTextX = Math.floor(screen.width / 2) - 30; // Rough center (adjust as needed) + + // Draw black shadow for close text + $.ink("black"); + $.write(closeText, { x: closeTextX + 1, y: closeTextY + 1, size: 1 }); + + // Draw white close text + $.ink("white"); + $.write(closeText, { x: closeTextX, y: closeTextY, size: 1 }); + }); + + // Update qrData for fullscreen + qrData.width = fullscreenQR.width; + qrData.height = fullscreenQR.height; + qrData.basePixels = new Uint8ClampedArray(fullscreenQR.pixels); + + qrOverlay = { + width: qrData.width, + height: qrData.height, + pixels: new Uint8ClampedArray(qrData.basePixels) + }; + } else { + // Normal mode: use original positioning and size + overlayWidth = qrData.width; + overlayHeight = qrData.height; + startX = screen.width - overlayWidth - margin; + startY = screen.height - overlayHeight - margin; // Removed +1 to prevent label shadow overlap + + // Create fresh overlay for transfer each time + qrOverlay = { + width: qrData.width, + height: qrData.height, + pixels: new Uint8ClampedArray(qrData.basePixels) // Fresh copy for transfer + }; + } + + // Cache the QR data for this piece once glyphs are confirmed rendered + // We use safeToCache to ensure the painting callback has completed + if (!isQRCacheDisabled && safeToCache && !hudAnimationState.qrFullscreen) { + qrOverlayCache.set(cacheKey, qrData); + console.log('[QR] ✅ Cached QR with MatrixChunky8 glyphs for:', cacheKey); + } + + // Add QR overlay to sendData with exact position and animation effects + sendData.qrOverlay = { + x: startX + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.x), + y: startY + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.y), + opacity: hudAnimationState.opacity, + img: qrOverlay + }; + + // Add clickable hitbox for QR code + if (hudAnimationState.qrFullscreen) { + // Fullscreen QR: make entire QR tappable to go back + send({ + type: "button:hitbox:add", + content: { + label: "qr-fullscreen", + box: { + x: startX, + y: startY, + w: overlayWidth || qrData.width, + h: overlayHeight || qrData.height + }, + message: "qr-fullscreen-tap" + } + }); + } else { + // Corner QR: make it tappable to go fullscreen + if (!SOLO_MODE) { + send({ + type: "button:hitbox:add", + content: { + label: "qr-corner", + box: { + x: startX + hudAnimationState.qrSlideOffset.x, + y: startY + hudAnimationState.qrSlideOffset.y, + w: overlayWidth || qrData.width, + h: overlayHeight || qrData.height + }, + message: "qr-corner-tap" + } + }); + } + } + + } + } else { + // No cached code yet - QR will appear once caching is complete + } + } catch (err) { + console.warn("Failed to generate QR overlay:", err); + } + } + + // 👤 Author attribution overlay for KidLisp pieces (bottom-left corner) + // Only show when: HUD is visible, we have an author, and it's a KidLisp piece + // Skip in device mode (device.kidlisp.com has its own DOM overlay) + // Note: Reuse isDeviceModeQR from earlier in the function + // In device mode, completely skip author overlay creation + if (isDeviceModeQR) { + // Don't create author overlay - device.html has its own DOM-based display + } else if (currentHUDAuthor && hudAnimationState.visible && !hudAnimationState.qrFullscreen) { + try { + const authorText = currentHUDAuthor; // Already formatted as @handle + const hitsText = currentHUDHits ? `${currentHUDHits.toLocaleString()} hits` : null; + + // Use MatrixChunky8 for compact display + const font = typefaceCache.get("MatrixChunky8"); + const charWidth = 4; // MatrixChunky8 char width + const charHeight = 8; // MatrixChunky8 char height + const padding = 2; + const lineGap = 2; + + // Calculate dimensions + const authorWidth = authorText.length * charWidth; + const hitsWidth = hitsText ? hitsText.length * charWidth : 0; + const maxWidth = Math.max(authorWidth, hitsWidth); + const height = hitsText ? (charHeight * 2 + lineGap + padding * 2) : (charHeight + padding * 2); + const width = maxWidth + padding * 2; + + // Create attribution overlay painting + const authorOverlay = $api.painting(width + 1, height + 1, ($) => { + $.unmask(); + + // Semi-transparent black background for readability + $.ink(0, 0, 0, 180).box(0, 0, width, height); + + // Draw author handle in pink (matching give.aesthetic.computer style) + $.ink(255, 107, 157); // Pink color from give page CSS --pink + if (font && font.glyphs) { + $.write(authorText, { x: padding, y: padding }, undefined, undefined, false, "MatrixChunky8"); + } else { + $.write(authorText, { x: padding, y: padding }); + } + + // Draw hits count in gold if available + if (hitsText) { + $.ink(255, 217, 61); // Gold color from give page CSS --gold + if (font && font.glyphs) { + $.write(hitsText, { x: padding, y: padding + charHeight + lineGap }, undefined, undefined, false, "MatrixChunky8"); + } else { + $.write(hitsText, { x: padding, y: padding + charHeight + lineGap }); + } + } + }); + + // Position in bottom-left corner with same margin as QR code + const margin = 4; + const startX = margin; + const startY = screen.height - height - margin; + + // Add author overlay to sendData with animation effects + sendData.authorOverlay = { + x: startX - hudAnimationState.qrSlideOffset.x, // Slide opposite direction from QR + y: startY + hudAnimationState.qrSlideOffset.y, + opacity: hudAnimationState.opacity, + img: (({ width, height, pixels }) => ({ width, height, pixels }))(authorOverlay), + }; + } catch (err) { + console.warn("Failed to generate author overlay:", err); + } + } + + // 📦 Top-right update-ready badge — appears in any piece when + // /api/version detects a new deployment. Tap to reload. + // An embedded view takes the update instead of advertising it. The + // message still goes — it is what arms the reload on the other side — + // but it carries no picture, so nothing is drawn over the piece and + // there is no green arrow waiting for a tap that cannot come. + if (globalUpdateReady && autoUpdateForFrame) { + sendData.updateBadge = { silent: true, auto: true }; + } else if ( + globalUpdateReady && + !PREVIEW_OR_ICON && + !DEVICE_MODE && + !hudAnimationState.qrFullscreen + ) { + const badgeSize = 13; + const badgeWidth = badgeSize * 2 + 3; + const margin = 4; + const startX = screen.width - badgeWidth - margin; + const startY = margin; + + // Pulse for attention. + const tBadge = Number($api.paintCount || 0n); + const pulse = 0.5 + 0.5 * Math.sin(tBadge * 0.15); + const fillBoost = Math.round(40 * pulse); + const outlineBoost = Math.round(60 * pulse); + + const badge = $api.painting(badgeWidth, badgeSize, ($) => { + $.unpan(); + $.unmask(); + // Filled square w/ pulsing green. + $.ink(20 + fillBoost, 80 + fillBoost, 30, 230).box( + 0, 0, badgeSize, badgeSize, + ); + $.ink(60 + outlineBoost, 180 + outlineBoost, 70, 255).box( + 0, 0, badgeSize, badgeSize, "outline", + ); + // Up arrow ↑ in pixels. + const cx = Math.floor(badgeSize / 2); + $.ink(255, 255, 255, 255); + $.box(cx, 3, 1, 1); // tip + $.box(cx - 1, 4, 3, 1); // wing 1 + $.box(cx - 2, 5, 5, 1); // wing 2 + $.box(cx, 6, 1, 4); // shaft + // Auto-reload toggle: check means reload after the short grace + // period; X means updates wait for a manual arrow tap. + const bx = badgeSize + 3; + $.ink(8, 30, 12, 230).box(bx, 0, badgeSize, badgeSize); + $.ink(80, 210, 90, 255).box(bx, 0, badgeSize, badgeSize, "outline"); + $.ink(255, 255, 255, 255); + if (globalAutoReload) { + $.box(bx + 3, 6, 2, 1); + $.box(bx + 4, 7, 2, 1); + $.box(bx + 5, 5, 1, 2); + $.box(bx + 6, 4, 1, 2); + $.box(bx + 7, 3, 2, 1); + } else { + $.box(bx + 3, 3, 1, 1); $.box(bx + 8, 3, 1, 1); + $.box(bx + 4, 4, 1, 1); $.box(bx + 7, 4, 1, 1); + $.box(bx + 5, 5, 2, 2); + $.box(bx + 4, 7, 1, 1); $.box(bx + 7, 7, 1, 1); + $.box(bx + 3, 8, 1, 1); $.box(bx + 8, 8, 1, 1); + } + }); + + sendData.updateBadge = { + x: startX, + y: startY, + opacity: 1, + auto: globalAutoReload, // What the checkbox is actually drawing. + img: (({ width, height, pixels }) => ({ width, height, pixels }))( + badge, + ), + }; + + const boxKey = `${startX},${startY},${badgeWidth},${globalAutoReload}`; + if (!updateBadgeBoxScreen || updateBadgeBoxScreen.key !== boxKey) { + send({ + type: "button:hitbox:add", + content: { + label: "update-corner", + box: { x: startX, y: startY, w: badgeSize, h: badgeSize }, + message: "update-corner-tap", + }, + }); + send({ + type: "button:hitbox:add", + content: { + label: "update-auto-corner", + box: { x: startX + badgeSize + 3, y: startY, w: badgeSize, h: badgeSize }, + message: "update-auto-corner-tap", + }, + }); + updateBadgeBoxScreen = { + x: startX, y: startY, w: badgeSize, h: badgeSize, key: boxKey, + }; + updateBadgeHitboxRegistered = true; + } + } else if (updateBadgeHitboxRegistered) { + send({ type: "button:hitbox:remove", content: "update-corner" }); + send({ type: "button:hitbox:remove", content: "update-auto-corner" }); + updateBadgeHitboxRegistered = false; + updateBadgeBoxScreen = null; + } + + let transferredPixels; + + // Check to see if we have a dirtyBox to render from. + const croppedBox = dirtyBox?.croppedBox?.(screen); + + if (croppedBox?.w > 0 && croppedBox?.h > 0) { + transferredPixels = dirtyBox.crop(screen); + sendData.pixels = transferredPixels; + sendData.dirtyBox = croppedBox; + if ($commonApi.rec.presenting) { + // Log dirtyBox usage during tape playback + } + } else if (painted === true) { + // TODO: Toggling this causes a flicker in `line`... but helps prompt. 2022.01.29.13.21 + // Otherwise render everything if we drew anything! + transferredPixels = screen.pixels; + sendData.pixels = transferredPixels; + } else { + // Painted is false, skip pixel transfer + } + + // 🩺 Sample the frame on its way to the display. This is the last point + // where the worker can still see what the viewer is about to see, which + // is the only place a piece that paints nothing is distinguishable from + // a piece that paints — from in here they look identical. + if (diagnostics.on && !screen.pixels?.buffer?.detached) { + diagnostics.frame(screen.pixels, screen.width, screen.height); + } + + // Optional messages to send. + if (painted === true) sendData.paintChanged = true; + if (loading === true) sendData.loading = true; + + // WebGPU state (tell main thread whether to skip CPU rendering) + if ($commonApi.webgpu.enabled) { + sendData.webgpuEnabled = true; + // Send backend preference if specified + if ($commonApi.webgpu.backend) { + sendData.gpuBackend = $commonApi.webgpu.backend; + } + // Signal end of frame to flush WebGPU command queue + send({ type: "webgpu-command", content: { type: "frame-end" } }); + } + + // These fields are one time `signals`. + if (reframe || glazeAfterReframe) { + sendData.reframe = reframe || glazeAfterReframe !== undefined; + if (glazeAfterReframe) { + send(glazeAfterReframe); + glazeAfterReframe = undefined; + } + } + + if (cursorCode) sendData.cursorCode = cursorCode; + + // Note: transferredPixels will be undefined when sendData === {}. + if (sendData.pixels) { + // 🛡️ Create a copy for transfer to avoid detaching the main rendering buffer + const pixelsCopy = new Uint8ClampedArray(sendData.pixels); + sendData.pixels = pixelsCopy.buffer; + } else { + sendData.pixels = content.pixels; + } + + if (sendData.pixels?.byteLength === 0) sendData.pixels = undefined; + + let transferredObjects = [sendData.pixels]; + + if (sendData.label) { + // 🛡️ Create a copy for transfer to avoid detaching the label buffer + const labelPixelsCopy = new Uint8ClampedArray(sendData.label.img.pixels); + transferredObjects.push(labelPixelsCopy.buffer); + } + + if (sendData.qrOverlay) { + // 🛡️ Create a copy for transfer to avoid detaching the QR overlay buffer + const qrPixelsCopy = new Uint8ClampedArray(sendData.qrOverlay.img.pixels); + transferredObjects.push(qrPixelsCopy.buffer); + } + + if (sendData.qrCornerText) { + // 🛡️ Create a copy for transfer to avoid detaching the QR corner text buffer + const qrCornerPixelsCopy = new Uint8ClampedArray(sendData.qrCornerText.img.pixels); + transferredObjects.push(qrCornerPixelsCopy.buffer); + } + + if (sendData.tapeProgressBar) { + // 🛡️ Create a copy for transfer to avoid detaching the tape progress bar buffer + const tapePixelsCopy = new Uint8ClampedArray(sendData.tapeProgressBar.img.pixels); + transferredObjects.push(tapePixelsCopy.buffer); + } + + if (sendData.merryProgressBar) { + // 🎄 Create a copy for transfer to avoid detaching the merry progress bar buffer + const merryPixelsCopy = new Uint8ClampedArray(sendData.merryProgressBar.img.pixels); + transferredObjects.push(merryPixelsCopy.buffer); + } + + if (sendData.demoplayCard) { + // 🎬 Create a copy for transfer to avoid detaching the demoplay card buffer + const demoplayPixelsCopy = new Uint8ClampedArray(sendData.demoplayCard.img.pixels); + transferredObjects.push(demoplayPixelsCopy.buffer); + } + + if (sendData.lanBadge) { + // 🔗 Create a copy for transfer to avoid detaching the LAN badge buffer + const lanBadgePixelsCopy = new Uint8ClampedArray(sendData.lanBadge.img.pixels); + transferredObjects.push(lanBadgePixelsCopy.buffer); + } + + if (sendData.authorOverlay) { + // 👤 Create a copy for transfer to avoid detaching the author overlay buffer + const authorPixelsCopy = new Uint8ClampedArray(sendData.authorOverlay.img.pixels); + transferredObjects.push(authorPixelsCopy.buffer); + } + + // 🎬 Add recording UI overlays (visible on screen but NOT captured in tape) + const recordingUIData = $commonApi.recordingUI._getPending(); + if (Object.keys(recordingUIData).length > 0) { + sendData.recordingUI = {}; + for (const [name, overlayData] of Object.entries(recordingUIData)) { + // Create a copy of the pixels for transfer + const pixelsCopy = new Uint8ClampedArray(overlayData.img.pixels); + sendData.recordingUI[name] = { + x: overlayData.x, + y: overlayData.y, + opacity: overlayData.opacity, + img: { + width: overlayData.img.width, + height: overlayData.img.height, + pixels: pixelsCopy, + }, + }; + transferredObjects.push(pixelsCopy.buffer); + } + $commonApi.recordingUI._clearPending(); + } + + // 🖼️ HD layer — ship this frame's native-resolution Canvas2D drawing + // as an ImageBitmap (transferred, so it never copies). The transfer + // leaves the OffscreenCanvas blank for the piece's next paint. When an + // hd piece paints a pixel-only frame (hd() gated off for a tape or + // preview), drop bios's layer so the pixel view shows through. + if (hdPainted && hdCanvas) { + sendData.hd = { bitmap: hdCanvas.transferToImageBitmap() }; + transferredObjects.push(sendData.hd.bitmap); + hdPainted = false; + hdVisible = true; + } else if (hdVisible && painted === true) { + send({ type: "hd:clear" }); + hdVisible = false; + } + + // console.log("TO:", transferredObjects); + // console.log("Sent data:", sendData); + + sendData.sound = sound; + + // Log first render sent back to main thread + if (!globalThis._firstRenderSent) { + globalThis._firstRenderSent = true; + diskTimings.firstRenderSent = Math.round(performance.now() - diskTimingStart); + // Silent: first render sent + + // Send timing data to main thread (only in dev mode) + if (!getPackMode()) send({ type: "disk-timings", content: diskTimings }); + } + + send({ type: "render", content: sendData }, transferredObjects); + + overlay2D = null; // consumed — the piece re-sets it each paint, so it never staleness-flickers + sound.sounds.length = 0; // Empty the sound command buffer. + sound.bubbles.length = 0; + sound.farts.length = 0; + sound.kills.length = 0; + + twoDCommands.length = 0; // Empty the 2D GPU command buffer. + + // Flush the `signals` after sending. + if (reframe) reframe = undefined; + if (cursorCode) cursorCode = undefined; + } else { + // Send update (sim). + maybeLeave(); + // TODO: How necessary is this - does any info ever need to actually + // get sent? 23.01.06.16.02 + + // console.log(pixels); + // 🛡️ Create a copy for transfer to avoid detaching the pixels buffer + const transferPixels = pixels ? new Uint8ClampedArray(pixels) : null; + + send( + { + type: "update", + content: { + didntRender: true, + loading, + pixels: transferPixels?.buffer, + width: content.width, + height: content.height, + sound, + }, + }, + [transferPixels?.buffer], + ); + } + + // Wait 8 frames of the default piece before loading the initial piece. + // And also make sure the session has been queried. + // console.log(sessionStarted); + + // 🕷️ SPIDER MODE: Debug why piece isn't loading + if (typeof window !== 'undefined' && window.acSPIDER && paintCount <= 10n) { + console.log("🕷️ SPIDER: Frame check:", { + paintCount: paintCount.toString(), + sessionStarted, + PREVIEW_OR_ICON, + sandboxed: $commonApi.net.sandboxed, + loadAfterPreambleExists: !!loadAfterPreamble + }); + } + + // The preamble: frames of the default disk before the requested piece + // loads. Nine of them made sense under the animated boot; with the empty + // boot they were ~130 ms of nothing on screen. Two keep the display set + // up and give session:started its usual chance to land first. + if ( + paintCount > 1n && + (sessionStarted || PREVIEW_OR_ICON || $commonApi.net.sandboxed) + ) { + if (paintCount === 2n) { + const preambleTime = Math.round(performance.now() - diskTimingStart); + diskTimings.preambleComplete = preambleTime; + // Silent: preamble complete + } + if (typeof window !== 'undefined' && window.acSPIDER) { + console.log("🕷️ SPIDER: Calling loadAfterPreamble now!"); + } + loadAfterPreamble?.(); // Start loading after the first disk if necessary. + } + + // 🛡️ Safety net: if session:started was never received (auth failure, network issue, + // or race condition in boot.mjs), force-proceed after ~2 seconds (120 frames at 60fps) + // to prevent the system from being stuck on noise16 forever. + if ( + paintCount > 120n && + !sessionStarted && + !PREVIEW_OR_ICON && + !$commonApi.net.sandboxed && + loadAfterPreamble + ) { + console.warn("⚠️ session:started not received after 120 frames — proceeding without auth"); + sessionStarted = true; + // Try to recover handle from localStorage even without full auth + const cachedHandle = store["handle"]; + if (cachedHandle && !HANDLE) { + HANDLE = "@" + cachedHandle; + if (typeof window !== 'undefined') window.acHANDLE = HANDLE; + if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); + send({ type: "handle", content: HANDLE }); + console.log(`🔐 Recovered cached handle: ${HANDLE}`); + } + loadAfterPreamble?.(); + } + + // soundClear?.(); + + // ***Frame State Reset*** + // Reset video transcoding / print progress. + + //console.log(performance.now() - frameTime, "ms"); + + //performance.mark("b"); + + //performance.measure("a", "b"); + //console.log("Frame perf:", performance.getEntriesByType("measure")[0].duration); + //performance.clearMarks(); + //performance.clearMeasures(); + } +} + +// 📚 Utilities + +// Get the active user's handle from the server if one exists, updating +// $commonApi.handle +let HANDLE; +let handleRetryInProgress = false; +// TODO: Cache this in localStorage and clear it on log in and log out? +// 24.05.23.22.16 + +async function handle(retryCount = 0) { + const MAX_RETRIES = 3; + const RETRY_DELAYS = [500, 1000, 2000]; // Exponential backoff + + if (USER) { + // Fast path: use handle already fetched by boot.mjs (via /user?withHandle=true) + const bootHandle = USER.handle; + // TODO: Check to see if this is in localStorage or not... + const storedHandle = bootHandle || store["handle"]; // || (await store.retrieve("handle")); + + // console.log("Stored handle...", storedHandle); + + if (storedHandle) { + const newHandle = "@" + storedHandle; + if (HANDLE === newHandle) return; + HANDLE = "@" + storedHandle; + if (USER && !USER.handle) USER.handle = storedHandle; // Sync to user object + if (typeof window !== 'undefined') window.acHANDLE = HANDLE; // Expose for UDP identity + if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); + send({ type: "handle", content: HANDLE }); + store["handle:received"] = true; + if (bootHandle) store["handle"] = bootHandle; // Cache boot-fetched handle + // console.log("Retrieved handle from store:", storedHandle); + return; // Leave early if a stored handle was found. + } + + try { + log.auth.debug("Fetching handle for:", USER.sub); + const response = await fetch(`/handle?for=${USER.sub}`); + if (response.status === 200) { + const data = await response.json(); + const newHandle = "@" + data.handle; + if (newHandle === HANDLE) return; + HANDLE = newHandle; + if (USER && !USER.handle) USER.handle = data.handle; // Sync to user object + if (typeof window !== 'undefined') window.acHANDLE = HANDLE; // Expose for UDP identity + if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); + send({ type: "handle", content: HANDLE }); + store["handle:received"] = true; + store["handle"] = data.handle; + store["handle:failed"] = false; // Clear any previous failure flag + // store.persist("handle"); // Maybe this shouldn't persist. + } else { + const errorText = await response.text(); + console.warn("🤚 Handle fetch failed:", response.status, errorText); + + // Retry on 5xx server errors + if (response.status >= 500 && retryCount < MAX_RETRIES && !handleRetryInProgress) { + handleRetryInProgress = true; + const delay = RETRY_DELAYS[retryCount] || 2000; + console.log(`🔄 Retrying handle fetch in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})...`); + await new Promise(resolve => setTimeout(resolve, delay)); + handleRetryInProgress = false; + return handle(retryCount + 1); + } + + store["handle:failed"] = true; + } + } catch (error) { + console.error("🤚 Handle fetch error:", error); + + // Retry on network errors + if (retryCount < MAX_RETRIES && !handleRetryInProgress) { + handleRetryInProgress = true; + const delay = RETRY_DELAYS[retryCount] || 2000; + console.log(`🔄 Retrying handle fetch in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})...`); + await new Promise(resolve => setTimeout(resolve, delay)); + handleRetryInProgress = false; + return handle(retryCount + 1); + } + + store["handle:failed"] = true; + } + } +} + +// Tell the `bios` to pull a screenshot of the next frame. +function downloadScreenshot() { + send({ + type: "$creenshot", + content: { + filename: `$creenshot-${num.timestamp()}.png`, + modifiers: { scale: 6 }, + }, + }); +} + +// Run the piece's "leave" function which will trigger +// a new load before sending off the final frame. +function maybeLeave() { + // 🚪 Leave (Skips act and sim and paint...) + if (leaving && leaveLoad) { + try { + leave({ ...painting.api, screen, ...$commonApi }); // Trigger leave. + } catch (e) { + console.warn("👋 Leave failure...", e); + } + leaveLoad(); + leaveLoad = null; + } +} + +// Debug utility for HUD hitbox visualization +globalThis.toggleHudHitboxDebug = () => { + globalThis.debugHudHitbox = !globalThis.debugHudHitbox; + $commonApi.needsPaint(); // Force repaint to show/hide the debug visualization +}; + +// Play a sound when "notice" fires. +const noticeBell = (api, { tone } = { tone: 600 }) => { + api.beep(tone); + + noticeTimer = new gizmo.Hourglass(160, { + completed: () => { + notice = ""; + noticeOpts = null; + noticeTimer = null; + $commonApi.needsPaint(); + }, + }); +}; diff --git a/easel/context/reference/lib/geo.mjs b/easel/context/reference/lib/geo.mjs new file mode 100644 index 000000000..76d2280a2 --- /dev/null +++ b/easel/context/reference/lib/geo.mjs @@ -0,0 +1,553 @@ +// 🧮 Geometry + +const { abs, cos, sin, floor, sqrt } = Math; +import * as vec4 from "../dep/gl-matrix/vec4.mjs"; +import { dist, radians, randIntRange } from "./num.mjs"; + +// A generic circle model for algorithmic use. +export class Circle { + x; + y; + radius; + + constructor(x, y, radius = 8) { + this.x = x; + this.y = y; + this.radius = radius; + } + + // Determines whether the circle is intersecting the given line. + // function ( line, ) + + // Returns a random (x, y) point within the circle by recursively generating + // random points within a bounding box and checking to see if they are within + // the radius. + random() { + const sq = [-this.radius, this.radius]; + const np = { + x: this.x + randIntRange(...sq), + y: this.y + randIntRange(...sq), + }; + + if (dist(this.x, this.y, np.x, np.y) < this.radius) { + return np; + } else { + return this.random(this.radius); + } + } +} + +// A dynamic box defined by x, y, w, h with methods that mutate the state. +export class Box { + x = 0; + y = 0; + w = 1; + h = 1; + + // Params: + // x, y, w, h + // x, y, s + // {x, y}, s + constructor() { + if (arguments.length === 4) { + this.x = arguments[0]; + this.y = arguments[1]; + this.w = arguments[2]; + this.h = arguments[3]; + } else if (arguments.length === 3) { + this.x = arguments[0]; + this.y = arguments[1]; + this.w = arguments[2]; + this.h = this.w; + } else if (arguments.length === 2) { + this.x = arguments[0].x; + this.y = arguments[0].y; + this.w = arguments[1]; + this.h = this.w; + } + + if (this.w === 0) this.w = 1; + if (this.h === 0) this.h = 1; + } + + // Yields a new box that is a copy of an existing old one. + static from(box) { + if (Array.isArray(box)) { + return new Box(...box); + } else { + return new Box(box.x, box.y, box.w || box.width, box.h || box.height); + } + } + + // Compute area of box. + get area() { + return abs(this.w * this.h); + } + + // Compute center point of box. + get center() { + return { + x: this.x + this.w / 2, + y: this.y + this.h / 2, + }; + } + + // Yields a box where x, y is at the top left and w, h are positive. + get abs() { + let { x, y, w, h } = this; + + if (w < 0) { + x += w + 1; + w = Math.abs(w); + } + + if (h < 0) { + y += h + 1; + h = Math.abs(h); + } + + return new Box(x, y, w, h); + } + + // The top side of the box. (same as y) + get top() { + return this.y; + } + + // Calculates a y value, representing the bottom of the box. + // Note: Returns y if the height is 1. + get bottom() { + return this.h === 1 ? this.y : this.y + this.h; + //return this.y + this.h; + } + + // The left side of the box. (same as x) + get left() { + return this.x; + } + + // Calculates an x value, representing the right of the box. + // Note: Returns x if the width is 1. + get right() { + return this.w === 1 ? this.x : this.x + this.w; + } + + // All four corners. + get topLeft() { + return { x: this.left, y: this.top }; + } + + get topRight() { + return { x: this.right, y: this.top }; + } + + get bottomLeft() { + return { x: this.left, y: this.bottom }; + } + + get bottomRight() { + return { x: this.right, y: this.bottom }; + } + + // Scales a box and returns a copy. + scale(n) { + return new Box(this.x, this.y, this.w * n, this.h * n); + } + + // Crops one box to another. + crop(toX, toY, toW, toH) { + let { x, y, w, h } = this; + + if (x >= toW || y >= toH) return; // Return `undefined` if x or y is out of bounds. + + // Crop left side. + if (x < toX) { + w += x; + x = toX; + } + // Crop right side. + if (x + w > toW) w = toW - x; + // Crop top side. + if (y < toY) { + h += y; + y = toY; + } + // Crop bottom side. + if (y + h > toH) h = toH - y; + + return new Box(x, y, w, h); + } + + // Moves the box by x and y. + move({ x, y }) { + this.x += x; + this.y += y; + } + + // Returns true if this box contains the point {x, y}. + contains(point = { x: undefined, y: undefined }) { + const { x, y } = point; + return ( + this.x <= x && x <= this.x + this.w && this.y <= y && y <= this.y + this.h + ); + } + + // Returns true if this box contains NO points in `arr`. + containsNone(arr) { + if (!arr) return true; + return arr.every((o) => this.contains(o) === false); + } + + // Returns true if this box contains the point `xy` from `arr`. + onlyContains(index, arr) { + // Cut index out of the original arr. + const newArr = arr.slice(0, index).concat(arr.slice(index + 1)); + // If our index is in the box, but it contains no other points, return true. + return this.contains(arr[index]) && this.containsNone(newArr); + } + + // The opposite of contains. + misses(o) { + return !this.contains(o); + } + + // Grow a box from the center by `n` units on every side. + // And returns a copy. + grow(n) { + return new Box(this.x - n, this.y - n, this.w + n * 2, this.h + n * 2); + } + + // Checks whether two boxes are making contact and / or intersecting. + // 📓 This might be a little unperforant with tons of boxes. 23.06.29.12.48 + contact(box) { + return ( + box && + (this.contains(box.topLeft) || + this.contains(box.topRight) || + this.contains(box.bottomRight) || + this.contains(box.bottomLeft)) + ); + } + + // Returns true if the boxes match exactly. + equal(box) { + return ( + this.x === box.x && + this.y === box.y && + this.w === box.w && + this.h === box.h + ); + } +} + +// High level behavior for points: {x, y} (See also: num.vec2) +export class Point { + static equals(p1, p2) { + return p1.x === p2.x && p1.y === p2.y; + } +} + +// A 2 dimensional uniform grid, using a box as the frame (with scaling). +export class Grid { + box; + scale; + // TODO: Could rotation eventually be added here? 2021.12.08.10.51 + transform; + + #halfScale; + centerOffset; + + constructor(x, y, w, h, s = 1) { + // Takes the same arguments as box. + this.box = new Box(x, y, w, h); + this.scale = s; + this.transform = { scale: this.scale }; + this.#halfScale = this.scale / 2; + this.centerOffset = floor(this.#halfScale); + } + + // Loop through every point in the grid, starting from the top left, and + // applying a callback. + each(fun) { + for (let x = 0; x < this.box.w; x += 1) { + for (let y = 0; y < this.box.h; y += 1) { + fun(x, y, x + y * this.box.w); // Send back the coords and an index. + } + } + } + + // Returns unscaled point `{x, y}` in `grid` for given display coordinate + // `pos`, or `false` if `pos` is outside of `grid`. + under({ x, y }, cb) { + const { scale, box } = this; + + // Get original (unscaled) grid position. + const gx = floor((x - box.x) / scale); + const gy = floor((y - box.y) / scale); + + // Generate display (x, y) box and grid (gx, gy) position, + // and whether we are in the grid or not. + const gridSquare = { + x: box.x + gx * scale, + y: box.y + gy * scale, + w: scale, + h: scale, + gx, + gy, + in: this.scaled.contains({ x, y }), + }; + + if (gridSquare.in && cb) cb(gridSquare); + return gridSquare; + } + + // Returns floored display coordinates from local, untransformed ones. + get(x, y) { + return [ + floor(this.box.x + x * this.scale), + floor(this.box.y + y * this.scale), + ]; + } + + // Yields the grid's transformed bounding box according to `scale`. + get scaled() { + return new Box( + this.box.x, + this.box.y, + this.box.w * this.scale, + this.box.h * this.scale, + ); + } + + // Yields the projected "center" x, y point of the whole grid. + middle() { + return this.get(floor(this.box.w / 2), floor(this.box.h / 2)); + } + + center(x, y) { + const scaled = this.get(x, y); + // TODO: This can be replaced with a vec2.add + scaled[0] += Math.floor(this.#halfScale); + scaled[1] += Math.floor(this.#halfScale); + return scaled; + } + + // Yields an array of offset points that can be plotted to mark the center of + // each grid square. (Useful for editors, development and debugging.) + // Tries to find the exact center point, but if that doesn't exist then + // this function produces a 2x2 grid of pixels in the center. + get centers() { + const o = this.centerOffset; + + let points = []; + + // Find exact center point of grid square if possible. + if (this.#halfScale % 1 === 0.5 && this.#halfScale > 0.5) { + // We have a perfect middle square. + points.push({ x: o, y: o }); + } else if (this.scale >= 4) { + // We can assume we are even here, so we return 4 pixels to mark + // the center. + points.push( + { x: o, y: o }, + { x: o - 1, y: o - 1 }, + { x: o - 1, y: o }, + { x: o, y: o - 1 }, + ); + } + return points; + } +} + +// This box model uses `soil` to build a dirty rectangle out of points +// in order to optimize rendering. +export class DirtyBox { + box; + #left; + #top; + #right; + #bottom; + soiled = false; + + constructor() { + this.box = new Box(0, 0, 0); // Note: I probably don't need all the features of `box` here. + } + + soil({ x, y }) { + if (this.#left === undefined) { + this.#left = x; + this.#right = this.#left; + } + if (this.#top === undefined) { + this.#top = y; + this.#bottom = this.#top; + } + + if (x < this.#left) this.#left = x; + if (y < this.#top) this.#top = y; + + if (x > this.#right) this.#right = x; + if (y > this.#bottom) this.#bottom = y; + + this.box.x = this.#left; + this.box.y = this.#top; + + this.box.w = this.#right - this.#left + 1; + this.box.h = this.#bottom - this.#top + 1; + + this.soiled = true; + } + + // Crops pixels from an image and returns the new one. + // - `image` has { width, height, pixels } + crop(image) { + const b = this.croppedBox(image); + const p = image.pixels; + const newP = new Uint8ClampedArray(b.w * b.h * 4); + + // Copy rows from `p` -> `newP` + for (let row = 0; row < b.h; row += 1) { + const index = (b.x + (b.y + row) * image.width) * 4; + newP.set(p.subarray(index, index + b.w * 4), row * b.w * 4); + } + + return newP; + } + + croppedBox(image) { + return this.box.crop(0, 0, image.width, image.height); + } +} + +// Project a line outwards from an origin point at dist, and degrees to get the other point. +export function linePointsFromAngle(x1, y1, dist, degrees) { + const x2 = x1 + dist * cos(radians(degrees)); + const y2 = y1 + dist * sin(radians(degrees)); + return [x1, y1, x2, y2]; +} + +// Project outwards from a point at an `angle` and `dist` and get the resulting point. +export function pointFrom(x, y, angle, dist) { + return [x + dist * cos(radians(angle)), y + dist * sin(radians(angle))]; +} + +// Follows a point over time. +// TODO: Also keep track of a target rotation? +// Usage: +// const race = new Race(); +export class Race { + pos; + step; + goal; + + last; + speed; + dist = 0; + + quantizer; + + constructor(opts = { quantized: true }) { + this.speed = opts.speed || 20; + this.step = opts.step || 0.005; + if (opts.quantized) this.quantizer = new Quantizer({ step: this.step }); + } + + // Should also reset. + start(point) { + if (point.length === 2) point.push(0, 0); // Make sure point is a vec4. + + this.quantizer?.start(point); + this.pos = vec4.clone(point); + this.goal = vec4.clone(point); + this.last = vec4.clone(point); + this.dist = 0; + } + + to(point = this.goal) { + if (!point) return; // No goal. + if (point.length === 2) point.push(0, 0); // Make sure point is a vec4. + this.goal = point; + + if (!this.pos) return false; + let out; + + // Quantization ON (regulated segments) + if (this.quantizer) { + const newPos = vec4.lerp( + vec4.create(), + this.pos, + point, + 0.01 * this.speed, + ); + out = this.quantizer.to(newPos); + this.pos = newPos; + } else { + // Quantization OFF (longer, less regulated segments) + const newPos = vec4.lerp( + vec4.create(), + this.pos, + point, + 0.01 * this.speed, + ); + this.dist += vec4.dist(this.pos, newPos); + this.pos = newPos; + + if (this.dist >= this.step) { + out = { + last: vec4.clone(this.last), + current: this.pos, + out: [this.last, this.pos], + }; + this.dist -= this.step; // Hold on for better normalization! + this.last = this.pos; + } else { + out = { last: this.last, current: this.pos }; + } + } + + return out; + } +} + +// A simple model for lazy following. +// Originally programmed for `3dline`. +// TODO: Generalize the output so only individual points +// (not line segment vertices with repeated points) +// can be returned. 22.10.18.11.10 +export class Quantizer { + pos; + step; + dist = 0; + + constructor(opts) { + this.step = opts.step; + } + + // Returns an array of [lastPoint, nextPoint...] + to(point) { + if (!this.pos) return false; + + let out = []; + this.dist = vec4.dist(this.pos, point); + let lastPoint = this.pos; + + while (this.dist >= this.step) { + const nextPoint = vec4.lerp( + vec4.create(), + lastPoint, + point, + this.step / this.dist, + ); + out.push(lastPoint, nextPoint); + lastPoint = nextPoint; + this.dist -= this.step; // Hold on for better normalization! + } + + this.pos = lastPoint; + return { last: this.pos, current: point, out }; + } + + start(point) { + this.pos = vec4.clone(point); + } +} diff --git a/easel/context/reference/lib/graph.mjs b/easel/context/reference/lib/graph.mjs new file mode 100644 index 000000000..be8de9094 --- /dev/null +++ b/easel/context/reference/lib/graph.mjs @@ -0,0 +1,9751 @@ +import { + p2, + number, + wrap, + randInt, + byteInterval17, + even, + radians, + lerp, + randIntRange, + randIntArr, + clamp, + signedCeil, + rainbow, + zebra, + resetRainbowCache as numResetRainbowCache, + resetZebraCache as numResetZebraCache, + isHexString, + hexToRgb, + shiftRGB, + cssColors, + parseColorIndex, + rand, +} from "./num.mjs"; + +import * as mat4 from "../dep/gl-matrix/mat4.mjs"; +import * as vec2 from "../dep/gl-matrix/vec2.mjs"; +import * as vec3 from "../dep/gl-matrix/vec3.mjs"; +import * as vec4 from "../dep/gl-matrix/vec4.mjs"; + +import { any, repeat, nonvalue, flip as randomFlip } from "./help.mjs"; +import { Box } from "./geo.mjs"; +import { nanoid } from "../dep/nanoid/nanoid.js"; + +const { round, sign: mathSign, abs, ceil, floor, sin, cos, min, max, sqrt, PI } = Math; + +let width, height, pixels; +// 🎬 Classic software-renderer Z-buffer. Holds per-pixel depth in NDC Z space +// (after perspective divide). Convention: lower = nearer, higher = farther. +// `Number.MAX_VALUE` is the "infinitely far" sentinel so any real geometry +// passes the first draw. Kept as a plain Array (not typed) because disk.mjs +// resizes it with `depthBuffer.length = W*H` which only works on Arrays. +const depthBuffer = []; +let depthEnabled = true; // global toggle (pieces can flip this for debugging) + +function clearDepthBuffer() { + if (!depthEnabled) return; + const needed = width * height; + if (needed === 0) return; + if (depthBuffer.length !== needed) depthBuffer.length = needed; + depthBuffer.fill(Number.MAX_VALUE); +} + +const writeBuffer = []; +const c = [255, 255, 255, 255]; +let c2 = null; // Alternate / secondary color support. +const panTranslation = { x: 0, y: 0 }; // For 2d shifting using `pan` and `unpan`. +let activeMask; // A box for totally masking the renderer. +// This should work everywhere. +const skips = []; + +// 🖼️ ImageBitmap pixel extraction cache (for tape previews and other runtime bitmaps) +const bitmapPixelCache = typeof WeakMap !== "undefined" ? new WeakMap() : null; +let bitmapPixelCanvas = null; +let bitmapPixelCtx = null; + +function getBitmapExtractionContext(width, height) { + // Prefer OffscreenCanvas when available (workers) + if (typeof OffscreenCanvas !== "undefined") { + if (!(bitmapPixelCanvas instanceof OffscreenCanvas)) { + bitmapPixelCanvas = new OffscreenCanvas(width, height); + bitmapPixelCtx = bitmapPixelCanvas.getContext("2d"); + } + if (bitmapPixelCanvas.width !== width) bitmapPixelCanvas.width = width; + if (bitmapPixelCanvas.height !== height) bitmapPixelCanvas.height = height; + if (!bitmapPixelCtx) bitmapPixelCtx = bitmapPixelCanvas.getContext("2d"); + return bitmapPixelCtx; + } + + // Fallback to DOM canvas when running on the main thread + if (typeof document !== "undefined") { + if (!bitmapPixelCanvas || typeof bitmapPixelCanvas.getContext !== "function") { + bitmapPixelCanvas = document.createElement("canvas"); + } + if (bitmapPixelCanvas.width !== width) bitmapPixelCanvas.width = width; + if (bitmapPixelCanvas.height !== height) bitmapPixelCanvas.height = height; + bitmapPixelCtx = bitmapPixelCanvas.getContext("2d"); + return bitmapPixelCtx; + } + + return null; +} + +function ensureBufferPixels(buffer) { + if (!buffer) return null; + + if (buffer.pixels && buffer.pixels.length) return buffer.pixels; + + if (bitmapPixelCache && bitmapPixelCache.has(buffer)) { + return bitmapPixelCache.get(buffer); + } + + const isImageBitmap = + typeof ImageBitmap !== "undefined" && buffer instanceof ImageBitmap; + const isImageData = typeof ImageData !== "undefined" && buffer instanceof ImageData; + + if (isImageData) { + const pixels = buffer.data; + bitmapPixelCache?.set(buffer, pixels); + return pixels; + } + + if (isImageBitmap) { + const ctx = getBitmapExtractionContext(buffer.width, buffer.height); + if (!ctx) return null; + ctx.clearRect(0, 0, buffer.width, buffer.height); + ctx.drawImage(buffer, 0, 0); + try { + const imageData = ctx.getImageData(0, 0, buffer.width, buffer.height); + const pixels = imageData.data; + bitmapPixelCache?.set(buffer, pixels); + return pixels; + } catch (err) { + console.warn("🎨 Unable to extract pixels from ImageBitmap", err); + return null; + } + } + + if (buffer.data && buffer.data.length) { + // Generic typed array fallback (ImageData-like objects) + return buffer.data; + } + + return null; +} + +let forceReplaceMode = false; + +function setForceReplaceMode(enabled = false) { + const previous = forceReplaceMode; + forceReplaceMode = !!enabled; + return previous; +} + +function withForceReplaceMode(callback) { + const previous = setForceReplaceMode(true); + try { + return callback?.(); + } finally { + setForceReplaceMode(previous); + } +} + +// Legacy global fade variables (kept for backward compatibility) +// Note: New code should use local fade system via parseLocalFade/getLocalFadeColor +let fadeMode = false; +let fadeColors = []; +let fadeDirection = "horizontal"; +let fadeNeat = false; + +// 🚨 GRAPH PERFORMANCE TRACKING +const graphPerf = { + enabled: false, + functions: new Map(), + lastFPS: 0, // Track current FPS for frame skipping + track(name, duration) { + if (!this.enabled) return; + if (!this.functions.has(name)) { + this.functions.set(name, { count: 0, totalTime: 0, maxTime: 0 }); + } + const stats = this.functions.get(name); + stats.count++; + stats.totalTime += duration; + stats.maxTime = Math.max(stats.maxTime, duration); + + // Warn about slow functions (reduced threshold for important debugging) + if (duration > 15) { + console.warn(`🐌 SLOW GRAPH FUNCTION: ${name} took ${duration.toFixed(2)}ms`); + } + }, + reset() { + this.functions.clear(); + }, + getStats() { + const stats = []; + this.functions.forEach((data, name) => { + stats.push({ + name, + count: data.count, + totalTime: data.totalTime, + avgTime: data.totalTime / data.count, + maxTime: data.maxTime + }); + }); + return stats.sort((a, b) => b.totalTime - a.totalTime); + } +}; + +// Make graphPerf globally accessible for performance monitoring +if (typeof window !== 'undefined') { + window.graphPerf = graphPerf; +} + +// Also try globalThis for broader compatibility +if (typeof globalThis !== 'undefined') { + globalThis.graphPerf = graphPerf; +} + +// 📚 DOCUMENTATION: Fade support - "fade:color1-color2-color3" syntax +// Fade state - used by ink() function for smooth color transitions +// Removed global fade state - everything is now local! +let currentKidLispContext = null; // For dynamic evaluation of fade directions +let currentRainbowColor = null; // Cache rainbow color for current drawing operation +let currentZebraColor = null; // Cache zebra color for current drawing operation + +let debug = false; +export function setDebug(newDebug) { + debug = newDebug; +} + +let matrixChunkyDebugCount = 0; + +function matrixDebugEnabled() { + if (typeof window !== "undefined" && window?.acMatrixDebug) return true; + if (typeof globalThis !== "undefined" && globalThis?.acMatrixDebug) return true; + return false; +} + +function inkFloodLoggingEnabled() { + if (typeof globalThis !== "undefined" && globalThis.AC_LOG_INK_COLORS) return true; + if (typeof process !== "undefined" && process.env?.AC_LOG_INK_COLORS === "1") return true; + return false; +} + +function inkFloodLogPrefix() { + let label = null; + if (typeof process !== "undefined" && process.env?.AC_LOG_INK_LABEL) { + label = process.env.AC_LOG_INK_LABEL; + } else if (typeof globalThis !== "undefined" && globalThis.AC_LOG_INK_LABEL) { + label = globalThis.AC_LOG_INK_LABEL; + } + return label ? `[${label}] ` : ""; +} + +function cloneColorForLog(color) { + if (Array.isArray(color)) return Array.from(color); + return color; +} + +function cloneValueForLog(value) { + if (Array.isArray(value)) return Array.from(value); + if (value && typeof value === "object") { + try { + return JSON.parse(JSON.stringify(value)); + } catch (err) { + return String(value); + } + } + return value; +} + +// 🎨 FADE HELPERS: New local fade detection and parsing +// These replace the global fade state management system + +/** + * Check if a color contains a fade string + * @param {Array} color - Color array that might contain fade string + * @returns {boolean} - True if color contains fade string + */ +function isFadeColor(color) { + return Array.isArray(color) && + color.length > 0 && + typeof color[0] === 'string' && + color[0].startsWith('fade:'); +} + +/** + * Parse fade color locally without setting global state + * @param {Array} color - Color array containing fade string + * @returns {Object|null} - Fade info object or null if not a fade + */ +/** + * Parse fade colors locally without setting any global state + * @param {string} fadeType - The fade type (e.g., "red-blue", "rainbow-black-rainbow") + * @param {number} alpha - Alpha value to apply to all colors + * @returns {Array} - Array of color arrays + */ +function parseLocalFade(fadeType, alpha = 255) { + // Replace :frame with current frame count if available + let processedFadeType = fadeType; + if (fadeType.includes("frame") && currentKidLispContext && currentKidLispContext.frameCount !== undefined) { + processedFadeType = fadeType.replace(/frame/g, currentKidLispContext.frameCount.toString()); + } + + // Handle color combinations separated by dashes + const colorNames = processedFadeType.split("-"); + const colors = []; + + // For symmetric patterns, we want the same sequence colors to have the same base + // but still support consecutive different colors like rainbow-rainbow-rainbow + let rainbowBaseOffset = 0; + let zebraBaseOffset = 0; + + for (let i = 0; i < colorNames.length; i++) { + const colorName = colorNames[i]; + + if (colorName === "rainbow") { + // Check if this is a consecutive rainbow (previous color was also rainbow) + const consecutiveOffset = (i > 0 && colorNames[i-1] === "rainbow") ? 1 : 0; + colors.push(["rainbow", 255, 255, 255, alpha, rainbowBaseOffset + consecutiveOffset]); + if (consecutiveOffset > 0) rainbowBaseOffset++; + } else if (colorName === "zebra") { + // Check if this is a consecutive zebra (previous color was also zebra) + const consecutiveOffset = (i > 0 && colorNames[i-1] === "zebra") ? 1 : 0; + colors.push(["zebra", 255, 255, 255, alpha, zebraBaseOffset + consecutiveOffset]); + if (consecutiveOffset > 0) zebraBaseOffset++; + } else if (/^c\d+$/i.test(colorName)) { + // Color index like c0, c1, etc. + const indexColor = parseColorIndex(colorName.toLowerCase()); + if (indexColor) { + colors.push([...indexColor.slice(0, 3), alpha]); + } + } else { + // Named CSS color + const color = cssColors[colorName]; + if (color) { + colors.push([...color.slice(0, 3), alpha]); + } else { + // If color not found, use a default color + colors.push([128, 128, 128, alpha]); + } + } + } + + return colors.length >= 2 ? colors : null; +} + +function parseFadeColor(color) { + if (!isFadeColor(color)) return null; + + const fadeString = color[0]; + const alpha = color[1] || 255; // Second element is alpha if provided + + // Parse fade string locally without setting global state + const parts = fadeString.split(":"); + if (parts.length < 2 || parts.length > 5 || parts[0] !== "fade") { + return null; + } + + // Extract direction and neat flag locally (neat is now the default) + let isNeat = true; // Changed: neat is now the default + let fadeType = parts[1]; + let direction = parts[2] || "horizontal"; + + // Handle different dirty syntax positions (dirty disables neat) + if (parts[1] === "dirty" && parts[2]) { + isNeat = false; + fadeType = parts[2]; + direction = parts[3] || "horizontal"; + } else if (parts[2] === "dirty") { + isNeat = false; + direction = "horizontal"; + } else if (parts[3] === "dirty") { + isNeat = false; + direction = parts[2]; + } else if (parts.includes("dirty")) { + // Handle dirty anywhere in the string + isNeat = false; + const filteredParts = parts.filter(p => p !== "dirty"); + if (filteredParts.length >= 2) { + fadeType = filteredParts[1]; + direction = filteredParts[2] || "horizontal"; + } + } + // Also still support explicit 'neat' keyword for clarity (though it's the default) + else if (parts[1] === "neat" && parts[2]) { + isNeat = true; + fadeType = parts[2]; + direction = parts[3] || "horizontal"; + } else if (parts[2] === "neat") { + isNeat = true; + direction = "horizontal"; + } else if (parts[3] === "neat") { + isNeat = true; + direction = parts[2]; + } else if (parts.includes("neat")) { + isNeat = true; + const filteredParts = parts.filter(p => p !== "neat"); + if (filteredParts.length >= 2) { + fadeType = filteredParts[1]; + direction = filteredParts[2] || "horizontal"; + } + } + + // Parse fade colors using the local fade type and alpha + const fadeColors = parseLocalFade(fadeType, alpha); + if (!fadeColors) return null; + + // Evaluate the direction (handles dynamic expressions like frame numbers) + const evaluatedDirection = evaluateFadeDirection(direction); + + return { + colors: fadeColors, + direction: evaluatedDirection, // Use evaluated direction (can be numeric angle) + neat: isNeat // Local neat flag, no global state + }; +} + +/** + * Get interpolated fade color locally without using global state + * @param {number} t - Position along fade (0-1) + * @param {number} x - X coordinate for noise (optional) + * @param {number} y - Y coordinate for noise (optional) + * @param {Object|Array} fadeInfo - Local fade info from parseFadeColor (object) or parseLocalFade (array) + * @returns {Array} - RGBA color array + */ +function getLocalFadeColor(t, x = 0, y = 0, fadeInfo) { + if (!fadeInfo) { + return c.slice(); // Return current color if no fade info + } + + // Handle both array (from parseLocalFade) and object (from parseFadeColor) formats + const colors = Array.isArray(fadeInfo) ? fadeInfo : fadeInfo.colors; + + if (!colors || colors.length < 2) { + return c.slice(); // Return current color if invalid colors + } + + // Clamp t to 0-1 range + t = Math.max(0, Math.min(1, t)); + + // Calculate which colors to interpolate between + const segments = colors.length - 1; + const segment = Math.min(Math.floor(t * segments), segments - 1); + const localT = (t * segments) - segment; + + const color1 = colors[segment]; + const color2 = colors[segment + 1] || colors[segment]; // Fallback to same color if out of bounds + + // Safety check to prevent undefined access + if (!color1 || !color2) { + console.warn("🚨 getLocalFadeColor: Invalid color data", { segment, colors, t }); + return c.slice(); // Return current color as fallback + } + + // Handle special dynamic colors (zebra first, then rainbow) + if (color1[0] === "zebra" || color2[0] === "zebra") { + // Don't use cached zebra color - always get fresh colors with offsets + // This fixes the cyan zebra issue when zebra is first in the chain + + // Properly interpolate between zebra and regular colors + if (color1[0] === "zebra" && color2[0] === "zebra") { + // Both are zebra, get colors with their respective offsets + const offset1 = color1[5] || 0; // Get offset from 6th element + const offset2 = color2[5] || 0; + const zebra1 = zebra(offset1); + const zebra2 = zebra(offset2); + + const alpha1 = color1[4] || 255; + const alpha2 = color2[4] || 255; + const alpha = Math.round(alpha1 + (alpha2 - alpha1) * localT); + + return [ + Math.round(zebra1[0] + (zebra2[0] - zebra1[0]) * localT), + Math.round(zebra1[1] + (zebra2[1] - zebra1[1]) * localT), + Math.round(zebra1[2] + (zebra2[2] - zebra1[2]) * localT), + alpha + ]; + } else if (color1[0] === "zebra") { + // Interpolate from zebra to regular/rainbow color + const offset1 = color1[5] || 0; + const zebra1 = zebra(offset1); + const alpha1 = color1[4] || 255; + + // Handle if color2 is also a special color + let color2RGB; + let alpha2; + if (color2[0] === "rainbow") { + const offset2 = color2[5] || 0; + color2RGB = rainbow(offset2); + alpha2 = color2[4] || 255; + } else { + color2RGB = [color2[0], color2[1], color2[2]]; + alpha2 = color2[3] || 255; + } + + return [ + Math.round(zebra1[0] + (color2RGB[0] - zebra1[0]) * localT), + Math.round(zebra1[1] + (color2RGB[1] - zebra1[1]) * localT), + Math.round(zebra1[2] + (color2RGB[2] - zebra1[2]) * localT), + Math.round(alpha1 + (alpha2 - alpha1) * localT) + ]; + } else { + // Interpolate from regular color to zebra + const offset2 = color2[5] || 0; + const zebra2 = zebra(offset2); + const alpha1 = color1[3] || 255; + const alpha2 = color2[4] || 255; + return [ + Math.round(color1[0] + (zebra2[0] - color1[0]) * localT), + Math.round(color1[1] + (zebra2[1] - color1[1]) * localT), + Math.round(color1[2] + (zebra2[2] - color1[2]) * localT), + Math.round(alpha1 + (alpha2 - alpha1) * localT) + ]; + } + } + + if (color1[0] === "rainbow" || color2[0] === "rainbow") { + // Don't use cached rainbow color - always get fresh colors with offsets + // This keeps rainbow consistent with zebra behavior + + // Properly interpolate between rainbow and regular colors + if (color1[0] === "rainbow" && color2[0] === "rainbow") { + // Both are rainbow, get colors with their respective offsets + const offset1 = color1[5] || 0; // Get offset from 6th element + const offset2 = color2[5] || 0; + const rainbow1 = rainbow(offset1); + const rainbow2 = rainbow(offset2); + + const alpha1 = color1[4] || 255; + const alpha2 = color2[4] || 255; + const alpha = Math.round(alpha1 + (alpha2 - alpha1) * localT); + + return [ + Math.round(rainbow1[0] + (rainbow2[0] - rainbow1[0]) * localT), + Math.round(rainbow1[1] + (rainbow2[1] - rainbow1[1]) * localT), + Math.round(rainbow1[2] + (rainbow2[2] - rainbow1[2]) * localT), + alpha + ]; + } else if (color1[0] === "rainbow") { + // Interpolate from rainbow to regular/zebra color + const offset1 = color1[5] || 0; + const rainbow1 = rainbow(offset1); + const alpha1 = color1[4] || 255; + + // Handle if color2 is also a special color + let color2RGB; + let alpha2; + if (color2[0] === "zebra") { + const offset2 = color2[5] || 0; + color2RGB = zebra(offset2); + alpha2 = color2[4] || 255; + } else { + color2RGB = [color2[0], color2[1], color2[2]]; + alpha2 = color2[3] || 255; + } + + return [ + Math.round(rainbow1[0] + (color2RGB[0] - rainbow1[0]) * localT), + Math.round(rainbow1[1] + (color2RGB[1] - rainbow1[1]) * localT), + Math.round(rainbow1[2] + (color2RGB[2] - rainbow1[2]) * localT), + Math.round(alpha1 + (alpha2 - alpha1) * localT) + ]; + } else { + // Interpolate from regular color to rainbow + const offset2 = color2[5] || 0; + const rainbow2 = rainbow(offset2); + const alpha1 = color1[3] || 255; + const alpha2 = color2[4] || 255; + return [ + Math.round(color1[0] + (rainbow2[0] - color1[0]) * localT), + Math.round(color1[1] + (rainbow2[1] - color1[1]) * localT), + Math.round(color1[2] + (rainbow2[2] - color1[2]) * localT), + Math.round(alpha1 + (alpha2 - alpha1) * localT) + ]; + } + } + + if (color1[0] === "zebra" || color2[0] === "zebra") { + console.log("🦓 Taking zebra branch"); + // Don't use cached zebra color - always get fresh colors with offsets + // This fixes the cyan zebra issue when zebra is first in the chain + + // Properly interpolate between zebra and regular colors + if (color1[0] === "zebra" && color2[0] === "zebra") { + // Both are zebra, get colors with their respective offsets + const offset1 = color1[5] || 0; // Get offset from 6th element + const offset2 = color2[5] || 0; + const zebra1 = zebra(offset1); + const zebra2 = zebra(offset2); + + const alpha1 = color1[4] || 255; + const alpha2 = color2[4] || 255; + const alpha = Math.round(alpha1 + (alpha2 - alpha1) * localT); + + return [ + Math.round(zebra1[0] + (zebra2[0] - zebra1[0]) * localT), + Math.round(zebra1[1] + (zebra2[1] - zebra1[1]) * localT), + Math.round(zebra1[2] + (zebra2[2] - zebra1[2]) * localT), + alpha + ]; + } else if (color1[0] === "zebra") { + // Interpolate from zebra to regular/rainbow color + const offset1 = color1[5] || 0; + const zebra1 = zebra(offset1); + + // DEBUG: Log zebra color when it's first + console.log("🦓 DEBUG zebra first:", { offset1, zebra1, color1 }); + + const alpha1 = color1[4] || 255; + + // Handle if color2 is also a special color + let color2RGB; + let alpha2; + if (color2[0] === "rainbow") { + const offset2 = color2[5] || 0; + color2RGB = rainbow(offset2); + alpha2 = color2[4] || 255; + } else { + color2RGB = [color2[0], color2[1], color2[2]]; + alpha2 = color2[3] || 255; + } + + return [ + Math.round(zebra1[0] + (color2RGB[0] - zebra1[0]) * localT), + Math.round(zebra1[1] + (color2RGB[1] - zebra1[1]) * localT), + Math.round(zebra1[2] + (color2RGB[2] - zebra1[2]) * localT), + Math.round(alpha1 + (alpha2 - alpha1) * localT) + ]; + } else { + // Interpolate from regular color to zebra + const offset2 = color2[5] || 0; + const zebra2 = zebra(offset2); + const alpha1 = color1[3] || 255; + const alpha2 = color2[4] || 255; + return [ + Math.round(color1[0] + (zebra2[0] - color1[0]) * localT), + Math.round(color1[1] + (zebra2[1] - color1[1]) * localT), + Math.round(color1[2] + (zebra2[2] - color1[2]) * localT), + Math.round(alpha1 + (alpha2 - alpha1) * localT) + ]; + } + } + + // Linear interpolation between regular colors + const r = Math.round(color1[0] + (color2[0] - color1[0]) * localT); + const g = Math.round(color1[1] + (color2[1] - color1[1]) * localT); + const b = Math.round(color1[2] + (color2[2] - color1[2]) * localT); + const a = Math.round(color1[3] + (color2[3] - color1[3]) * localT); + + // Add noise if not neat mode (same logic as getFadeColor) + if (!fadeInfo.neat) { + const noiseAmount = 8; + const noiseR = (Math.random() - 0.5) * noiseAmount; + const noiseG = (Math.random() - 0.5) * noiseAmount; + const noiseB = (Math.random() - 0.5) * noiseAmount; + + return [ + Math.max(0, Math.min(255, r + noiseR)), + Math.max(0, Math.min(255, g + noiseG)), + Math.max(0, Math.min(255, b + noiseB)), + a + ]; + } + + return [r, g, b, a]; +} + +// 🔧 DEBUG: Helper function to get current global dimensions +function getGlobalDimensions() { + return `${width}x${height}`; +} + +// 🔧 DEBUG: Export for debugging buffer context issues +export { getGlobalDimensions }; + +let twoDCommands; +export function twoD(ref) { + twoDCommands = ref; +} + +// 1. Configuration & State +function makeBuffer(width, height, fillProcess, painting, api) { + if (!width || !height) return; + + const bufferStart = performance.now(); // 🔍 PERFORMANCE DEBUG + + const imageData = new ImageData(width, height); + + const buffer = { + pixels: imageData.data, + width: imageData.width, + height: imageData.height, + }; + + buffer.api = api; + + if (typeof fillProcess === "function") { + // Remember the current buffer and color. + const savedBuffer = getBuffer(); + const rc = c.slice(); // Remember color (copy, not reference). + // Save and reset panTranslation to prevent paste operations in new buffers from being offset + const savedPan = { x: panTranslation.x, y: panTranslation.y }; + panTranslation.x = 0; + panTranslation.y = 0; + + setBuffer(buffer); + api.screen.pixels = buffer.pixels; // Set the API's pixel buffer. + + // 🚀 OPTIMIZATION: Fast path for simple operations + try { + fillProcess(api); // Every fill process gets a painting API. + // Only call paint if there's actually something to paint + if (painting && typeof painting.paint === 'function') { + painting.paint(true); + } + } catch (error) { + console.warn('⚠️ makeBuffer fillProcess error:', error); + } + + // Restore old buffer, color, and panTranslation. + panTranslation.x = savedPan.x; + panTranslation.y = savedPan.y; + setBuffer(savedBuffer); + color(...rc); + } + + const bufferTime = performance.now() - bufferStart; // 🔍 PERFORMANCE DEBUG + // Disabled buffer creation logging for cleaner console + // if (bufferTime > 10) { // Only log very slow buffer creation + // console.log(`🔍 graph.makeBuffer took ${bufferTime.toFixed(2)}ms (${width}x${height})`); + // } + + return buffer; +} + +// Returns a cloned pixel buffer. +function cloneBuffer(buffer) { + return { + width: buffer.width, + height: buffer.height, + pixels: new Uint8ClampedArray(buffer.pixels), + }; +} + +function getBuffer() { + return { width, height, pixels }; +} + +function setBuffer(buffer) { + // Check if the buffer's pixels array is detached and recreate if necessary + if (buffer.pixels && buffer.pixels.buffer && buffer.pixels.buffer.detached) { + console.warn('🚨 Detected detached pixels buffer in setBuffer, recreating... (This should be rare now)'); + // Recreate the buffer with the same dimensions, filled with transparent pixels + buffer.pixels = new Uint8ClampedArray(buffer.width * buffer.height * 4); + buffer.pixels.fill(0); // Fill with transparent black + } + + ({ width, height, pixels } = buffer); +} + +function changePixels(changer) { + changer(pixels, width, height); +} + +// Return a pixel from the main buffer or from a specified buffer. +function pixel(x, y, painting = { width, height, pixels }) { + const buffer = painting.pixels; + if (x >= painting.width || y >= painting.height || x < 0 || y < 0) { + return [0, 0, 0, 0]; + } + const i = (floor(x) + floor(y) * painting.width) * 4; + return [buffer[i], buffer[i + 1], buffer[i + 2], buffer[i + 3]]; +} + +// Helper function to check if two colors are the same +function colorsMatch(color1, color2) { + if (!color1) return false; + return ( + color1[0] === color2[0] && + color1[1] === color2[1] && + color1[2] === color2[2] && + color1[3] === color2[3] + ); +} + +// Fill pixels with a color using a flood fill technique. +function flood(x, y, fillColor = c) { + // Note: Fade flood fill temporarily disabled during global fade system removal + // TODO: Implement local fade flood fill later + + // Bounds check + if (x < 0 || y < 0 || x >= width || y >= height) { + return { color: [0, 0, 0, 0], area: 0 }; + } + + // Get the target color of the pixel at (x, y) + const targetColor = pixel(x, y); + if (targetColor[3] === 0) { + // If the target pixel is transparent, return + return { + color: targetColor, + area: 0, + }; + } + + const previousColorState = cloneColorForLog(c); + const resolvedFillColor = findColor(fillColor); + if (inkFloodLoggingEnabled()) { + console.log( + `${inkFloodLogPrefix()}🌊 FLOOD DEBUG`, + { + input: cloneValueForLog(fillColor), + resolved: cloneColorForLog(resolvedFillColor), + previous: previousColorState, + origin: { x, y }, + target: cloneColorForLog(targetColor) + } + ); + } + + // 🚀 TRY GPU FLOOD FIRST (Jump Flooding Algorithm - O(log n)) + if (gpuFloodEnabled && gpuFloodAvailable && gpuAllowed("flood") && gpuSpinModule && pixels && width && height) { + const floodStart = performance.now(); + const result = gpuSpinModule.gpuFlood( + pixels, + width, + height, + x, + y, + targetColor, + resolvedFillColor + ); + + if (result.success) { + gpuOk("flood"); + const floodTime = performance.now() - floodStart; + graphPerf.track("flood-gpu", floodTime); + + if (inkFloodLoggingEnabled()) { + console.log( + `${inkFloodLogPrefix()}🌊 GPU FLOOD RESULT (JFA)`, + { + resolved: cloneColorForLog(resolvedFillColor), + previous: previousColorState, + area: result.area, + timeMs: floodTime.toFixed(2) + } + ); + } + + return { + color: targetColor, + area: result.area, + }; + } + gpuFailed("flood"); + } + + // 💻 CPU FALLBACK: Stack-based flood fill + const cpuStart = performance.now(); + let count = 0; + // Use a more efficient visited tracking with numeric keys + const visited = new Uint8Array(width * height); + const stack = [[x, y]]; + + color(...resolvedFillColor); + const oldColor = c.slice(); // Copy, not reference + while (stack.length) { + const [cx, cy] = stack.pop(); + + // Bounds check + if (cx < 0 || cy < 0 || cx >= width || cy >= height) continue; + + const index = cy * width + cx; + if (visited[index]) continue; + visited[index] = 1; + + const currentColor = pixel(cx, cy); + if (colorsMatch(currentColor, targetColor)) { + count++; + plot(cx, cy); + + // Push neighbors to stack only if they're in bounds + if (cx + 1 < width) stack.push([cx + 1, cy]); + if (cx > 0) stack.push([cx - 1, cy]); + if (cy + 1 < height) stack.push([cx, cy + 1]); + if (cy > 0) stack.push([cx, cy - 1]); + } + } + + color(...oldColor); + + const cpuTime = performance.now() - cpuStart; + graphPerf.track("flood-cpu", cpuTime); + + if (inkFloodLoggingEnabled()) { + console.log( + `${inkFloodLogPrefix()}🌊 CPU FLOOD RESULT`, + { + resolved: cloneColorForLog(resolvedFillColor), + previous: previousColorState, + area: count, + timeMs: cpuTime.toFixed(2) + } + ); + } + + return { + color: targetColor, + area: count, + }; +} + +// Gradient flood fill for fade colors +// gradientFlood function temporarily removed during global fade system refactor +// TODO: Implement local fade flood fill using the new local fade system + +// getFadeColor function removed - everything uses local getLocalFadeColor now! + +// Reset rainbow and zebra cache for new drawing operations +function resetRainbowCache() { + currentRainbowColor = null; + currentZebraColor = null; + // Also reset the num.mjs frame caches + numResetRainbowCache(); + // Note: Don't reset zebra cache here - zebra needs to maintain its color + // within the same frame, while rainbow can reset on every call. + // numResetZebraCache(); +} + +// Legacy getFadeColor function (for backward compatibility) +// New code should use getLocalFadeColor instead +function getFadeColor(t, x = 0, y = 0) { + if (!fadeMode || fadeColors.length < 2) { + return c.slice(); // Return current color if no fade + } + + // For backward compatibility, create local fade info and use local function + const fadeInfo = { + colors: fadeColors, + direction: fadeDirection, + neat: fadeNeat + }; + + return getLocalFadeColor(t, x, y, fadeInfo); +} + +function normalizeColorInput(value) { + if (value === undefined || value === null) return value; + + // Directly handle KidLisp symbol wrappers and other objects that expose a `.name` or `.value` + if (typeof value === "object") { + if (typeof value.name === "string") { + return normalizeColorInput(value.name); + } + if (typeof value.value === "string") { + return normalizeColorInput(value.value); + } + if (typeof value.toString === "function" && value.toString !== Object.prototype.toString) { + return normalizeColorInput(value.toString()); + } + } + + if (typeof value !== "string") return value; + + let normalized = value.trim(); + + // Strip leading KidLisp quote syntax (e.g. `'white`) and enclosing quotes/backticks + if (normalized.startsWith("'") && !normalized.endsWith("'")) { + normalized = normalized.slice(1); + } + const quotePairs = ["'", '"', "`"]; // Support ', ", ` pairs + for (const quote of quotePairs) { + if (normalized.startsWith(quote) && normalized.endsWith(quote) && normalized.length > 1) { + normalized = normalized.slice(1, -1).trim(); + break; + } + } + + // Handle KidLisp pipe symbols (|white|) that occasionally appear + if (normalized.startsWith("|") && normalized.endsWith("|") && normalized.length > 2) { + normalized = normalized.slice(1, -1); + } + + // Normalize unicode quotes (smart quotes) by stripping them + const smartQuotes = ["“", "”", "‘", "’"]; + if (smartQuotes.includes(normalized[0])) normalized = normalized.slice(1); + if (smartQuotes.includes(normalized[normalized.length - 1])) normalized = normalized.slice(0, -1); + + // Lowercase CSS color names & indexes but keep hex casing flexible + if (normalized.startsWith("fade:")) { + const fadeContent = normalized.slice(5); + normalized = `fade:${fadeContent.toLowerCase()}`; + } else if (!(normalized.startsWith("#") || normalized.startsWith("0x"))) { + normalized = normalized.toLowerCase(); + } + + return normalized; +} + +// Parse a color from a variety of inputs.. +function findColor() { + let args = [...arguments]; + + if (args.length === 1 && args[0] !== undefined) { + const isNumber = () => typeof args[0] === "number"; + const isArray = () => Array.isArray(args[0]); + const isString = () => typeof args[0] === "string"; + const isFadeObject = () => typeof args[0] === "object" && args[0] !== null && args[0].type === "fade"; + const isBool = typeof args[0] === "boolean"; + + // Handle fade objects (from parseColor) - now completely local + if (isFadeObject()) { + const fadeObj = args[0]; + // Extract alpha value from array if needed (for range syntax like "32-64") + const alphaValue = Array.isArray(fadeObj.alpha) ? fadeObj.alpha[0] : fadeObj.alpha; + // Create fade color array format for local detection + const fadeColorArray = [fadeObj.fadeString, alphaValue || 255]; + return fadeColorArray; // Return as is for local processing + } + + if (isBool) { + // No more global fade state to reset + resetRainbowCache(); + return args[0] ? [255, 255, 255, 255] : [0, 0, 0, 255]; + } + + // If it's not a Number or Array or String or FadeObject, then assume it's an object, + // randomly pick a key & re-run. + if (!isNumber() && !isArray() && !isString() && !isFadeObject()) + return findColor(any(args[0])); + + // Single number argument. + if (isNumber()) { + // No more global fade state to reset + resetRainbowCache(); + + // Treat as raw hex if we hit a certain limit. + if (args[0] > 255) { + args = hexToRgb(args[0]); + } else { + // Otherwise, replicate the first number across all three fields. + args = Array.from(args); + args.push(args[0], args[0]); + } + } else if (isArray()) { + // No more global fade state handling needed + return findColor(...args[0]); + } else if (isString()) { + const normalizedString = normalizeColorInput(args[0]); + if (typeof normalizedString === "string") { + args[0] = normalizedString; + } + + // Check for fade syntax first (for direct fade strings) + if (args[0].startsWith("fade:")) { + // Return as fade color array for local processing + return [args[0], 255]; // Default alpha for direct strings + } + + // No more global fade state to reset + resetRainbowCache(); + + // FIRST: Check for color index format like "c0", "c1", "p0", etc. + const indexColor = parseColorIndex(args[0]); + if (indexColor) { + if (indexColor[0] === "rainbow") { + args = rainbow(); // Handle p0 rainbow palette + } else { + args = indexColor; // Handle c0, c1, etc. + } + } else if (args[0] === "erase") { + // TODO: Add better alpha support here... 23.09.11.22.10 + // ^ See `parseColor` in `num`. + // let alpha = 255; + // if (args[1]) alpha = parseFloat(args[1]); + const originalAlpha = arguments[1]; // Save before overwriting args + args = [-1, -1, -1]; + if (originalAlpha !== undefined) args.push(computeAlpha(originalAlpha)); + } else if (args[0] === "rainbow") { + args = rainbow(); // Cycle through roygbiv in a linear sequence. + } else if (args[0] === "zebra") { + args = zebra(); // Cycle through black/white in alternating sequence. + } else { + // See if it's a hex. + const cleanedHex = args[0] + .replace("#", "") + .replace("0x", "") + .toUpperCase(); + if (isHexString(cleanedHex) === true) { + args = hexToRgb(cleanedHex); + } else if (args[0].startsWith("fade:")) { + // Fade operations should have been handled above - this is a fallback + args = [0, 0, 0]; // Fallback to black (stale fade string) + args.push(255); + } else { + // Try CSS color table lookup + const cssColor = cssColors[args[0]]; + if (cssColor) { + args = cssColor; + } else { + // Fallback to random color + args = randIntArr(255, 3); + args.push(255); + if (debug) { + console.warn("⚠️ findColor: Unknown color string", normalizedString, "→ falling back to random color"); + } + } + } + } + // TODO: Add an error message here. 22.08.29.13.03 + } + } else if (args.length === 2) { + // rainbow, alpha or zebra, alpha + if (args[0] === "rainbow") { + args = [...rainbow(), computeAlpha(args[1])]; + } else if (args[0] === "zebra") { + args = [...zebra(), computeAlpha(args[1])]; + } else if (typeof args[0] === "string") { + const normalizedString = normalizeColorInput(args[0]); + if (typeof normalizedString === "string") { + args[0] = normalizedString; + } + // Check for fade syntax with alpha + if (args[0].startsWith("fade:")) { + // Return local fade info instead of setting global state + const fadeString = args[0]; + const alpha = computeAlpha(args[1] || 255); + return [fadeString, alpha]; // Return as fade color array for local processing + } + + // Check for color index format with alpha + const indexColor = parseColorIndex(args[0]); + if (indexColor) { + if (indexColor[0] === "rainbow") { + args = [...rainbow(), computeAlpha(args[1])]; + } else { + args = [...indexColor, computeAlpha(args[1])]; + } + } else { + // Try CSS color table lookup + const cssColor = cssColors[args[0]]; + if (cssColor) { + args = [...cssColor, computeAlpha(args[1])]; + } else { + args = [0, 0, 0, computeAlpha(args[1])]; // Fallback to black + if (debug) { + console.warn("⚠️ findColor: Unknown color string with alpha", normalizedString, "→ defaulting to black"); + } + } + } + } else if (Array.isArray(args[0])) { + const baseColor = [...args[0]]; + if (args.length > 1 && args[1] !== undefined) { + const alphaValue = computeAlpha(args[1]); + if (baseColor.length >= 4) { + baseColor[3] = alphaValue; + } else { + baseColor.push(alphaValue); + } + } + args = baseColor; + } else if (args[0] === undefined) { + // Random color with specified alpha: (undefined, alpha) + const alphaValue = args[1]; // Store the alpha before generating random RGB + args = randIntArr(255, 3); + args.push(computeAlpha(alphaValue)); // Use specified alpha + } else { + // rgb, a + args = [args[0], args[0], args[0], args[1]]; + } + } else if ( + args.length === 0 || + (args.length === 1 && args[0] === undefined) + ) { + args = randIntArr(255, 3); + args.push(255); // Generate random values here, always leave alpha 255. + } + + if (args.length === 3) args = [...args, 255]; // Always be sure we have alpha. + + // Debug: Check for problematic color values and filter out timing expressions + // Exclude valid fade strings from the invalid check + if (args.some(val => val === undefined || + (isNaN(val) && !(typeof val === 'string' && val.startsWith('fade:'))) || + (typeof val === 'string' && val.match(/^\d*\.?\d+s\.\.\.?$/)))) { + + // Check if this contains a fade string that got passed incorrectly + const fadeString = args.find(val => typeof val === 'string' && val.startsWith('fade:')); + if (fadeString) { + // Return fade color array format + const alpha = args.find(val => typeof val === 'number' && !isNaN(val)) || 255; + return [fadeString, alpha]; + } + + // Filter out timing expressions that were incorrectly passed as color arguments + args = args.filter(val => !(typeof val === 'string' && val.match(/^\d*\.?\d+s\.\.\.?$/))); + + // If we filtered out timing expressions, we might be short on arguments + while (args.length < 3) { + args.push(255); // Add default RGB values + } + if (args.length === 3) args.push(255); // Add alpha if missing + + // Clean up any remaining invalid values + args = args.map((val, i) => { + if (val === undefined || isNaN(val) || typeof val === 'string') { + return i === 3 ? 255 : randInt(255); // Alpha defaults to 255, RGB gets random + } + return val; + }); + + // Ensure we have exactly 4 values + args = args.slice(0, 4); + while (args.length < 4) { + args.push(args.length === 3 ? 255 : randInt(255)); + } + } + + // Randomized any undefined or null values across all 4 arguments. + args.forEach((a, i) => { + if (isNaN(args[i])) args[i] = randInt(255); + }); + + return args; +} + +// TODO: How could I convert from 0->1 and 0->255 with no side effects? +// TODO: What's a good way for ink to accept different ranges of alpha? +// 24.08.20.20.12 +// Current solution: +// Any number from 0-><1 will use 0-> alpha. +// - 1 and above will use 0->255. +// - 0 always bottoms out +// Edge case near the 1 is manageable. +function computeAlpha(alpha) { + if (alpha > 0 && alpha < 1) alpha = round(alpha * 255); + return alpha; +} + +// Set global color. +// Internal function to set color without resetting fade mode +function setColor(r, g, b, a = 255) { + c[0] = floor(r); + c[1] = floor(g); + c[2] = floor(b); + c[3] = floor(a); + return c.slice(); +} + +// Send 0 arguements to retrieve the current one. +function color(r, g, b, a = 255) { + if (arguments.length === 0) return c.slice(); + + // Check if this is a fade color string (when called as color("fade:red-lime", 255)) + if (typeof r === 'string' && r.startsWith('fade:')) { + // Store the fade color array directly in c for box() to detect + c[0] = r; // fade string + c[1] = g || 255; // alpha from second argument + c[2] = 0; // unused for fade + c[3] = 255; // default alpha fallback + return c.slice(); + } + + // Check if this is a fade color array format (when called as color(["fade:red-lime", 255])) + if (arguments.length === 1 && Array.isArray(r) && typeof r[0] === 'string' && r[0].startsWith('fade:')) { + // Store the fade color array directly in c for box() to detect + c[0] = r[0]; // fade string + c[1] = r[1] || 255; // alpha + c[2] = 0; // unused for fade + c[3] = 255; // default alpha fallback + return c.slice(); + } + + // No more global fade state to manage - everything is local now! + + return setColor(r, g, b, a); +} + +// Support for secondary color with the ability to clear the color by +// passing null or undefined as the first parameter. +function color2(r, g, b, a = 255) { + if (arguments.length === 0) return c2.slice(); + if (r === undefined || r === null) { + c2 = null; + return; + } + if (!c2) c2 = []; + c2[0] = floor(r); + c2[1] = floor(g); + c2[2] = floor(b); + c2[3] = floor(a); + return c2.slice(); +} + +export { + makeBuffer, + cloneBuffer, + setBuffer, + getBuffer, + changePixels, + depthBuffer, + writeBuffer, + color, + color2, + findColor, + c, // currentColor + pixel, + setForceReplaceMode, + withForceReplaceMode, + parseFadeColor, + parseLocalFade, + getLocalFadeColor, + evaluateFadeDirection, + calculateAngleFadePosition, +}; + +// Helper function to evaluate dynamic fade direction +function evaluateFadeDirection(directionStr) { + // First try to parse as a number + const numericAngle = parseFloat(directionStr); + if (!isNaN(numericAngle)) { + return numericAngle; + } + + // Check for named directions + const validDirections = [ + "horizontal", "horizontal-reverse", + "vertical", "vertical-reverse", + "diagonal", "diagonal-reverse" + ]; + if (validDirections.includes(directionStr)) { + return directionStr; + } + + // Check if it's a string representation of a number (e.g., "30") + const trimmed = directionStr.trim(); + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + const parsed = parseFloat(trimmed); + if (!isNaN(parsed)) { + return parsed; + } + } + + // Try to evaluate as KidLisp expression if we have context + if (currentKidLispContext) { + try { + // Check if it's a JSON-encoded expression + if (directionStr.startsWith('"') || directionStr.startsWith('[')) { + const parsedExpression = JSON.parse(directionStr); + const result = currentKidLispContext.evaluate(parsedExpression, currentKidLispContext.api, currentKidLispContext.env); + const numResult = parseFloat(result); + if (!isNaN(numResult)) { + return numResult; + } + } else { + // Try evaluating as a variable name + const result = currentKidLispContext.evaluate(directionStr, currentKidLispContext.api, currentKidLispContext.env); + const numResult = parseFloat(result); + if (!isNaN(numResult)) { + return numResult; + } + } + } catch (error) { + console.warn("Failed to evaluate fade direction expression:", directionStr, error); + } + } + + // Fallback to horizontal + console.warn("Invalid fade direction, falling back to horizontal:", directionStr); + return "horizontal"; +} + +// Helper function to calculate fade position based on angle (0-360 degrees) +function calculateAngleFadePosition(x, y, minX, minY, maxX, maxY, angle) { + // Normalize angle to 0-360 range + const originalAngle = angle; + angle = ((angle % 360) + 360) % 360; + + // Get area dimensions + const areaWidth = maxX - minX; + const areaHeight = maxY - minY; + + // Calculate position relative to center of the area + const centerX = minX + areaWidth / 2; + const centerY = minY + areaHeight / 2; + const relX = (x - centerX) / (areaWidth / 2); // Normalize to -1 to 1 + const relY = (y - centerY) / (areaHeight / 2); // Normalize to -1 to 1 + + // Convert angle to radians and calculate direction vector + const radians = (angle * Math.PI) / 180; + const dirX = Math.cos(radians); + const dirY = -Math.sin(radians); // Negative because screen Y increases downward + + // Project current position onto the direction vector + const dotProduct = relX * dirX + relY * dirY; + + // Debug logging for angle calculation (only once per frame) + // if (x === minX && y === minY) { + // console.log(`🔄 ANGLE DEBUG: angle=${angle}°, radians=${radians.toFixed(3)}, dirX=${dirX.toFixed(3)}, dirY=${dirY.toFixed(3)}`); + // } + + // Normalize to 0-1 range (dotProduct ranges from -sqrt(2) to sqrt(2)) + const maxDot = Math.sqrt(2); // Maximum possible dot product + const t = (dotProduct + maxDot) / (2 * maxDot); + + // Debug log only for first few pixels to avoid spam + if (x < minX + 3 && y < minY + 3) { + // Angle calculation debug log removed for performance + } + + return Math.max(0, Math.min(1, t)); +} + +// 2. 2D Drawing +function clear() { + // 🚀 OPTIMIZED CLEAR FUNCTION - Up to 10x faster! + + // 🎬 Classic Z-buffer: reset to +Infinity at the start of each frame so + // subsequent 3D form draws correctly occlude each other regardless of the + // order they're submitted in. Matches the width × height of the current + // screen; grows/shrinks on resolution change. + clearDepthBuffer(); + + // Clean up blur buffers to prevent memory accumulation + cleanupBlurBuffers(); + + // Determine the area to clear (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // Don't apply pan translation to mask bounds - mask is already set at current pan position + minX = activeMask.x; + minY = activeMask.y; + maxX = activeMask.x + activeMask.width; + maxY = activeMask.y + activeMask.height; + } + + // 🎨 Check if current color is a fade string + const fadeInfo = parseFadeColor(c); + const isLocalFade = fadeInfo !== null; + + if (isLocalFade) { + // 🚀 OPTIMIZED FADE CLEAR: Pre-compute colors and use fast row/column filling + const colors = fadeInfo.colors; + const hasSpecialColors = colors.some(col => col[0] === "rainbow" || col[0] === "zebra"); + + // For gradients without special colors, use fast path with proper multi-stop interpolation + if (!hasSpecialColors && colors.length >= 2) { + const workingWidth = maxX - minX; + const workingHeight = maxY - minY; + const direction = fadeInfo.direction; + const numColors = colors.length; + const numSegments = numColors - 1; + + // Helper to interpolate between multiple color stops + const interpolateMultiColor = (t) => { + t = Math.max(0, Math.min(1, t)); + const segment = Math.min(Math.floor(t * numSegments), numSegments - 1); + const localT = (t * numSegments) - segment; + const c1 = colors[segment]; + const c2 = colors[segment + 1]; + return [ + (c1[0] + (c2[0] - c1[0]) * localT) | 0, + (c1[1] + (c2[1] - c1[1]) * localT) | 0, + (c1[2] + (c2[2] - c1[2]) * localT) | 0, + ((c1[3] ?? 255) + ((c2[3] ?? 255) - (c1[3] ?? 255)) * localT) | 0 + ]; + }; + + if (direction === "vertical" || direction === "vertical-reverse") { + // 🚀 VERTICAL: Pre-compute one color per row, then use copyWithin + const reverse = direction === "vertical-reverse"; + const maxT = Math.max(1, workingHeight - 1); + + for (let y = minY; y < maxY; y++) { + const t = reverse ? (maxY - 1 - y) / maxT : (y - minY) / maxT; + const [r, g, b, a] = interpolateMultiColor(t); + + const rowStart = (y * width + minX) * 4; + // Set first pixel + pixels[rowStart] = r; + pixels[rowStart + 1] = g; + pixels[rowStart + 2] = b; + pixels[rowStart + 3] = a; + + // Copy across row using doubling strategy + const rowWidth = workingWidth * 4; + for (let copySize = 4; copySize < rowWidth; copySize *= 2) { + const copyEnd = Math.min(copySize * 2, rowWidth); + pixels.copyWithin(rowStart + copySize, rowStart, rowStart + copyEnd - copySize); + } + } + return; + } else if (direction === "horizontal" || direction === "horizontal-reverse") { + // 🚀 HORIZONTAL: Pre-compute color lookup table, then blast rows + const reverse = direction === "horizontal-reverse"; + const maxT = Math.max(1, workingWidth - 1); + + // Pre-compute color for each x position (reused for all rows) + const rowColors = new Uint8ClampedArray(workingWidth * 4); + for (let x = 0; x < workingWidth; x++) { + const t = reverse ? (workingWidth - 1 - x) / maxT : x / maxT; + const [r, g, b, a] = interpolateMultiColor(t); + const idx = x * 4; + rowColors[idx] = r; + rowColors[idx + 1] = g; + rowColors[idx + 2] = b; + rowColors[idx + 3] = a; + } + + // Copy pre-computed row to each row + for (let y = minY; y < maxY; y++) { + const rowStart = (y * width + minX) * 4; + pixels.set(rowColors, rowStart); + } + return; + } else if (direction === "diagonal" || direction === "diagonal-reverse") { + // 🚀 DIAGONAL: Direct pixel write with multi-color interpolation + const reverse = direction === "diagonal-reverse"; + const maxTx = Math.max(1, workingWidth - 1); + const maxTy = Math.max(1, workingHeight - 1); + + for (let y = minY; y < maxY; y++) { + const dy = reverse ? (maxY - 1 - y) / maxTy : (y - minY) / maxTy; + const rowOffset = y * width; + + for (let x = minX; x < maxX; x++) { + const dx = reverse ? (maxX - 1 - x) / maxTx : (x - minX) / maxTx; + const t = (dx + dy) * 0.5; + const [r, g, b, a] = interpolateMultiColor(t); + const i = (rowOffset + x) * 4; + pixels[i] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = a; + } + } + return; + } + // Fall through for angle-based fades + } + // Fall through to slow path for special colors or complex gradients + } + if (isLocalFade) { + // Handle fade colors pixel by pixel (cannot use fast optimization) + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + // Calculate fade position based on direction + let t = 0; + const numericAngle = parseFloat(fadeInfo.direction); + + if (!isNaN(numericAngle)) { + // Use angle-based fade calculation + // if (x === minX && y === minY) { + // console.log(`🎯 GRADIENT ROTATION: Using angle ${numericAngle}° for fade at (${x},${y})`); + // } + t = calculateAngleFadePosition(x, y, minX, minY, maxX - 1, maxY - 1, numericAngle); + } else { + // Use named direction + if (fadeInfo.direction === "horizontal") { + t = (x - minX) / Math.max(1, (maxX - 1) - minX); + } else if (fadeInfo.direction === "horizontal-reverse") { + t = ((maxX - 1) - x) / Math.max(1, (maxX - 1) - minX); + } else if (fadeInfo.direction === "vertical") { + t = (y - minY) / Math.max(1, (maxY - 1) - minY); + } else if (fadeInfo.direction === "vertical-reverse") { + t = ((maxY - 1) - y) / Math.max(1, (maxY - 1) - minY); + } else if (fadeInfo.direction === "diagonal") { + const dx = (x - minX) / Math.max(1, (maxX - 1) - minX); + const dy = (y - minY) / Math.max(1, (maxY - 1) - minY); + t = (dx + dy) / 2; + } else if (fadeInfo.direction === "diagonal-reverse") { + const dx = ((maxX - 1) - x) / Math.max(1, (maxX - 1) - minX); + const dy = ((maxY - 1) - y) / Math.max(1, (maxY - 1) - minY); + t = (dx + dy) / 2; + } else { + // Default to horizontal + t = (x - minX) / Math.max(1, (maxX - 1) - minX); + } + } + + // Get the fade color for this position + const fadeColor = getLocalFadeColor(t, x, y, fadeInfo); + + // Set pixel directly + const i = (x + y * width) * 4; + if (i >= 0 && i < pixels.length - 3) { + pixels[i] = fadeColor[0]; // r + pixels[i + 1] = fadeColor[1]; // g + pixels[i + 2] = fadeColor[2]; // b + pixels[i + 3] = fadeColor[3]; // alpha + } + } + } + } else if (activeMask) { + // Clear only the masked area with solid color (row by row optimization) + const rowWidth = (maxX - minX) * 4; + for (let y = minY; y < maxY; y++) { + const rowStart = (y * width + minX) * 4; + + // Fill first pixel of row + pixels[rowStart] = c[0]; + pixels[rowStart + 1] = c[1]; + pixels[rowStart + 2] = c[2]; + pixels[rowStart + 3] = c[3]; + + // Use copyWithin to duplicate the pattern across the row (much faster!) + if (rowWidth > 4) { + for (let copySize = 4; copySize < rowWidth; copySize *= 2) { + const copyEnd = Math.min(copySize * 2, rowWidth); + pixels.copyWithin(rowStart + copySize, rowStart, rowStart + copyEnd - copySize); + } + } + } + } else { + // 🚀 ULTRA-FAST FULL SCREEN CLEAR using copyWithin doubling strategy + + // Fast path for transparent black (common case) + if (c[0] === 0 && c[1] === 0 && c[2] === 0 && c[3] === 0) { + pixels.fill(0); + return; + } + + // Set the first pixel with the target color + pixels[0] = c[0]; // r + pixels[1] = c[1]; // g + pixels[2] = c[2]; // b + pixels[3] = c[3]; // alpha + + // Use copyWithin to double the filled area repeatedly + // This is much faster than for loops: O(log n) vs O(n) copies + for (let copySize = 4; copySize < pixels.length; copySize *= 2) { + const copyEnd = Math.min(copySize * 2, pixels.length); + pixels.copyWithin(copySize, 0, copyEnd - copySize); + } + } +} + +/** + * Plot a single pixel using (x, y) or {x, y} if only x is given. + * (1) {x, y} + * (2) (x, y) + */ +// Where a pixel is a region in which we draw from the upper left corner. (2D) +function plot(x, y) { + x = floor(x); + y = floor(y); + if (!pixels) return; + // Skip pixels that are offscreen and/or found in the `skips` list. + if (x < 0 || x >= width || y < 0 || y >= height) return; + + // Check pixels in the active mask. + if (activeMask) { + // Don't apply pan translation to mask bounds when checking plot coordinates + // The mask coordinates are already set relative to the current pan position + if ( + y < activeMask.y || + y >= activeMask.y + activeMask.height || + x >= activeMask.x + activeMask.width || + x < activeMask.x + ) + return; + } + + for (const s of skips) if (x === s.x && y === s.y) return; + // Plot our pixel. + const i = (x + y * width) * 4; + + // Additional safety check for array bounds + if (i < 0 || i + 3 >= pixels.length) return; + + const alpha = c[3]; + + // Check if current color is a fade color and resolve it + let plotColor = c; + if (typeof c[0] === 'string' && c[0].startsWith('fade:')) { + // Parse the fade string and get the color at this pixel position + const fadeInfo = parseLocalFade(c[0]); + if (fadeInfo) { + const resolvedColor = getLocalFadeColor(null, x, y, fadeInfo); + plotColor = [...resolvedColor, c[1] || 255]; // Use fade alpha + } else { + // Fallback to transparent if fade parsing fails (stale fade string) + plotColor = [0, 0, 0, 0]; + } + } + + if (forceReplaceMode) { + pixels[i] = plotColor[0] ?? 0; + pixels[i + 1] = plotColor[1] ?? 0; + pixels[i + 2] = plotColor[2] ?? 0; + pixels[i + 3] = plotColor[3] ?? alpha; + return; + } + + // Erasing + if (c[0] === -1 && c[1] === -1 && c[2] === -1) { + const na = 1 - c[3] / 255; + erase(pixels, i, na); + if (eraseTarget) { + const pi = i >> 2; + eraseTarget[pi] = 255 - ((255 - eraseTarget[pi]) * na + 0.5) | 0; + } + } else if (alpha === 255) { + // No alpha blending, just copy. + pixels.set(plotColor, i); + } else if (alpha !== 0) { + blend(pixels, plotColor, 0, i); + } +} + +// Adds a point to the skip list which ignores these points from being drawn +// in `plot`. Passing `null` will clear the skip list. +// TODO: Should the skip list clear automatically on every paint? 2022.02.03.01.16 +// TODO: Leaving skip blank will default to a random skipping algorithm? +// Writing a function will allow you to dynamically skip pixels. +function skip(...args) { + if (args[0] === null) skips.length = 0; + else + args.forEach((p) => { + skips.push({ + x: floor(p.x || p[0]) + panTranslation.x, + y: floor(p.y || p[1]) + panTranslation.y, + }); + }); +} + +// Plots a single pixel within the panned coordinate space. +// Basically a wrapper over plot, which should ultimately be renamed to set? +// Accepts x, y or {x, y} +function point(...args) { + let x, y; + + if (args.length === 1) { + if (args[0].length >= 2) { + // Array + x = args[0][0]; + y = args[0][1]; + } else { + // Object + x = args[0].x; + y = args[0].y; + } + } else if (args.length >= 2) { + // Multiple arguments + x = args[0]; + y = args[1]; + } else { + x = randInt(width); + y = randInt(height); + } + + + + // TODO: Add support for {x, y} single argument. 2022.02.02.20.39 + x += panTranslation.x; + y += panTranslation.y; + // TODO: Eventually add rotation and scale etc. + + plot(x, y); + return [x, y]; +} + +// Run a callback function to shade, then plot an array of pixel coordinates. +// This runs within `pline` and `pixelPerfectPolyline`. +function shadePixels(points, shader, shaderArgs = []) { + points.forEach((p) => { + // TODO: - [] Send current pixel under p? This can be used for cool position + // reading color effects. 23.01.05.01.23 + + // Clip + if (p.x < 0) return; + if (p.x >= width) return; + if (p.y < 0) return; + if (p.y >= height) return; + + shader.position?.(p, ...shaderArgs); // Compute position. + + // Clip again + if (p.x < 0) return; + if (p.x >= width) return; + if (p.y < 0) return; + if (p.y >= height) return; + + p.x = floor(p.x); // Floor position and check bounds. + p.y = floor(p.y); + + const n = p.x + p.y * width; + + if (writeBuffer[n] !== 1) { + writeBuffer[n] = 1; // Remember this point in the frame's writeBuffer. + // 🪄 Pixel Shader + const i = floor(p.x + p.y * width) * 4; // Get current pixel under p. + const pixel = pixels.subarray(i, i + 4); + // TODO: Put p.color into shaderArgs or some other automated thing? + shader.color({ x: p.x, y: p.y }, pixel, c, p.color); // Modify color. + } + }); // Paint each filtered pixel. +} + +function pan(x, y) { + if (typeof x === "object") { + x = x.x; + y = x.y; + } + if (y === undefined) y = x; + panTranslation.x += floor(x); + panTranslation.y += floor(y); +} + +function unpan() { + panTranslation.x = 0; + panTranslation.y = 0; +} + +let savedPanTranslation; + +// Save the local transform. +function savepan() { + savedPanTranslation = { ...panTranslation }; +} + +// Restore it. +function loadpan() { + if (savedPanTranslation) { + panTranslation.x = savedPanTranslation.x; + panTranslation.y = savedPanTranslation.y; + } +} + +// 😷 Mask off pixels. +function mask(box) { + activeMask = box; +} + +// 😄 Unmask pixels. +function unmask() { + activeMask = null; +} + +function copy(destX, destY, srcX, srcY, src, alpha = 1.0) { + destX = floor(destX); + destY = floor(destY); + srcX = floor(srcX); + srcY = floor(srcY); + + // Skip pixels that are offscreen or outside the src buffer. + // Used in `paste`. + if ( + destX < 0 || + destX >= width || + destY < 0 || + destY >= height || + srcX < 0 || + srcX >= src.width || + srcY < 0 || + srcY >= src.height + ) { + return; + } + + const di = (destX + destY * width) * 4; + const si = (srcX + srcY * src.width) * 4; + + blend(pixels, src.pixels, si, di, alpha); +} + +/* +function copyRow(destX, destY, srcX, srcY, src) { + destX = Math.round(destX); + destY = Math.round(destY); + srcX = Math.round(srcX); + srcY = Math.round(srcY); + + const destIndex = (destX + destY * width) * 4; + const srcIndex = (srcX + srcY * src.width) * 4; + const rowLength = src.width * 4 - destX * 4; + + let srcStart = srcIndex; + let srcEnd = srcIndex + src.width * 4; + + const sub = src.pixels.subarray(srcStart, srcEnd); + + pixels.set(sub, destIndex); +} + */ + +// Resize a bitmap to a new with and height, returning a new +// bitmap, using nearest neighbor scaling. +// Bitmaps are {pixels: uint8array, width: int, height, int} +function resize(bitmap, width, height) { + const ratioX = bitmap.width / width; + const ratioY = bitmap.height / height; + const pixels = new Uint8ClampedArray(width * height * 4); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = (y * width + x) * 4; + const srcX = floor(x * ratioX); + const srcY = floor(y * ratioY); + const srcIndex = (srcY * bitmap.width + srcX) * 4; + + pixels[index] = bitmap.pixels[srcIndex]; + pixels[index + 1] = bitmap.pixels[srcIndex + 1]; + pixels[index + 2] = bitmap.pixels[srcIndex + 2]; + pixels[index + 3] = bitmap.pixels[srcIndex + 3]; + } + } + return { pixels, width, height }; +} + +// Helper function to detect recording mode +function isInRecordingMode() { + // Check multiple indicators that we're in recording mode + return typeof globalThis !== 'undefined' && ( + globalThis.tapeRecording === true || + globalThis.recording === true || + (typeof process !== 'undefined' && process.argv && process.argv.includes('--recording')) + ); +} + +// Fast checksum for pixel data (using sampling for performance) +function getPixelChecksum(pixels, width, height, x, y, w, h) { + let checksum = 0; + const sampleRate = Math.max(1, Math.floor(Math.sqrt(w * h) / 32)); // Sample ~32 points + + for (let sy = y; sy < y + h; sy += sampleRate) { + for (let sx = x; sx < x + w; sx += sampleRate) { + if (sx < width && sy < height) { + const i = (sy * width + sx) * 4; + checksum = (checksum * 31 + pixels[i] + pixels[i + 1] + pixels[i + 2] + pixels[i + 3]) >>> 0; + } + } + } + + return checksum; +} + +// Cached contrast lookup table to avoid re-allocation per frame +let cachedContrastLUT = null; +let cachedContrastLevel = null; + +// Adjust the contrast of the pixel buffer +// level: 1.0 = no change, >1.0 = more contrast, <1.0 = less contrast +function contrast(level = 1.0) { + // Early exit if no contrast change needed + if (level === 1.0) return; + + const contrastStart = performance.now(); + + // Determine the area to adjust (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // Apply pan translation to mask bounds + const maskX = activeMask.x + panTranslation.x; + const maskY = activeMask.y + panTranslation.y; + minX = Math.max(0, Math.floor(maskX)); + minY = Math.max(0, Math.floor(maskY)); + maxX = Math.min(width, Math.floor(maskX + activeMask.width)); + maxY = Math.min(height, Math.floor(maskY + activeMask.height)); + } + + // 🚀 TRY GPU CONTRAST FIRST + if (gpuContrastEnabled && gpuSpinAvailable && gpuAllowed("contrast") && gpuSpinModule && pixels && width && height) { + const mask = activeMask ? { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + } : null; + + const result = gpuSpinModule.gpuContrast?.(pixels, width, height, level, mask); + if (result) { + gpuOk("contrast"); + const contrastTime = performance.now() - contrastStart; + graphPerf.track("contrast-gpu", contrastTime); + return; + } + gpuFailed("contrast"); + } + + // 💻 CPU FALLBACK + const cpuStart = performance.now(); + + // Reuse cached LUT if contrast level hasn't changed + if (cachedContrastLevel !== level) { + cachedContrastLUT = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + const normalized = i / 255.0; + const adjusted = ((normalized - 0.5) * level + 0.5) * 255.0; + cachedContrastLUT[i] = Math.max(0, Math.min(255, Math.round(adjusted))); + } + cachedContrastLevel = level; + } + const contrastLUT = cachedContrastLUT; + + // Apply contrast adjustment to each pixel using lookup table + if (!activeMask) { + // Fast path: linear scan for full-screen contrast (no per-pixel index math) + const len = pixels.length; + for (let i = 0; i < len; i += 4) { + if (pixels[i + 3] === 0) continue; + pixels[i] = contrastLUT[pixels[i]]; + pixels[i + 1] = contrastLUT[pixels[i + 1]]; + pixels[i + 2] = contrastLUT[pixels[i + 2]]; + } + } else { + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + const idx = (y * width + x) * 4; + if (pixels[idx + 3] === 0) continue; + pixels[idx] = contrastLUT[pixels[idx]]; + pixels[idx + 1] = contrastLUT[pixels[idx + 1]]; + pixels[idx + 2] = contrastLUT[pixels[idx + 2]]; + } + } + } + + const cpuTime = performance.now() - cpuStart; + graphPerf.track("contrast-cpu", cpuTime); +} + +// Adjust brightness using lookup table optimization +// adjustment: -255 to +255 (0 = no change, positive = brighter, negative = darker) +function brightness(adjustment = 0) { + // Early exit if no adjustment needed + if (adjustment === 0) return; + + const brightnessStart = performance.now(); + + // Clamp adjustment to valid range + adjustment = Math.max(-255, Math.min(255, adjustment)); + + // Determine the area to adjust (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + const maskX = activeMask.x + panTranslation.x; + const maskY = activeMask.y + panTranslation.y; + minX = Math.max(0, Math.floor(maskX)); + minY = Math.max(0, Math.floor(maskY)); + maxX = Math.min(width, Math.floor(maskX + activeMask.width)); + maxY = Math.min(height, Math.floor(maskY + activeMask.height)); + } + + // 🚀 TRY GPU BRIGHTNESS FIRST + if (gpuContrastEnabled && gpuSpinAvailable && gpuAllowed("brightness") && gpuSpinModule && pixels && width && height) { + const mask = activeMask ? { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + } : null; + + const result = gpuSpinModule.gpuBrightness?.(pixels, width, height, adjustment, mask); + if (result) { + gpuOk("brightness"); + const brightnessTime = performance.now() - brightnessStart; + graphPerf.track("brightness-gpu", brightnessTime); + return; + } + gpuFailed("brightness"); + } + + // 💻 CPU FALLBACK + const cpuStart = performance.now(); + + // 🚀 OPTIMIZATION: Pre-calculate brightness lookup table + const brightnessLUT = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + brightnessLUT[i] = Math.max(0, Math.min(255, i + adjustment)); + } + + // Apply brightness adjustment to each pixel using lookup table + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + const idx = (y * width + x) * 4; + + // Skip transparent pixels + if (pixels[idx + 3] === 0) continue; + + // Use lookup table for fast brightness adjustment (RGB channels only) + pixels[idx] = brightnessLUT[pixels[idx]]; // R + pixels[idx + 1] = brightnessLUT[pixels[idx + 1]]; // G + pixels[idx + 2] = brightnessLUT[pixels[idx + 2]]; // B + // Alpha channel stays unchanged + } + } + + const cpuTime = performance.now() - cpuStart; + graphPerf.track("brightness-cpu", cpuTime); +} + +// Invert RGB colors (255 - value) while preserving alpha +function invert() { + const invertStart = performance.now(); + + // Determine the area to adjust (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + const maskX = activeMask.x + panTranslation.x; + const maskY = activeMask.y + panTranslation.y; + minX = Math.max(0, Math.floor(maskX)); + minY = Math.max(0, Math.floor(maskY)); + maxX = Math.min(width, Math.floor(maskX + activeMask.width)); + maxY = Math.min(height, Math.floor(maskY + activeMask.height)); + } + + // 🚀 TRY GPU INVERT FIRST + if (gpuContrastEnabled && gpuSpinAvailable && gpuAllowed("invert") && gpuSpinModule && pixels && width && height) { + const mask = activeMask ? { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + } : null; + + const result = gpuSpinModule.gpuInvert?.(pixels, width, height, mask); + if (result) { + gpuOk("invert"); + const invertTime = performance.now() - invertStart; + graphPerf.track("invert-gpu", invertTime); + return; + } + gpuFailed("invert"); + } + + // 💻 CPU FALLBACK + const cpuStart = performance.now(); + + // Apply invert to each pixel (RGB channels only, preserve alpha) + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + const idx = (y * width + x) * 4; + + // Skip transparent pixels + if (pixels[idx + 3] === 0) continue; + + // Invert RGB channels (255 - value) + pixels[idx] = 255 - pixels[idx]; // R + pixels[idx + 1] = 255 - pixels[idx + 1]; // G + pixels[idx + 2] = 255 - pixels[idx + 2]; // B + // Alpha channel stays unchanged + } + } + + const cpuTime = performance.now() - cpuStart; + graphPerf.track("invert-cpu", cpuTime); +} + +// Copies pixels from a source buffer to the active buffer and returns +// the source buffer. +// TODO: Add dirty rectangle support here... +// - What would the best parameter set be? +// `from` - can either be +// TODO: Replace with more generic algorithm? + +// Notes: +// Scale can also be a transform object: { scale, angle } +// Blit only works with a scale of 1. +// Nearest-neighbor crop+scale blit, sampled straight from the source buffer. +// Kept as its own small function so V8 optimizes the hot loop independently +// of paste()'s many other shapes. Bounds arrive pre-clamped (screen + mask); +// out-of-source samples (crop overhang) skip, matching the zero-fill +// semantics of the copy-based path. Alpha is the high byte (RGBA, LE). +function blitCropScale( + src, srcWidth, srcHeight, cX, cY, + scaleX, scaleY, destX, destY, + dst, dstWidth, startX, startY, endX, endY, +) { + // Column lookup: source x per destination x; -1 marks overhang columns. + const srcXs = new Int32Array(endX - startX); + for (let dx = startX; dx < endX; dx += 1) { + const sx = cX + Math.floor(dx * scaleX); + srcXs[dx - startX] = sx >= 0 && sx < srcWidth ? sx : -1; + } + + const src32 = new Uint32Array(src.buffer, src.byteOffset, src.length >> 2); + const dst32 = new Uint32Array(dst.buffer, dst.byteOffset, dst.length >> 2); + + for (let dy = startY; dy < endY; dy += 1) { + const srcY = cY + Math.floor(dy * scaleY); + if (srcY < 0 || srcY >= srcHeight) continue; + const srcRow = srcY * srcWidth; + let destIdx32 = (destY + dy) * dstWidth + destX + startX; + for (let dx = startX; dx < endX; dx += 1, destIdx32 += 1) { + const sx = srcXs[dx - startX]; + if (sx < 0) continue; + const s = src32[srcRow + sx]; + const sA = s >>> 24; + if (sA === 255) { + dst32[destIdx32] = s; + } else if (sA > 0) { + // Translucent source — blend through the byte views. + const srcIdx = (srcRow + sx) * 4; + const destIdx = destIdx32 * 4; + const alpha = sA + 1; + const invAlpha = 256 - alpha; + dst[destIdx] = (alpha * src[srcIdx] + invAlpha * dst[destIdx]) >> 8; + dst[destIdx + 1] = + (alpha * src[srcIdx + 1] + invAlpha * dst[destIdx + 1]) >> 8; + dst[destIdx + 2] = + (alpha * src[srcIdx + 2] + invAlpha * dst[destIdx + 2]) >> 8; + dst[destIdx + 3] = Math.min(255, dst[destIdx + 3] + sA); + } + } + } +} + +function paste(from, destX = 0, destY = 0, scale = 1, blit = false) { + const pasteStart = performance.now(); // 🚨 PERFORMANCE TRACKING + + if (!from) { + graphPerf.track('paste', 0); + return; + } + + // 🚨 ROBOT FIX: Ensure destination pixels buffer is available + if (!pixels) { + console.warn('🚨 paste: No destination pixels buffer available, skipping paste operation'); + graphPerf.track('paste', 0); + return; + } + + destX += panTranslation.x; + destY += panTranslation.y; + + // 🔧 FIX: Floor coordinates to prevent fractional pixel indices that cause color channel misalignment + destX = Math.floor(destX); + destY = Math.floor(destY); + + // 🚀 OPTIMIZATION: Skip paste operations for tiny scales + if (typeof scale === "number" && Math.abs(scale) < 0.01) { + graphPerf.track('paste', 0); + return; + } + + if (scale !== 1) { + let angle = 0; + let anchor; + let tWidth, tHeight; + let crop; // 🎨 Support crop parameter for ImageBitmap + + if (typeof scale === "object") { + angle = scale.angle; + tWidth = scale.width; + tHeight = scale.height; + anchor = scale.anchor; + crop = scale.crop; // 🎨 Extract crop from transform object + // ^ Pull properties out of the scale object. + + // Only default scale to 1 if explicit dimensions are NOT provided. + // This allows grid() to use tWidth/tHeight for scaling when they are present. + if (scale.scale === undefined && (tWidth === undefined || tHeight === undefined)) { + scale = 1; + } else { + scale = scale.scale; + } + } + + // Fast path for simple integer scaling (no rotation, no custom dimensions). + // It blits from.width × from.height wholesale, so it must decline anything + // cropped — otherwise a cropped sprite silently pastes its entire sheet, + // and only when the sheet happens to fit, which hides the bug. + if ( + !angle && + !crop && + !tWidth && + !tHeight && + !anchor && + typeof scale === "number" && + scale > 0 && + scale === ~~scale && + scale <= 8 + ) { + // Integer scale up to 8x for safety + + // Ultra-fast nearest-neighbor scaling using direct pixel manipulation + const srcWidth = from.width; + const srcHeight = from.height; + const srcPixels = from.pixels; + const scaleInt = ~~scale; // Convert to integer + + // Pre-calculate destination bounds + const destWidth = srcWidth * scaleInt; + const destHeight = srcHeight * scaleInt; + + // Boundary check + if ( + destX >= 0 && + destY >= 0 && + destX + destWidth <= width && + destY + destHeight <= height + ) { + // Direct pixel buffer manipulation for maximum speed. Opaque pixels + // write their scaled block straight into the destination via a Uint32 + // view (one write per dest pixel — no color()/box() call overhead); + // partial-alpha pixels fall back to color()+box()/plot() so blending is + // preserved. Bounds are guaranteed by the outer boundary check above. + // Alignment-guarded; alpha is the high byte (little-endian RGBA). + const fastBlock = + (srcPixels.byteOffset & 3) === 0 && (pixels.byteOffset & 3) === 0; + const src32 = fastBlock + ? new Uint32Array(srcPixels.buffer, srcPixels.byteOffset, srcWidth * srcHeight) + : null; + const dst32 = fastBlock + ? new Uint32Array(pixels.buffer, pixels.byteOffset, width * height) + : null; + for (let srcY = 0; srcY < srcHeight; srcY += 1) { + for (let srcX = 0; srcX < srcWidth; srcX += 1) { + const srcIndex = (srcX + srcY * srcWidth) << 2; // Fast * 4 + if (srcIndex >= srcPixels.length) continue; + + const a = srcPixels[srcIndex + 3]; + if (a === 0) continue; // skip transparent + + const baseDestX = destX + srcX * scaleInt; + const baseDestY = destY + srcY * scaleInt; + + if (a === 255 && fastBlock) { + // Opaque — fill the scaled block with a single 32-bit value. + const s = src32[srcX + srcY * srcWidth]; + for (let dy = 0; dy < scaleInt; dy += 1) { + let di = (baseDestY + dy) * width + baseDestX; + for (let dx = 0; dx < scaleInt; dx += 1) dst32[di + dx] = s; + } + } else { + // Partial alpha (or unaligned) — blend through the pen. + color(srcPixels[srcIndex], srcPixels[srcIndex + 1], srcPixels[srcIndex + 2], a); + if (scaleInt > 1) { + box(baseDestX, baseDestY, scaleInt, scaleInt, "fill"); + } else { + plot(baseDestX, baseDestY); + } + } + } + } + return; // Exit early for fast path + } + } + + // 🎨 Special case: ImageBitmap with crop - convert to buffer with crop + let isImageBitmap = typeof ImageBitmap !== "undefined" && from instanceof ImageBitmap; + if (isImageBitmap && crop) { + // Extract pixels from ImageBitmap and create a cropped buffer object + const pixels = ensureBufferPixels(from); + if (pixels) { + // Convert to simple buffer to share logic with regular buffer path + from = { width: from.width, height: from.height, pixels }; + isImageBitmap = false; // Allow fall-through to next block + } else { + console.warn("🎨 Failed to extract pixels from ImageBitmap for cropping"); + return; // Can't proceed without pixels + } + } + + // 🎨 Special case: Regular buffer with crop + if (!isImageBitmap && crop && from && from.width && from.height) { + const sourcePixels = from.pixels || (from.painting && from.painting.pixels); + + if (sourcePixels) { + // Ensure crop parameters are integers for pixel manipulation + const cX = Math.floor(crop.x); + const cY = Math.floor(crop.y); + const cW = Math.floor(crop.w); + const cH = Math.floor(crop.h); + const srcWidth = from.width; + const srcHeight = from.height; + + // Destination-based scaling for cropped buffers (fixes downscaling gaps) + const targetW = tWidth || (scale && typeof scale === 'number' ? Math.floor(cW * scale) : cW); + const targetH = tHeight || (scale && typeof scale === 'number' ? Math.floor(cH * scale) : cH); + + // 🚀 Fast crop+scale: nearest-neighbor straight from the source + // buffer — no intermediate crop copy. The mask is rectangular, so + // it clamps the loop bounds instead of branching per pixel, and + // fully opaque pixels move as single 32-bit words. This is the + // realtime path for full-screen video zoom (e.g. cap). + if ( + (targetW !== cW || targetH !== cH) && + !angle && + (sourcePixels.byteOffset & 3) === 0 && + (pixels.byteOffset & 3) === 0 + ) { + const scaleX = cW / targetW; + const scaleY = cH / targetH; + + // Loop bounds clamped to the screen... + let startY = Math.max(0, -destY); + let endY = Math.min(targetH, height - destY); + let startX = Math.max(0, -destX); + let endX = Math.min(targetW, width - destX); + + // ...and to the mask rect (pan-translated like every other blit). + if (activeMask) { + const maskX = activeMask.x + panTranslation.x; + const maskY = activeMask.y + panTranslation.y; + startX = Math.max(startX, maskX - destX); + endX = Math.min(endX, maskX + activeMask.width - destX); + startY = Math.max(startY, maskY - destY); + endY = Math.min(endY, maskY + activeMask.height - destY); + } + + if (endX > startX && endY > startY) { + blitCropScale( + sourcePixels, srcWidth, srcHeight, cX, cY, + scaleX, scaleY, destX, destY, + pixels, width, startX, startY, endX, endY, + ); + } + return; + } + + // Create a new buffer for the cropped area + // We must copy the pixels because grid() expects a packed buffer matching width/height + const croppedPixels = new Uint8ClampedArray(cW * cH * 4); + + for (let y = 0; y < cH; y++) { + const srcY = cY + y; + if (srcY >= 0 && srcY < from.height) { + const srcRowStart = (srcY * srcWidth + cX) * 4; + const rowLength = cW * 4; + + // Calculate valid copy range + // We need to handle cases where crop.x is negative or extends beyond width + let startOffset = 0; + let copyLength = rowLength; + let srcStart = srcRowStart; + + if (cX < 0) { + startOffset = -cX * 4; + copyLength -= startOffset; + srcStart = (srcY * srcWidth) * 4; // Start from beginning of row + } + + if (cX + cW > srcWidth) { + copyLength -= (cX + cW - srcWidth) * 4; + } + + if (copyLength > 0 && srcStart < sourcePixels.length) { + const row = sourcePixels.subarray(srcStart, srcStart + copyLength); + // Ensure we don't write out of bounds + if (y * rowLength + startOffset + row.length <= croppedPixels.length) { + croppedPixels.set(row, y * rowLength + startOffset); + } + } + } + } + + const croppedBuffer = { + width: cW, + height: cH, + pixels: croppedPixels + }; + + // Misaligned-buffer fallback for the scaled case (the aligned fast + // path above returned already). + if ((targetW !== cW || targetH !== cH) && !angle) { + const scaleX = cW / targetW; + const scaleY = cH / targetH; + + // Apply pan translation to mask bounds for proper clipping + const maskX = activeMask ? activeMask.x + panTranslation.x : 0; + const maskY = activeMask ? activeMask.y + panTranslation.y : 0; + const maskW = activeMask ? activeMask.width : width; + const maskH = activeMask ? activeMask.height : height; + + // Pre-calculate bounds + const startY = Math.max(0, -destY); + const endY = Math.min(targetH, height - destY); + const startX = Math.max(0, -destX); + const endX = Math.min(targetW, width - destX); + + for (let dy = startY; dy < endY; dy++) { + const srcY = Math.floor(dy * scaleY); + const destRowY = destY + dy; + const destRowStart = destRowY * width * 4; + + for (let dx = startX; dx < endX; dx++) { + const destColX = destX + dx; + + // Check if destination pixel is within mask + if (activeMask) { + if (destColX < maskX || destColX >= maskX + maskW || + destRowY < maskY || destRowY >= maskY + maskH) { + continue; // Skip pixels outside mask + } + } + + const srcX = Math.floor(dx * scaleX); + const srcIdx = (srcY * cW + srcX) * 4; + const destIdx = destRowStart + destColX * 4; + + // Blend pixel + const sA = croppedPixels[srcIdx + 3]; + if (sA > 0) { + const sR = croppedPixels[srcIdx]; + const sG = croppedPixels[srcIdx + 1]; + const sB = croppedPixels[srcIdx + 2]; + + if (sA === 255) { + pixels[destIdx] = sR; + pixels[destIdx + 1] = sG; + pixels[destIdx + 2] = sB; + pixels[destIdx + 3] = 255; + } else { + // Alpha blend + const dA = pixels[destIdx + 3]; + const alpha = sA + 1; + const invAlpha = 256 - alpha; + pixels[destIdx] = (alpha * sR + invAlpha * pixels[destIdx]) >> 8; + pixels[destIdx + 1] = (alpha * sG + invAlpha * pixels[destIdx + 1]) >> 8; + pixels[destIdx + 2] = (alpha * sB + invAlpha * pixels[destIdx + 2]) >> 8; + pixels[destIdx + 3] = Math.min(255, dA + sA); + } + } + } + } + return; + } + + // A per-axis {x, y} scale only means anything to grid, so always hand + // it over — that is how a mirror ({x: -1, y: 1}) gets drawn. + if (tWidth || tHeight || (typeof scale === 'number' && scale !== 1) || + (scale && typeof scale === 'object') || angle) { + grid( + { + box: { x: destX, y: destY, w: cW, h: cH }, + transform: { scale, angle, width: tWidth, height: tHeight, anchor }, + }, + croppedBuffer, + ); + } else { + paste(croppedBuffer, destX, destY, 1, blit); + } + return; + } + } + + // Fall back to general grid-based scaling for complex cases + if (from && from.width && from.height) { + grid( + { + box: { x: destX, y: destY, w: from.width, h: from.height }, + transform: { scale, angle, width: tWidth, height: tHeight, anchor }, + }, + from, + ); + } + + return; + } + + // Note: Angle does not work here yet... 23.04.29.23.38 + + // TODO: See if from has a dirtyBox attribute. + if (from.crop) { + // A cropped copy - optimize with bulk pixel operations + const cropW = from.crop.w; + const cropH = from.crop.h; + const fromPixels = from.painting?.pixels || from.pixels; + + // 🚀 OPTIMIZATION: Bulk pixel copying for cropped regions + if ( + fromPixels && + destX >= 0 && + destY >= 0 && + destX + cropW <= width && + destY + cropH <= height + ) { + const fromWidth = from.painting?.width || from.width; + + // Apply pan translation to mask bounds for proper clipping + const maskX = activeMask ? activeMask.x + panTranslation.x : 0; + const maskY = activeMask ? activeMask.y + panTranslation.y : 0; + const maskW = activeMask ? activeMask.width : width; + const maskH = activeMask ? activeMask.height : height; + + for (let y = 0; y < cropH; y += 1) { + const srcY = from.crop.y + y; + const destRowY = destY + y; + + // Process entire row for better cache performance + for (let x = 0; x < cropW; x += 1) { + const dx = destX + x; + const dy = destRowY; + + // Check if destination pixel is within mask + if (activeMask) { + if (dx < maskX || dx >= maskX + maskW || + dy < maskY || dy >= maskY + maskH) { + continue; // Skip pixels outside mask + } + } + + const srcX = from.crop.x + x; + const srcIdx = (srcY * fromWidth + srcX) * 4; + const destIdx = (destRowY * width + dx) * 4; + + // Skip if source index is out of bounds + if (srcIdx + 3 < fromPixels.length) { + const srcAlpha = fromPixels[srcIdx + 3]; + + if (srcAlpha > 0) { + if (srcAlpha === 255) { + // Opaque source - direct copy + pixels[destIdx] = fromPixels[srcIdx]; + pixels[destIdx + 1] = fromPixels[srcIdx + 1]; + pixels[destIdx + 2] = fromPixels[srcIdx + 2]; + pixels[destIdx + 3] = fromPixels[srcIdx + 3]; + } else { + // Alpha blending + const alpha = srcAlpha + 1; + const invAlpha = 256 - alpha; + pixels[destIdx] = (alpha * fromPixels[srcIdx] + invAlpha * pixels[destIdx]) >> 8; + pixels[destIdx + 1] = (alpha * fromPixels[srcIdx + 1] + invAlpha * pixels[destIdx + 1]) >> 8; + pixels[destIdx + 2] = (alpha * fromPixels[srcIdx + 2] + invAlpha * pixels[destIdx + 2]) >> 8; + pixels[destIdx + 3] = Math.min(255, pixels[destIdx + 3] + srcAlpha); + } + } + } + } + } + } else { + // Fall back to copy() function for edge cases + const sourcePainting = from.painting || from; + if (sourcePainting && sourcePainting.pixels) { + for (let y = 0; y < cropH; y += 1) { + for (let x = 0; x < cropW; x += 1) { + copy( + destX + x, + destY + y, + from.crop.x + x, + from.crop.y + y, + sourcePainting, + ); + } + } + } + } + } else { + // Check to see if we can perform a full copy here, + // with no alpha blending. + if (blit) { + pixels.set(from.pixels, 0); + } else { + // 🚀 MAJOR OPTIMIZATION: Bulk pixel copying for better performance + const srcWidth = from.width; + const srcHeight = from.height; + const srcPixels = from.pixels; + + // Check if we can do ultra-fast bulk copying + if ( + destX >= 0 && + destY >= 0 && + destX + srcWidth <= width && + destY + srcHeight <= height && + srcPixels + ) { + // Ultra-fast bulk pixel blending + // Apply pan translation to mask bounds for proper clipping + const maskX = activeMask ? activeMask.x + panTranslation.x : 0; + const maskY = activeMask ? activeMask.y + panTranslation.y : 0; + const maskW = activeMask ? activeMask.width : width; + const maskH = activeMask ? activeMask.height : height; + + if ( + !activeMask && + (srcPixels.byteOffset & 3) === 0 && + (pixels.byteOffset & 3) === 0 + ) { + // 🚀 No-mask fast path. The outer bounds test already guarantees every + // source/destination index is valid, so the per-pixel mask and bounds + // checks are dropped entirely. Pixels are viewed as Uint32 so an opaque + // pixel copies in a single 32-bit write (and a fully-transparent pixel + // is skipped with one read); only partial-alpha pixels fall back to + // per-channel blending. Alpha is the high byte (little-endian RGBA, as + // canvas ImageData always is); the 32-bit copy itself is endian-safe. + const src32 = new Uint32Array( + srcPixels.buffer, + srcPixels.byteOffset, + srcWidth * srcHeight, + ); + const dst32 = new Uint32Array( + pixels.buffer, + pixels.byteOffset, + width * height, + ); + for (let y = 0; y < srcHeight; y += 1) { + let si = y * srcWidth; + let di = (destY + y) * width + destX; + for (let x = 0; x < srcWidth; x += 1, si += 1, di += 1) { + const s = src32[si]; + const a = s >>> 24; // source alpha + if (a === 255) { + dst32[di] = s; // opaque — single 32-bit write + } else if (a !== 0) { + // partial alpha — per-channel blend + const si4 = si << 2; + const di4 = di << 2; + const alpha = a + 1; + const invAlpha = 256 - alpha; + pixels[di4] = (alpha * srcPixels[si4] + invAlpha * pixels[di4]) >> 8; + pixels[di4 + 1] = (alpha * srcPixels[si4 + 1] + invAlpha * pixels[di4 + 1]) >> 8; + pixels[di4 + 2] = (alpha * srcPixels[si4 + 2] + invAlpha * pixels[di4 + 2]) >> 8; + pixels[di4 + 3] = Math.min(255, pixels[di4 + 3] + a); + } + } + } + } else { + for (let y = 0; y < srcHeight; y += 1) { + const srcRowStart = y * srcWidth * 4; + const destRowStart = ((destY + y) * width + destX) * 4; + + // Process entire row at once for better cache performance + for (let x = 0; x < srcWidth; x += 1) { + const dx = destX + x; + const dy = destY + y; + + // Check if destination pixel is within mask + if (activeMask) { + if (dx < maskX || dx >= maskX + maskW || + dy < maskY || dy >= maskY + maskH) { + continue; // Skip pixels outside mask + } + } + + const srcIdx = srcRowStart + (x * 4); + const destIdx = destRowStart + (x * 4); + + // Bounds check to prevent reading beyond buffer + if (srcIdx + 3 >= srcPixels.length || destIdx + 3 >= pixels.length) continue; + + // Skip transparent pixels + if (srcPixels[srcIdx + 3] > 0) { + // Fast alpha blending without function call overhead + const srcAlpha = srcPixels[srcIdx + 3]; + + if (srcAlpha === 255) { + // Opaque source - direct copy (fastest path) + pixels[destIdx] = srcPixels[srcIdx]; + pixels[destIdx + 1] = srcPixels[srcIdx + 1]; + pixels[destIdx + 2] = srcPixels[srcIdx + 2]; + pixels[destIdx + 3] = srcPixels[srcIdx + 3]; + } else { + // Alpha blending (optimized inline version) + const alpha = srcAlpha + 1; + const invAlpha = 256 - alpha; + pixels[destIdx] = (alpha * srcPixels[srcIdx] + invAlpha * pixels[destIdx]) >> 8; + pixels[destIdx + 1] = (alpha * srcPixels[srcIdx + 1] + invAlpha * pixels[destIdx + 1]) >> 8; + pixels[destIdx + 2] = (alpha * srcPixels[srcIdx + 2] + invAlpha * pixels[destIdx + 2]) >> 8; + pixels[destIdx + 3] = Math.min(255, pixels[destIdx + 3] + srcAlpha); + } + } + } + } + } + } else { + // Fallback to copy() function for edge cases + for (let y = 0; y < srcHeight; y += 1) { + for (let x = 0; x < srcWidth; x += 1) { + const destPixelX = destX + x; + const destPixelY = destY + y; + + // Check if pixel is within the active mask (if one exists) + if (activeMask) { + const maskX = destPixelX - activeMask.x - panTranslation.x; + const maskY = destPixelY - activeMask.y - panTranslation.y; + + if ( + maskX < 0 || + maskY < 0 || + maskX >= activeMask.width || + maskY >= activeMask.height + ) { + continue; // Skip pixels outside the mask + } + } + + copy(destPixelX, destPixelY, x, y, from); + } + } + } + } + } + + const pasteEnd = performance.now(); + graphPerf.track('paste', pasteEnd - pasteStart); +} + +let stampSkipCounter = 0; + +// Similar to `paste` but always centered. +// Fixed: Now properly supports scale and angle parameters while maintaining center positioning +function stamp(from, x, y, scale, angle) { + const stampStart = performance.now(); // 🚨 PERFORMANCE TRACKING + + // Set all untruthy values like null, or undefined to a random value. + if (x == null) x = randIntRange(0, width - 1); + if (y == null) y = randIntRange(0, height - 1); + + // � DEBUG: Log stamp calls + // console.log(`🎯 STAMP called: from=${from?.constructor?.name || typeof from}, scale=${scale}, angle=${angle}`); + + // �🚀 OPTIMIZATION: Skip stamps that are too small to be visible + if (scale !== undefined && Math.abs(scale) < 0.01) { + graphPerf.track('stamp', 0); + return; + } + + // 🚀 PERFORMANCE OPTIMIZATION: Only skip frames when running very slowly + if (graphPerf && graphPerf.lastFPS && graphPerf.lastFPS < 5) { + stampSkipCounter++; + if (stampSkipCounter % 3 !== 0) { // Skip 2 out of 3 frames when FPS < 5 + // console.log(`⏭️ STAMP skipped due to low FPS: ${graphPerf.lastFPS}`); + graphPerf.track('stamp', 0); + return; + } + } else { + stampSkipCounter = 0; // Reset when performance is good + } + + if (scale !== undefined || angle !== undefined) { + // For stamp, we want to center at x,y coordinates + // Account for scaling when calculating the center offset + const effectiveScale = scale !== undefined ? scale : 1; + const scaledWidth = from.width * effectiveScale; + const scaledHeight = from.height * effectiveScale; + + // Adjust the position so that the scaled image is centered at our target x,y + const adjustedX = x - scaledWidth / 2; + const adjustedY = y - scaledHeight / 2; + + const transform = {}; + if (scale !== undefined) transform.scale = scale; + if (angle !== undefined) transform.angle = angle; + + paste(from, adjustedX, adjustedY, transform); + } else { + // Fallback for simple case without scaling/rotation + paste(from, x - from.width / 2, y - from.height / 2); + } + + const stampEnd = performance.now(); + graphPerf.track('stamp', stampEnd - stampStart); +} + +let blendingMode = "blend"; +function blendMode(mode = "blend") { + blendingMode = mode; +} + +// 🧽 Erase target: when set, erase operations write their erase amounts here +// so KidLisp can apply them during compositing to punch through baked layers. +let eraseTarget = null; +let eraseTargetWidth = 0; +function setEraseTarget(target, targetWidth) { + eraseTarget = target; + eraseTargetWidth = targetWidth; +} + +// let stipple = 0; +// A fast alpha blending function that looks into a pixel array. +// Transcribed from C++: https://stackoverflow.com/a/12016968 +function blend(dst, src, si, di, alphaIn = 1) { + //stipple += 1; + //if (stipple < 4) { return; } + //stipple = 0; + + if (forceReplaceMode) { + for (let i = 0; i < 3; i++) { + dst[di + i] = src[si + i]; + } + dst[di + 3] = src[si + 3] * alphaIn; + return; + } + + if (blendingMode === "erase") { + const normalAlpha = 1 - src[si + 3] / 255; + dst[di + 3] *= normalAlpha; + if (dst[di + 3] === 0) { + // If the alpha is zero then wipe the data. + dst[di] = 32; + dst[di + 1] = 32; + dst[di + 2] = 32; + } + // 🧽 Also record erase amount in eraseTarget for cross-layer compositing + if (eraseTarget) { + const pi = di >> 2; // pixel index + eraseTarget[pi] = 255 - ((255 - eraseTarget[pi]) * normalAlpha + 0.5) | 0; + } + return; + } + + if (src[si + 3] === 0) return; // Return early if src is invalid. + + // Just do a straight up copy if we are in "blit" mode. + if (blendingMode === "blit") { + for (let i = 0; i < 4; i++) { + if (i != 3) { + dst[di + i] = src[si + i]; // For R, G, B channels + } else { + dst[di + i] = src[si + i] * alphaIn; // For the Alpha channel + } + } + return; + } + + // A. Blend over transparent pixels. + if (dst[di + 3] < 255 && src[si + 3] > 0) { + const epsilon = 1e-10; // Small number to prevent division by zero + const alphaSrc = (src[si + 3] * alphaIn) / 255; + const alphaDst = dst[di + 3] / 255; + const combinedAlpha = alphaSrc + (1.0 - alphaSrc) * alphaDst; + if (combinedAlpha > epsilon) { + // Check against a very small number instead of 0 + for (let offset = 0; offset < 3; offset++) { + // Iterate over R, G, B channels + dst[di + offset] = + (src[si + offset] * alphaSrc + + dst[di + offset] * (1.0 - alphaSrc) * alphaDst) / + (combinedAlpha + epsilon); // Add epsilon to prevent division instability + } + dst[di + 3] = combinedAlpha * 255; + } + } else { + // B. Blend over opaque pixels. + const alpha = src[si + 3] * alphaIn + 1; + const invAlpha = 256 - alpha; + dst[di] = (alpha * src[si + 0] + invAlpha * dst[di + 0]) >> 8; + dst[di + 1] = (alpha * src[si + 1] + invAlpha * dst[di + 1]) >> 8; + dst[di + 2] = (alpha * src[si + 2] + invAlpha * dst[di + 2]) >> 8; + // Keep destination alpha opaque when blending over opaque background + dst[di + 3] = 255; + } +} + +// Blends the alpha channel only / erases pixels. +function erase(pixels, i, normalizedAlpha) { + pixels[i + 3] *= normalizedAlpha; +} + +// 🚀 FAST LINE: Inline Bresenham without array allocation or per-pixel function calls +// Used when no fade, no gradient, no skip list - just pure pixel writing +function lineFast(x0, y0, x1, y1) { + if (!pixels) return; + // Guard: resolve stale non-numeric color to prevent artifacts + if (typeof c[0] === 'string' && !c[0].startsWith('fade:')) { + setColor(0, 0, 0, c[3] || 255); + } + + // Floor coordinates + x0 = floor(x0) || 0; + y0 = floor(y0) || 0; + x1 = floor(x1) || 0; + y1 = floor(y1) || 0; + + // Get mask bounds (or full screen) + let minX = 0, minY = 0, maxX = width, maxY = height; + if (activeMask) { + minX = activeMask.x; + minY = activeMask.y; + maxX = activeMask.x + activeMask.width; + maxY = activeMask.y + activeMask.height; + } + + // Pre-cache color values + const r = c[0], g = c[1], b = c[2], a = c[3]; + const isErase = r === -1 && g === -1 && b === -1; + const normalAlpha = isErase ? 1 - a / 255 : 0; + const isOpaque = a === 255 && !isErase; + const hasAlpha = a !== 0 && a !== 255 && !isErase; + + // Bresenham's algorithm inlined + const dx = abs(x1 - x0); + const dy = -abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx + dy; + + while (true) { + // Bounds check + if (x0 >= minX && x0 < maxX && y0 >= minY && y0 < maxY) { + const i = (x0 + y0 * width) * 4; + + if (forceReplaceMode || isOpaque) { + pixels[i] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = isOpaque ? 255 : a; + } else if (isErase) { + pixels[i + 3] *= normalAlpha; + if (eraseTarget) { + const pi = i >> 2; + eraseTarget[pi] = 255 - ((255 - eraseTarget[pi]) * normalAlpha + 0.5) | 0; + } + } else if (hasAlpha) { + // Inline alpha blend + const invA = 255 - a; + pixels[i] = (r * a + pixels[i] * invA) >> 8; + pixels[i + 1] = (g * a + pixels[i + 1] * invA) >> 8; + pixels[i + 2] = (b * a + pixels[i + 2] * invA) >> 8; + pixels[i + 3] = a + (pixels[i + 3] * invA >> 8); + } + } + + if (x0 === x1 && y0 === y1) break; + + const e2 = 2 * err; + if (e2 >= dy) { + err += dy; + x0 += sx; + } + if (e2 <= dx) { + err += dx; + y0 += sy; + } + } +} + +// Draws a horizontal line. (Should be very fast...) +function lineh(x0, x1, y) { + x0 = floor(x0); + x1 = floor(x1); + y = floor(y); + if (!pixels) return; + // Guard: resolve stale non-numeric color to prevent artifacts + if (typeof c[0] === 'string' && !c[0].startsWith('fade:')) { + setColor(0, 0, 0, c[3] || 255); + } + if (y < 0 || y >= height || x0 >= width || x1 < 0) return; // Check if the entire line is outside the mask + if (activeMask) { + // Don't apply pan translation to mask bounds - mask is already set at current pan position + if ( + y < activeMask.y || + y >= activeMask.y + activeMask.height || + x1 < activeMask.x || + x0 >= activeMask.x + activeMask.width + ) + return; + } + + // Clamp to screen bounds first + x0 = clamp(x0, 0, width - 1); + x1 = clamp(x1, 0, width - 1); // Then clamp to mask bounds if mask is active + if (activeMask) { + // Don't apply pan translation to mask bounds - mask is already set at current pan position + x0 = clamp(x0, activeMask.x, activeMask.x + activeMask.width - 1); + x1 = clamp(x1, activeMask.x, activeMask.x + activeMask.width - 1); + } + + const firstIndex = (x0 + y * width) * 4; + const secondIndex = (x1 + y * width) * 4; + + let startIndex, endIndex; + + // Sort indices so we can always draw from left to right. + if (firstIndex > secondIndex) { + startIndex = secondIndex; + endIndex = firstIndex; + } else { + startIndex = firstIndex; + endIndex = secondIndex; + } + + if (forceReplaceMode) { + for (let i = startIndex; i <= endIndex; i += 4) { + pixels[i] = c[0]; + pixels[i + 1] = c[1]; + pixels[i + 2] = c[2]; + pixels[i + 3] = c[3]; + } + return; + } + + // Erasing. + if (blendMode !== "erase" && c[0] === -1 && c[1] === -1 && c[2] === -1) { + const normalAlpha = 1 - c[3] / 255; + for (let i = startIndex; i <= endIndex; i += 4) { + erase(pixels, i, normalAlpha); + if (eraseTarget) { + const pi = i >> 2; + eraseTarget[pi] = 255 - ((255 - eraseTarget[pi]) * normalAlpha + 0.5) | 0; + } + } + // No alpha. + } else if (c[3] === 255) { + for (let i = startIndex; i <= endIndex; i += 4) { + pixels[i] = c[0]; + pixels[i + 1] = c[1]; + pixels[i + 2] = c[2]; + pixels[i + 3] = 255; + } + // Alpha blending. + } else if (c[3] !== 0) { + for (let i = startIndex; i <= endIndex; i += 4) { + blend(pixels, c, 0, i); + } + } +} + +// Draws a line +// (2) p1, p2: pairs of {x, y} or [x, y] +// (4) x0, y0, x1, y1 +// TODO: Automatically use lineh if possible. 22.10.05.18.27 +// Fast thick line with rounded endcaps via scanline capsule fill. +// For each row, finds the x-range where distance to the line segment <= r, +// then fills with a single lineh call. No per-pixel overhead. +function lineThick(x0, y0, x1, y1, r) { + x0 += panTranslation.x; + y0 += panTranslation.y; + x1 += panTranslation.x; + y1 += panTranslation.y; + + const r2 = r * r + r; // +r for pixel-grid rounding to match circle stamp look. + const ldx = x1 - x0, ldy = y1 - y0; + const len2 = ldx * ldx + ldy * ldy; + + // Degenerate: single point → filled circle scanlines. + if (len2 === 0) { + for (let dy = -r; dy <= r; dy++) { + const h = floor(sqrt(max(0, r2 - dy * dy))); + lineh(x0 - h, x0 + h, y0 + dy); + } + return; + } + + const invLen2 = 1 / len2; + const minY = floor(min(y0, y1) - r); + const maxY = floor(max(y0, y1) + r); + + for (let y = minY; y <= maxY; y++) { + // Find x-range where capsule distance ≤ r at this row. + // Sample a few x candidates and bracket the span: + // 1. Endcap circles at A and B. + // 2. Body: closest point on segment projected to this row. + + let lo = Infinity, hi = -Infinity; + + // Endcap circles. + const da = y - y0, db = y - y1; + const da2 = da * da, db2 = db * db; + if (da2 <= r2) { + const h = sqrt(r2 - da2); + lo = x0 - h; hi = x0 + h; + } + if (db2 <= r2) { + const h = sqrt(r2 - db2); + if (x1 - h < lo) lo = x1 - h; + if (x1 + h > hi) hi = x1 + h; + } + + // Body: project (x, y) onto segment. The closest point on the segment + // for a given y lies at t = ((x-x0)*ldx + (y-y0)*ldy) / len2. + // For the body, the perpendicular distance from (x,y) to the line is + // |cross| / len where cross = (y-y0)*ldx - (x-x0)*ldy. + // Solving cross^2 / len2 <= r2 for x: + // (C - x*ldy)^2 <= r2*len2 where C = (y-y0)*ldx + x0*ldy + // x*ldy ∈ [C - D, C + D] where D = sqrt(r2*len2) + // Then clamp resulting x to the segment's t ∈ [0,1] range. + const C = (y - y0) * ldx + x0 * ldy; + const D = sqrt(r2 * len2); + + if (abs(ldy) > 0.0001) { + // Solve for x from the perpendicular constraint. + let bxLo = (C - D) / ldy, bxHi = (C + D) / ldy; + if (bxLo > bxHi) { const tmp = bxLo; bxLo = bxHi; bxHi = tmp; } + + // Clamp to segment t ∈ [0,1]: x = x0 + t*ldx, so t = (x-x0)/ldx if ldx≠0. + // Instead, clamp by checking t at each body-x bound. + const tLo = ((bxLo - x0) * ldx + (y - y0) * ldy) * invLen2; + const tHi = ((bxHi - x0) * ldx + (y - y0) * ldy) * invLen2; + const tMin = max(0, min(tLo, tHi)); + const tMax = min(1, max(tLo, tHi)); + + if (tMin <= tMax) { + // At the clamped t range, compute x and offset by the + // perpendicular half-width at this row. + const xA = x0 + tMin * ldx, yA = y0 + tMin * ldy; + const xB = x0 + tMax * ldx, yB = y0 + tMax * ldy; + // Half-width from circle at each point. + const ha2 = r2 - (y - yA) * (y - yA); + const hb2 = r2 - (y - yB) * (y - yB); + if (ha2 >= 0) { + const h = sqrt(ha2); + if (xA - h < lo) lo = xA - h; + if (xA + h > hi) hi = xA + h; + } + if (hb2 >= 0) { + const h = sqrt(hb2); + if (xB - h < lo) lo = xB - h; + if (xB + h > hi) hi = xB + h; + } + } + } + + if (lo <= hi) { + lineh(floor(lo), floor(hi), y); + } + } +} + +function line() { + let x0, y0, x1, y1, thickness; + if (arguments.length === 1) { + // Safely access properties on the first argument + const arg0 = arguments[0]; + if (arg0 && typeof arg0 === "object") { + x0 = arg0.x0; // Assume an object { x0, y0, x1, y1 } + y0 = arg0.y0; + x1 = arg0.x1; + y1 = arg0.y1; + thickness = arg0.thickness; + } + } else if (arguments.length >= 4) { + x0 = arguments[0]; // Set all `undefined` or `null` values to 0. + y0 = arguments[1]; + x1 = arguments[2]; + y1 = arguments[3]; + thickness = arguments[4]; + } else if (arguments.length === 2) { + const arg0 = arguments[0]; + const arg1 = arguments[1]; + if (Array.isArray(arg0) && Array.isArray(arg1)) { + // assume [x, y], [x, y] + x0 = arg0[0]; + y0 = arg0[1]; + x1 = arg1[0]; + y1 = arg1[1]; + } else if ( + arg0 && + typeof arg0 === "object" && + arg1 && + typeof arg1 === "object" + ) { + // assume {x, y}, {x, y} + x0 = arg0.x; + y0 = arg0.y; + x1 = arg1.x; + y1 = arg1.y; + } + } else { + // if (debug) { + // console.warn( + // "Line did not use the correct number of arguments:", + // arguments + // ); + // } + } + + // Set all untruthy values like null, or undefined to a random value. + if (x0 == null) x0 = randIntRange(0, width); + if (y0 == null) y0 = randIntRange(0, height); + if (x1 == null) x1 = randIntRange(0, width); + if (y1 == null) y1 = randIntRange(0, height); + + // Handle NaN values the same way as null - use random values + if (isNaN(x0)) x0 = randIntRange(0, width); + if (isNaN(y0)) y0 = randIntRange(0, height); + if (isNaN(x1)) x1 = randIntRange(0, width); + if (isNaN(y1)) y1 = randIntRange(0, height); + + // Thick line with rounded endcaps. + if (thickness > 1) { + const radius = floor((thickness - 1) / 2); + lineThick(floor(x0), floor(y0), floor(x1), floor(y1), radius); + const out = [floor(x0), floor(y0), floor(x1), floor(y1)]; + twoDCommands?.push(["line", ...out]); + return out; + } + + // console.log("Line in:", x0, y0, x1, y1); + + // Add any panTranslations. + x0 += panTranslation.x; + y0 += panTranslation.y; + x1 += panTranslation.x; + y1 += panTranslation.y; + + // Lerp from primary to secondary color as needed. + const cachedInk = c.slice(0); + const localFadeInfo = parseFadeColor(c); + const usingLocalFade = !!localFadeInfo; + + // 🚀 FAST PATH: Use lineFast when no special effects are needed + // This is much faster than bresenham + forEach + plot + if (!fadeMode && !usingLocalFade && !c2 && skips.length === 0) { + // Check if horizontal line (even faster) + if (y0 === y1) { + lineh(x0, x1, y0); + } else { + lineFast(x0, y0, x1, y1); + } + } else if (y0 === y1 && !c2 && !fadeMode) { + // Horizontal line without gradient/fade + lineh(x0, x1, y0); + } else { + // SLOW PATH: Need per-pixel color calculations + const lineLength = sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2); + + bresenham(x0, y0, x1, y1).forEach((p, index, points) => { + if (fadeMode) { + // Calculate fade position based on line progress + const t = lineLength > 0 ? sqrt((p.x - x0) ** 2 + (p.y - y0) ** 2) / lineLength : 0; + const fadeColor = getFadeColor(t, p.x, p.y); + setColor(...fadeColor); + plot(p.x, p.y); + } else if (usingLocalFade) { + const totalSteps = Math.max(1, points.length - 1); + const t = totalSteps === 0 ? 0 : index / totalSteps; + const fadeColor = getLocalFadeColor(t, p.x, p.y, localFadeInfo); + setColor(...fadeColor); + plot(p.x, p.y); + } else if (c2) { + const step = sqrt(p.x * p.x + p.y * p.y) / 255; // Gradient step. + color(...shiftRGB(c, c2, step)); + plot(p.x, p.y); + } else { + plot(p.x, p.y); + } + }); + if (usingLocalFade) { + if (typeof cachedInk[0] === "string" && cachedInk[0].startsWith("fade:")) { + color(cachedInk); + } else { + setColor(...cachedInk); + } + } else if (c2 || fadeMode) { + setColor(...cachedInk); + } + } + + const out = [x0, y0, x1, y1]; + // console.log("Line out:", out); + twoDCommands?.push(["line", ...out]); // Forward this call to the GPU. + return out; +} + +// Takes an array of pixel coords `{x, y}` and filters out L shapes. +// Note: It checks the previous, current, and next pixel and requires a minimum +// set of 3 before it removes anything. +// Draws a regular `line` if only two pixels are provided. +// Transcribed from: https://rickyhan.com/jekyll/update/2018/11/22/pixel-art-algorithm-pixel-perfect.html +function pixelPerfectPolyline(points, shader) { + if (points.length < 2) return; // Require 2 or more points. + const pixels = []; + //points.reverse(); // Draw from front to back. + + let last = points[0]; + + // TODO: Draw everything front to back similar to pline? 23.02.01.13.12 + //for (let i = coords.length - 2; i >= 0; i -= 1) { + + points.forEach((cur) => { + // Clip offscreen segments. + const xMin = min(last.x, cur.x); + const xMax = max(last.x, cur.x); + const yMin = min(last.y, cur.y); + const yMax = max(last.y, cur.y); + if (xMin >= width || xMax < 0 || yMin >= height || yMax < 0) { + last = cur; + return; + } + + //if (cur.color === "rainbow") color(rainbow()); + + const rb = last.color === "rainbow"; + + // Compute bresen pixels, filtering out duplicates. + bresenham(last.x, last.y, cur.x, cur.y).forEach((p, i) => { + if (i > 0 || pixels.length < 2) { + pixels.push({ ...p, color: rb ? rainbow() : last.color }); // Add color for each pixel. + } + }); + last = cur; + }); + + const filtered = []; + let c = 0; + + while (c < pixels.length) { + if ( + c > 0 && + c + 1 < pixels.length && + (pixels[c - 1].x === pixels[c].x || pixels[c - 1].y === pixels[c].y) && // check left and up + (pixels[c + 1].x === pixels[c].x || pixels[c + 1].y === pixels[c].y) && // check right and down + pixels[c - 1].x !== pixels[c + 1].x && // check left and right of prev and next + pixels[c - 1].y !== pixels[c + 1].y + ) { + // check top and bottom of prev and next + c += 1; + } + filtered.push(pixels[c]); + c += 1; + } + + if (shader) { + shadePixels(filtered, shader); + } else { + filtered.forEach((p) => point(p)); + } +} + +// Draws a line from a point at a distance... with an angle in degrees. +function lineAngle(x1, y1, dist, degrees) { + const x2 = x1 + dist * cos(radians(degrees)); + const y2 = y1 + dist * sin(radians(degrees)); + return line(x1, y1, x2, y2); +} + +// Take two vertices and plot a 3d line with depth information. +function line3d(a, b, lineColor, gradients) { + const aColor = a.color; + const bColor = b.color; + + a = a.transform(screenMatrix); + b = b.transform(screenMatrix); + + const aZ = a.pos[Z]; + const bZ = a.pos[Z]; + + a = a.perspectiveDivide(); + b = b.perspectiveDivide(); + + const [x0, y0, z0] = a.pos; + const [x1, y1, z1] = b.pos; + const points = bresenham(x0, y0, x1, y1); + + const saveColor = c.slice(); + + color(...lineColor); // Set color from lineColor or default to global color. + // color(randInt(255), randInt(255), randInt(255)); // Random colors. + + points.forEach((p, i) => { + const progress = i / points.length; + //const z = lerp(z0, z1, progress); + + // Lerp from a.color to b.color if gradients are enabled. + if (gradients && aColor && bColor) { + const R = lerp(aColor[0], bColor[0], progress); + const G = lerp(aColor[1], bColor[1], progress); + const B = lerp(aColor[2], bColor[2], progress); + color(R, G, B); + } + + const newZ = lerp(aZ, bZ, progress); + const stretchedDepth = 1 - min(1, abs(newZ) / 2.5); + + /* + color( + c[0] * stretchedDepth, + c[1] * stretchedDepth, + c[2] * stretchedDepth, + ); // Set color from lineColor or default to global color. + */ + + const depth = newZ * -1; + if (depthBuffer) { + const index = p.x + p.y * width; + if (depth > depthBuffer[index]) { + } else { + depthBuffer[index] = depth; + plot(p.x, p.y); + } + } else { + plot(p.x, p.y); + } + }); + + color(...saveColor); // Restore color. +} + +// TODO: Implement a nice filled option here... +// Something pixel-perfect with the outline... like a flood? +function circle(x0, y0, radius, filled = false, thickness, precision) { + if (filled || thickness > 1) { + oval(x0, y0, radius, radius, filled, thickness, precision); + return; + } + + // Circle + x0 = floor(x0); + y0 = floor(y0); + radius = floor(radius); + + let f = 1 - radius, + ddF_x = 0, + ddF_y = -2 * radius, + x = 0, + y = radius; + + point(x0, y0 + radius); + point(x0, y0 - radius); + point(x0 + radius, y0); + point(x0 - radius, y0); + + while (x < y) { + if (f >= 0) { + y -= 1; + ddF_y += 2; + f += ddF_y; + } + x += 1; + ddF_x += 2; + f += ddF_x + 1; + point(x0 + x, y0 + y); + point(x0 - x, y0 + y); + point(x0 + x, y0 - y); + point(x0 - x, y0 - y); + point(x0 + y, y0 + x); + point(x0 - y, y0 + x); + point(x0 + y, y0 - x); + point(x0 - y, y0 - x); + } +} + +// Draw a filled pie slice / wedge (for pie charts, progress indicators, etc.) +// startAngle and endAngle are in radians, 0 = right, PI/2 = down, etc. +function pie(x0, y0, radius, startAngle, endAngle, precision = 3) { + const points = [[x0, y0]]; // Start from center + + // Generate arc points + const angleSpan = endAngle - startAngle; + const steps = max(floor(abs(angleSpan) * radius / precision), 8); + + for (let i = 0; i <= steps; i++) { + const angle = startAngle + (angleSpan * i) / steps; + const x = x0 + radius * cos(angle); + const y = y0 + radius * sin(angle); + points.push([x, y]); + } + + // Close back to center + points.push([x0, y0]); + + shape({ points, filled: true }); +} + +// TODO: Generate sampled points around a circle then use +function oval( + x0, + y0, + radiusX, + radiusY, + filled = false, + thickness = 1, + precision, +) { + const points = generateEllipsePoints(x0, y0, radiusX, radiusY, precision); + shape({ points, filled, thickness }); +} + +// TODO: How can I relate precision to the circumference and avoid this little +// sharp point on the top? +// TODO: This function can have radians etc. / be more terse. 23.02.13.19.42 +function generateEllipsePoints(x0, y0, radiusX, radiusY, precision = 20) { + const points = []; + for (let i = 0; i < 360; i += precision) { + const angle = radians(i); + const x = x0 + radiusX * cos(angle); + const y = y0 + radiusY * sin(angle); + points.push([x, y]); + } + return points; +} + +// Draws a series of 1px lines without overlapping / overdrawing points. +// TODO: Add closed mode? Example: ink(handPalette.w).poly([...w, w[0]]); +function poly(coords) { + let last = coords[0]; + for (let i = 1; i < coords.length; i++) { + const cur = coords[i]; + line(last, cur); + last = cur; + } +} + +// Rasterize an Npx thick poly line with rounded end-caps. +/* TODO + - [😇] Clip coords to inside of the screen. + + Later + - [] Perhaps if thickness === 1 then this can be combined with + `pixelPerfectPolyline` ? + - [] Render a third triangle from mid point to last point to next quad + point? + - [] Rounded half-circle endcaps. + - [] Filled circle if coords.length === 1. + - [] Texture / special FX. + + Done + - [x] Triangle rasterization of segment. + - [x] Optimize performance. + - [x] Run the profiler. +*/ +function pline(coords, thickness, shader) { + // 1️⃣ Generate geometry. + if (coords.length < 2) return; // Require at least two coordinates. + + let points = [], // Raster grids. + lines = [], + tris = []; + + // 🎴 Draw everything from front to back! + + let last = coords[coords.length - 1]; // Keep the last drawn element. + + let lpar, ldir; // Store last parallel points / prepopulate if supplied. + + for (let i = coords.length - 2; i >= 0; i -= 1) { + const cur = coords[i]; + + // 1. Two points on both sides of last and cur via line dir. + const lp = [last.x || last[0], last.y || last[1]], + cp = [cur.x || cur[0], cur.y || cur[1]]; // Convert last and cur to vec2. + + const dir = vec2.normalize([], vec2.subtract([], cp, lp)); // Line direction. + if (!ldir) ldir = dir; + + const rot = vec2.rotate([], dir, [0, 0], PI / 2); // Rotated by 90d + + const offset1 = vec2.scale([], rot, thickness / 2); // Parallel offsets. + const offset2 = vec2.scale([], rot, -thickness / 2); + + let c1, c2; + if (!lpar) { + c1 = vec2.add([], lp, offset1); // Compute both sets of points. + c2 = vec2.add([], lp, offset2); + lpar = [c1, c2]; + } else { + [c1, c2] = lpar; + } + + [c1, c2, lp, cp].forEach((v) => vec2.floor(v, v)); // Floor everything. + + // 2. Plotting + + const dot = vec2.dot(dir, ldir); // Get the dot product of cur and last dir. + + let trig; // Triangle geometry. + + if (dot > 0) { + // Vertex order for forward direction. + trig = [ + [c1, c2, lp], + [c2, lp, cp], + ]; + lines.push(...bresenham(...c1, ...lp)); // Par line 1 + lines.push(...bresenham(...c2, ...cp)); // Par line 2 + } else { + // Vertex order for backward direction. + trig = [ + [c2, lp, c1], + [c1, cp, lp], + ]; + lines.push(...bresenham(...c2, ...lp)); // Par line 1 + lines.push(...bresenham(...c1, ...cp)); // Par line 2 + } + + // Partial outside clipping. + // const clippedTris = trig.filter((triangle) => + // triangle.every( + // (v) => v[0] >= 0 && v[0] < width && v[1] >= 0 && v[1] < height + // ) + // ); + + // Full outside clipping. + // Clip triangles that are *fully* offscreen. + // Take into account panning here... + const clippedTris = trig.filter((triangle) => + triangle.some((v) => { + const tv = v.slice(); + tv[0] += panTranslation.x; + tv[1] += panTranslation.y; + return tv[0] >= 0 && tv[0] < width && tv[1] >= 0 && tv[1] < height; + }), + ); + + clippedTris.forEach((tri) => fillTri(tri, tris)); // Fill quad. + //trig.forEach((t) => fillTri(t, tris)); // Fill quad. + + ldir = dir; + lines.push(...bresenham(...lp, ...cp)); + + if (i === coords.length - 2) + points.push({ x: c1[0], y: c1[1] }, { x: c2[0], y: c2[1] }); + + points.push({ x: lp[0], y: lp[1] }, { x: cp[0], y: cp[1] }); // Add points. + + // Paint each triangle. + if (cur.color === "rainbow") color(...rainbow()); + else if (cur.color) color(...cur.color); + + if (shader) { + const progress = 1 - i / (coords.length - 2); + shadePixels(tris, shader, [progress]); + } else { + tris.forEach((p) => point(p)); + } + + tris.length = 0; + + last = cur; // Update the last point. + lpar = [c1, c2]; // ... and last parallel points. + } + + // 3️⃣ Painting + + // color(0, 255, 0); // Paint vertex points. + // points.forEach((p) => point(p)); + + // color(0, 0, 255); // Paint wireframe lines. + // lines.forEach((p) => point(p)); + + return lpar; +} + +// 🔺 Rasterizes a tri. See also: https://www.youtube.com/watch?v=SbB5taqJsS4 +function fillTri(v3, pix) { + const scan = []; // Scan buffer of left->right down Y axis. + const [min, mid, max] = v3.sort((va, vb) => va[1] - vb[1]); // Sort Y. + + const v1 = [max[0] - min[0], max[1] - min[1]], // Area / cross-product. + v2 = [mid[0] - min[0], mid[1] - min[1]], + cp = v1[0] * v2[1] - v2[0] * v1[1]; + + const handedness = cp > 0 ? 1 : cp < 0 ? 0 : -1; // O->L, 1->R, -1->️🚫 + + // Scan across each edge. + [ + [min, max, 0 + handedness], // Min and Max for each edge with handedness. + [min, mid, 1 - handedness], + [mid, max, 1 - handedness], + ].forEach(function scanEdge(v) { + const yStart = v[0][Y], + yEnd = v[1][Y], + xStart = v[0][X], + xEnd = v[1][X]; + + const yDist = yEnd - yStart; + const xDist = xEnd - xStart; + if (yDist <= 0) return; // Don't convert if we have no columns. + const xStep = xDist / yDist; + + let x = xStart; + for (let y = yStart; y < yEnd; y += 1) { + scan[y * 2 + v[2]] = floor(x); + x += xStep; + } + }); + + // Rasterize all by scanning horizontally. + for (let y = min[Y]; y < max[Y]; y += 1) { + for (let x = scan[y * 2]; x < scan[y * 2 + 1]; x += 1) { + pix.push({ x, y }); + } + } +} + +/** + * Bresenham's Line Algorithm + * @description - Returns an array of integer points that make up an aliased line from {x0, y0} to {x1, y1}. + * - This function is "abstract" and does not render anything... but outputs points. + * @param x0 + * @param y0 + * @param x1 + * @param y1 + * @returns {*[]} + */ +function bresenham(x0, y0, x1, y1) { + const points = []; + + // Make sure everything is floor'd. + x0 = floor(x0) || 0; + y0 = floor(y0) || 0; + x1 = floor(x1) || 0; + y1 = floor(y1) || 0; + // Bresenham's Algorithm + const dx = abs(x1 - x0); + const dy = -abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx + dy; + + while (true) { + points.push({ x: x0, y: y0 }); + + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 >= dy) { + err += dy; + x0 += sx; + } + if (e2 <= dx) { + err += dx; + y0 += sy; + } + } + return points; +} + +// Takes in x, y, width and height and draws an +// outline, inline (1px) or filled rectangle, optionally +// from the center by inputting eg: "inline*center" in mode. +const BOX_CENTER = "center"; +// Parameters +// (1) box (any object with {x, y, w, h} properties) (1) +// (2) box, mode (2) +// (3) x, y, size (3) +// (4) x, y, w, h (4) +// (4) x, y, size, mode:string (4) +// (5) x, y, w, h, mode (5) +// TODO: ♾️ If width or height (size) is infinity then make sure the box covers +// the entire pixel buffer width. 25.05.11.16.30 +function box() { + const boxStart = performance.now(); // 🚨 PERFORMANCE TRACKING + + let x, + y, + w, + h, + mode = "fill"; + + // Apply the TODO: If any argument is NaN then just make it 'undefined' + for (let i = 0; i < arguments.length; i++) { + if (Number.isNaN(arguments[i])) { + arguments[i] = undefined; + } + } + + if (arguments.length === 1 || arguments.length === 2) { + // Array(4) + if (Array.isArray(arguments[0])) { + x = arguments[0][0]; + y = arguments[0][1]; + w = arguments[0][2]; + h = arguments[0][3]; + } else if (arguments[0]) { + // Object {x, y, w, h} + // Note: Also works with anything that has width and height properties. + + // Allow long names. + if (!isNaN(arguments[0].width)) arguments[0].w = arguments[0].width; + if (!isNaN(arguments[0].height)) arguments[0].h = arguments[0].height; + + x = arguments[0].x || 0; + y = arguments[0].y || 0; + w = arguments[0].w || 0; + + if (isNaN(arguments[0].h)) { + h = w; + } else { + h = arguments[0].h; + } + + if (x === undefined || y === undefined || w === undefined) { + return console.error( + "Could not make a box {x,y,w,h} from:", + arguments[0], + ); + } + } + if (arguments[1]) mode = arguments[1]; + } else if (arguments.length === 3) { + // x, y, size + x = arguments[0]; + y = arguments[1]; + w = arguments[2]; + h = arguments[2]; + } else if (arguments.length === 4) { + if (typeof arguments[3] === "number") { + // x, y, w, h + x = arguments[0]; + y = arguments[1]; + w = arguments[2]; + h = arguments[3]; + } else { + // x, y, size, mode + x = arguments[0]; + y = arguments[1]; + w = arguments[2]; + h = arguments[2]; + mode = arguments[3]; + } + } else if (arguments.length === 5) { + // x, y, w, h, mode + x = arguments[0]; + y = arguments[1]; + w = arguments[2]; + h = arguments[3]; + mode = arguments[4]; + } else { + //return console.error("Invalid box call."); + } + + if (w === 0 || h === 0 || isNaN(w) || isNaN(h)) return; // Silently quit if the box has no volume. + if (w === Infinity) w = width; + if (h === Infinity) h = height; + + // Random parameters if undefined, null, or NaN. + if (nonvalue(x)) x = randInt(width); + if (nonvalue(y)) y = randInt(height); + if (nonvalue(w)) w = randInt(width); + if (nonvalue(h)) h = randInt(height); + + // Abs / normalize the parameters. + if (mode === undefined || mode === "") mode = "fill"; + + // Check for "Center" at the end of mode. + if (mode.endsWith(BOX_CENTER)) { + x -= w / 2; + y -= h / 2; + mode = mode.slice(0, -BOX_CENTER.length - 1); // Clear through separator: * + } + + x = floor(x); + y = floor(y); + w = signedCeil(w); + h = signedCeil(h); + + ({ x, y, w, h } = Box.from([x, y, w, h]).abs); + + // Apply any global pan translations. + // x += panTranslation.x; // Note: Already processed in `line`. + // y += panTranslation.y; + + const thickness = parseInt(mode.split(":")[1]) || 1; + mode = mode.split(":")[0]; + + if (mode === "outline" || mode === "out") { + if (thickness === 1) { + line(x - 1, y - 1, x + w, y - 1); // Top + line(x - 1, y + h, x + w, y + h); // Bottom + line(x - 1, y, x - 1, y + h - 1); // Left + line(x + w, y, x + w, y + h - 1); // Right + } else { + const leftX = x - thickness; + const topY = y - thickness; + const rightX = x + w + thickness; + const bottomY = y + h + thickness; + const boxHeight = h + thickness * 2; + const boxWidth = w + thickness * 2; + box(leftX, topY, boxWidth, thickness); // Top box + box(leftX, bottomY - thickness, boxWidth, thickness); // Bottom box + box(leftX, topY + thickness, thickness, boxHeight - thickness * 2); // Left box + box( + rightX - thickness, + topY + thickness, + thickness, + boxHeight - thickness * 2, + ); // Right box + } + } else if (mode === "inline" || mode === "in") { + if (thickness === 1) { + line(x, y, x + w - 1, y); // Top + line(x, y + h - 1, x + w - 1, y + h - 1); // Bottom + line(x, y + 1, x, y + h - 2); // Left + line(x + w - 1, y + 1, x + w - 1, y + h - 2); // Right + } else { + if (thickness * 2 <= w && thickness * 2 <= h) { + box(x, y, w, thickness); // Top + box(x, y + h - thickness, w, thickness); // Bottom + box(x, y + thickness, thickness, h - thickness * 2); // Left + box(x + w - thickness, y + thickness, thickness, h - thickness * 2); // Right + } else { + box(x, y, w, h); // Just fill the box if the inline is too big. + } + } + } else if (mode === "fill" || mode === "") { + // TODO: The boxes could be cropped to always fit inside the screen here. + w -= 1; + + // Guard: resolve stale fade strings in c to prevent rendering artifacts + if (typeof c[0] === 'string' && !c[0].startsWith('fade:')) { + setColor(0, 0, 0, c[3] || 255); + } + + const cachedInk = c.slice(0); + + // 🎨 NEW LOCAL FADE HANDLING: Check if current color is a fade + const fadeInfo = parseFadeColor(c); + const isLocalFade = fadeInfo !== null; + + // console.log("📦 BOX DEBUG: Local fade detection", { + // "current color c": c, + // "c[0]": c[0], + // "typeof c[0]": typeof c[0], + // "c[0] startsWith fade": typeof c[0] === 'string' ? c[0].startsWith('fade:') : 'not string', + // "fadeInfo": fadeInfo, + // "isLocalFade": isLocalFade, + // "global fadeMode": fadeMode, + // "global fadeColors": fadeColors.length > 0 ? fadeColors : "empty" + // }); + + if (mathSign(height) === 1) { + for (let row = 0; row < h; row += 1) { + if (isLocalFade) { + // Use local fade info instead of global fade state + const numericAngle = parseFloat(fadeInfo.direction); + + if (!isNaN(numericAngle)) { + // For numeric angles, we need to calculate fade per pixel + // Draw line pixel by pixel with angle-based fade + for (let col = 0; col <= w; col++) { + const pixelX = x + col; + const pixelY = y + row; + + // Use the same calculateAngleFadePosition function as clear() + const t = calculateAngleFadePosition(pixelX, pixelY, x, y, x + w, y + h - 1, numericAngle); + + // Calculate fade color locally without setting global state + const fadeColor = getLocalFadeColor(t, pixelX, pixelY, fadeInfo); + setColor(...fadeColor); + plot(pixelX, pixelY); + } + } else { + // Use named direction + let t; + if (fadeInfo.direction === "vertical") { + t = h <= 1 ? 0 : row / (h - 1); + } else if (fadeInfo.direction === "vertical-reverse") { + t = h <= 1 ? 0 : 1 - (row / (h - 1)); // Reverse the direction + } else if (fadeInfo.direction === "horizontal") { + t = 0; // Will be calculated per pixel in line drawing + } else if (fadeInfo.direction === "horizontal-reverse") { + t = 0; // Will be calculated per pixel in line drawing + } else { // diagonal, etc. + t = h <= 1 ? 0 : row / (h - 1); + } + + if (fadeInfo.direction.startsWith("horizontal")) { + // For horizontal fades, draw pixels individually with local fade colors + const lineY = y + row; + for (let px = x; px < x + w; px++) { + // Prevent division by zero for single-pixel width + const horizontalT = w <= 1 ? 0 : ( + fadeInfo.direction === "horizontal-reverse" + ? 1 - ((px - x) / (w - 1)) + : (px - x) / (w - 1) + ); + const fadeColor = getLocalFadeColor(horizontalT, px, lineY, fadeInfo); + setColor(...fadeColor); + plot(px, lineY); + } + } else { + // For vertical fades, set color once per row + const fadeColor = getLocalFadeColor(t, x, y + row, fadeInfo); + setColor(...fadeColor); + line(x, y + row, x + w, y + row); + } + } + } else { + line(x, y + row, x + w, y + row); + } + } + } else { + for (let row = 0; row > h; row -= 1) { + if (isLocalFade) { + // Use local fade info instead of global fade state + const numericAngle = parseFloat(fadeInfo.direction); + + if (!isNaN(numericAngle)) { + // For numeric angles, we need to calculate fade per pixel + // Draw line pixel by pixel with angle-based fade + for (let col = 0; col <= w; col++) { + const pixelX = x + col; + const pixelY = y + row; + + // Use the same calculateAngleFadePosition function as clear() + const t = calculateAngleFadePosition(pixelX, pixelY, x, y, x + w, y + h - 1, numericAngle); + + const fadeColor = getLocalFadeColor(t, pixelX, pixelY, fadeInfo); + setColor(...fadeColor); + plot(pixelX, pixelY); + } + } else { + // Use named direction + let t; + if (fadeInfo.direction === "vertical") { + t = Math.abs(row) / Math.abs(h - 1); + } else if (fadeInfo.direction === "vertical-reverse") { + t = 1 - (Math.abs(row) / Math.abs(h - 1)); + } else if (fadeInfo.direction === "horizontal") { + t = 0; // Will be calculated per pixel in line drawing + } else if (fadeInfo.direction === "horizontal-reverse") { + t = 0; // Will be calculated per pixel in line drawing + } else { // diagonal, etc. + t = Math.abs(row) / Math.abs(h - 1); + } + + if (fadeInfo.direction.startsWith("horizontal")) { + // For horizontal fades, we need to temporarily set up fade state for line() + // TODO: Update line() function to also use local fade handling + const savedFadeMode = fadeMode; + const savedFadeColors = fadeColors.slice(); + const savedFadeDirection = fadeDirection; + const savedFadeNeat = fadeNeat; + + fadeMode = true; + fadeColors = fadeInfo.colors; + fadeDirection = fadeInfo.direction; + fadeNeat = fadeInfo.neat; + + line(x, y + row, x + w, y + row); + + // Restore previous fade state + fadeMode = savedFadeMode; + fadeColors = savedFadeColors; + fadeDirection = savedFadeDirection; + fadeNeat = savedFadeNeat; + } else { + // For vertical fades, set color once per row + const fadeColor = getLocalFadeColor(t, x, y + row, fadeInfo); + setColor(...fadeColor); + line(x, y + row, x + w, y + row); + } + } + } else { + line(x, y + row, x + w, y + row); + } + } + } + } + + graphPerf.track('box', performance.now() - boxStart); // 🚨 PERFORMANCE TRACKING +} + +// Rasterizes an outlined or filled shape from pairs of points. +// Accepts: (x, y, x, y, x, y, ...) +// Or: ([[x, y], [x, y], ...]) +// Or: { points: ", filled: false } +function shape() { + const shapeStart = performance.now(); // 🚨 PERFORMANCE TRACKING + + let argPoints; + let points; + let filled = true; + let thickness = 1; // Used if unfilled. + + if (arguments.length === 1 && !Array.isArray(arguments[0])) { + // Assume an object {points, filled} + argPoints = arguments[0].points; + filled = arguments[0].filled; + thickness = arguments[0].thickness || thickness; + } else { + argPoints = arguments[0]; + } + + if (!Array.isArray(argPoints[0])) { + // Assume a flat list of coordinates to convert into pairs. + points = []; + + for (let p = 0; p < argPoints.length; p += 2) { + points.push([argPoints[p], argPoints[p + 1]]); + } + } else { + points = argPoints; // Assume array of pairs was passed. + } + + if (filled) { + // 🚀 MASSIVE OPTIMIZATION: Use fast fillTri for triangles instead of slow fillShape + if (points.length === 3) { + // Triangle - use the optimized fillTri function + const triPixels = []; + fillTri(points, triPixels); + triPixels.forEach((p) => point(p)); + } else { + // For other polygons, fall back to the general fillShape algorithm + fillShape(points); + } + } else { + // Make lines from 1->2->3->...->1 + if (thickness === 1) { + points.forEach((p, i) => { + const lastPoint = i < points.length - 1 ? points[i + 1] : points[0]; + line(...p, ...lastPoint); + }); + } else { + // Thicker outline using pline. + points.push(points[0]); + pline( + points.map((p) => p2.of(...p)), + thickness, + ); + } + } + + graphPerf.track('shape', performance.now() - shapeStart); // 🚨 PERFORMANCE TRACKING +} + +// Note: This may not be very fast. It was written by ChatGPT. 23.02.11.12.51 +// Note: The most efficient algorithm in C I could find as an alternate: +// https://gist.github.com/ideasman42/983738130f754ef58ffa66bcdbbab892 +// Fills a shape using the scan line algorithm, hitting every pixel via `point`. +function fillShape(points) { + // Find the minimum and maximum y-coordinates of the points + let minY = Infinity; + let maxY = -Infinity; + for (let i = 0; i < points.length; i++) { + minY = min(minY, points[i][1]); + maxY = max(maxY, points[i][1]); + } + + // For each scan line from minY to maxY + for (let y = minY; y <= maxY; y++) { + // Find the intersections of the scan line with the edges of the polygon + let intersections = []; + for (let i = 0; i < points.length; i++) { + let p1 = points[i]; + let p2 = points[(i + 1) % points.length]; + if ((p1[1] <= y && y < p2[1]) || (p2[1] <= y && y < p1[1])) { + let x = ((y - p1[1]) * (p2[0] - p1[0])) / (p2[1] - p1[1]) + p1[0]; + intersections.push(x); + } + } + + // Sort the intersections in increasing order + intersections.sort((a, b) => a - b); + + // Fill in the pixels between each pair of intersections + for (let i = 0; i < intersections.length; i += 2) { + let x1 = floor(intersections[i]); + let x2 = ceil(intersections[i + 1]); + for (let x = x1; x < x2; x++) { + point(x, y); + } + } + } +} + +// Draws a filled triangle with gradient colors using barycentric interpolation +// Each vertex has its own color that smoothly blends across the triangle +// Draws a filled triangle with vertex colors using barycentric interpolation for smooth gradients +// Now includes Z-depth testing for proper occlusion +function drawGradientTriangle(x1, y1, color1, z1, x2, y2, color2, z2, x3, y3, color3, z3) { + // Skip triangles with any vertex having extreme coordinates (clipping failure). + // After screen-space clipping all vertices should be within [0, width]×[0, height]; + // anything >2× screen is a clipping artifact (e.g. a near-plane shoot-off). + const maxCoord = max(width, height) * 2; + if (abs(x1) > maxCoord || abs(y1) > maxCoord || + abs(x2) > maxCoord || abs(y2) > maxCoord || + abs(x3) > maxCoord || abs(y3) > maxCoord) { + return; // Skip triangles with extreme coordinates + } + + // Calculate bounding box + const minX = floor(min(x1, x2, x3)); + const maxX = ceil(max(x1, x2, x3)); + const minY = floor(min(y1, y2, y3)); + const maxY = ceil(max(y1, y2, y3)); + + // Clip to screen bounds + const screenMinX = max(0, minX); + const screenMaxX = min(width, maxX); + const screenMinY = max(0, minY); + const screenMaxY = min(height, maxY); + + // Skip if triangle is completely off-screen + if (screenMinX >= screenMaxX || screenMinY >= screenMaxY) return; + + // Triangle area (signed). We use edge functions (half-plane tests) so the + // inside test and barycentric weights share work: u = E1/areaABC where + // E1(x,y) = areaPBC(x,y). Each E is linear in (x, y), so we can increment + // it by constant deltas per pixel instead of recomputing. + const areaABC = (x2 - x1) * (y3 - y1) - (x3 - x1) * (y2 - y1); + if (abs(areaABC) < 0.5) return; // degenerate + + const invArea = 1 / areaABC; + + // Partial derivatives of each edge function (constant per step). + const dE1dx = y2 - y3, dE1dy = x3 - x2; // E1 = areaPBC → weight V1 + const dE2dx = y3 - y1, dE2dy = x1 - x3; // E2 = areaPCA → weight V2 + const dE3dx = y1 - y2, dE3dy = x2 - x1; // E3 = areaPAB → weight V3 + + // Evaluate each edge function at the top-left pixel centre (minX+0.5, minY+0.5). + const px0 = screenMinX + 0.5; + const py0 = screenMinY + 0.5; + let e1Row = (x2 - px0) * (y3 - py0) - (x3 - px0) * (y2 - py0); + let e2Row = (x3 - px0) * (y1 - py0) - (x1 - px0) * (y3 - py0); + let e3Row = (x1 - px0) * (y2 - py0) - (x2 - px0) * (y1 - py0); + + // Colour components are passed in pre-scaled 0-255 space (the caller in + // Form.graph does the `* 255` multiply). We just alias them for readability. + const c1r = color1[0], c1g = color1[1], c1b = color1[2], c1a = color1[3]; + const c2r = color2[0], c2g = color2[1], c2b = color2[2], c2a = color2[3]; + const c3r = color3[0], c3g = color3[1], c3b = color3[2], c3a = color3[3]; + // If every vertex is fully opaque we can skip the per-pixel alpha path. + const allOpaque = c1a >= 254 && c2a >= 254 && c3a >= 254; + + const hasDepth = depthBuffer.length > 0; + const pix = pixels; + const bufW = width; + let pixCount = 0; + + for (let y = screenMinY; y < screenMaxY; y++) { + let e1 = e1Row, e2 = e2Row, e3 = e3Row; + let rowPix = (screenMinX + y * bufW) * 4; + let rowDep = screenMinX + y * bufW; + let wasInside = false; + for (let x = screenMinX; x < screenMaxX; x++) { + // Inside test via barycentric: all three weights ≥ 0. Division by + // invArea flips sign correctly for either winding order. + const u = e1 * invArea; + const v = e2 * invArea; + const w = 1 - u - v; + if (u >= 0 && v >= 0 && w >= 0) { + wasInside = true; + const depth = u * z1 + v * z2 + w * z3; + if (!hasDepth || depth <= depthBuffer[rowDep]) { + const r = (u * c1r + v * c2r + w * c3r) | 0; + const g = (u * c1g + v * c2g + w * c3g) | 0; + const b = (u * c1b + v * c2b + w * c3b) | 0; + if (allOpaque) { + pix[rowPix] = r; + pix[rowPix + 1] = g; + pix[rowPix + 2] = b; + pix[rowPix + 3] = 255; + } else { + const a = (u * c1a + v * c2a + w * c3a) | 0; + if (a >= 254) { + pix[rowPix] = r; + pix[rowPix + 1] = g; + pix[rowPix + 2] = b; + pix[rowPix + 3] = 255; + } else if (a > 0) { + // Integer alpha blend: src*a/256 + dst*(256-a)/256. + const aa = a + 1; + const inv = 256 - a; + pix[rowPix] = (r * aa + pix[rowPix] * inv) >> 8; + pix[rowPix + 1] = (g * aa + pix[rowPix + 1] * inv) >> 8; + pix[rowPix + 2] = (b * aa + pix[rowPix + 2] * inv) >> 8; + const dstA = pix[rowPix + 3]; + pix[rowPix + 3] = dstA > a ? dstA : a; + } + } + if (hasDepth) depthBuffer[rowDep] = depth; + pixCount++; + } + } else if (wasInside) { + // Convex triangle — once we've entered & left on this scanline we + // won't re-enter. Skip to next row. + break; + } + e1 += dE1dx; e2 += dE2dx; e3 += dE3dx; + rowPix += 4; + rowDep += 1; + } + e1Row += dE1dy; + e2Row += dE2dy; + e3Row += dE3dy; + } + renderStats.pixelsDrawn += pixCount; +} + +// Helper: Subdivide a triangle if it's too large in screen space +// Returns an array of smaller triangles (or the original if small enough) +function subdivideTriangleIfNeeded(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, maxScreenSize = 300, depth = 0) { + // Prevent infinite recursion - max depth of 4 (16 triangles max) + if (depth > 4) { + return [[x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3]]; + } + + const minX = min(x1, x2, x3); + const maxX = max(x1, x2, x3); + const minY = min(y1, y2, y3); + const maxY = max(y1, y2, y3); + + const screenWidth = maxX - minX; + const screenHeight = maxY - minY; + + // Clamp to actual screen bounds to get real on-screen size + const clampedMinX = max(0, minX); + const clampedMaxX = min(width, maxX); + const clampedMinY = max(0, minY); + const clampedMaxY = min(height, maxY); + + const onScreenWidth = clampedMaxX - clampedMinX; + const onScreenHeight = clampedMaxY - clampedMinY; + + // Use ON-SCREEN dimensions for subdivision decision + // If the on-screen portion is small enough, don't subdivide + if (onScreenWidth <= maxScreenSize && onScreenHeight <= maxScreenSize) { + return [[x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3]]; + } + + // Also skip if triangle is completely off-screen + if (onScreenWidth <= 0 || onScreenHeight <= 0) { + return [[x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3]]; + } + + // Subdivide into 4 smaller triangles by finding midpoints + const mx12 = (x1 + x2) / 2, my12 = (y1 + y2) / 2; + const mx23 = (x2 + x3) / 2, my23 = (y2 + y3) / 2; + const mx31 = (x3 + x1) / 2, my31 = (y3 + y1) / 2; + + const mz12 = (z1 + z2) / 2, mz23 = (z2 + z3) / 2, mz31 = (z3 + z1) / 2; + + // Perspective-correct UV interpolation + // Interpolate u/w, v/w, and 1/w, then recover u and v + const invW1 = 1.0 / w1, invW2 = 1.0 / w2, invW3 = 1.0 / w3; + const u1w = uv1[0] * invW1, v1w = uv1[1] * invW1; + const u2w = uv2[0] * invW2, v2w = uv2[1] * invW2; + const u3w = uv3[0] * invW3, v3w = uv3[1] * invW3; + + // Interpolate 1/w linearly in screen space (1/w is linear after perspective divide) + const mInvW12 = (invW1 + invW2) / 2, mInvW23 = (invW2 + invW3) / 2, mInvW31 = (invW3 + invW1) / 2; + const mw12 = 1 / mInvW12, mw23 = 1 / mInvW23, mw31 = 1 / mInvW31; + + // Interpolate u/w and v/w linearly in screen space + const mu12w = (u1w + u2w) / 2, mv12w = (v1w + v2w) / 2; + const mu23w = (u2w + u3w) / 2, mv23w = (v2w + v3w) / 2; + const mu31w = (u3w + u1w) / 2, mv31w = (v3w + v1w) / 2; + + // Recover UV coordinates: u = (u/w) / (1/w) + const muv12 = [mu12w / mInvW12, mv12w / mInvW12]; + const muv23 = [mu23w / mInvW23, mv23w / mInvW23]; + const muv31 = [mu31w / mInvW31, mv31w / mInvW31]; + + // Recursively subdivide the 4 sub-triangles + const result = []; + result.push(...subdivideTriangleIfNeeded(x1, y1, uv1, z1, w1, mx12, my12, muv12, mz12, mw12, mx31, my31, muv31, mz31, mw31, maxScreenSize, depth + 1)); + result.push(...subdivideTriangleIfNeeded(mx12, my12, muv12, mz12, mw12, x2, y2, uv2, z2, w2, mx23, my23, muv23, mz23, mw23, maxScreenSize, depth + 1)); + result.push(...subdivideTriangleIfNeeded(mx31, my31, muv31, mz31, mw31, mx23, my23, muv23, mz23, mw23, x3, y3, uv3, z3, w3, maxScreenSize, depth + 1)); + result.push(...subdivideTriangleIfNeeded(mx12, my12, muv12, mz12, mw12, mx23, my23, muv23, mz23, mw23, mx31, my31, muv31, mz31, mw31, maxScreenSize, depth + 1)); + + return result; +} + +// Draws a filled triangle with texture mapping using barycentric interpolation +// Each vertex has UV coordinates that map to a texture buffer +// Uses perspective-correct interpolation with clip-space W values (not NDC Z!) +// Also interpolates Z values for depth buffering +function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, texture, alphaMultiplier = 1.0) { + // Safety check: avoid division by zero or near-zero W values. This is stricter + // than "finite math only" but much looser than the clip-space near plane so + // near-camera floor geometry can survive clipping without exploding. + if (w1 < MIN_PERSPECTIVE_W || w2 < MIN_PERSPECTIVE_W || w3 < MIN_PERSPECTIVE_W) { + return; // Skip triangles with invalid depth + } + + // Skip triangles with any vertex having extreme coordinates (clipping failure). + const maxCoord = max(width, height) * 2; + if (abs(x1) > maxCoord || abs(y1) > maxCoord || + abs(x2) > maxCoord || abs(y2) > maxCoord || + abs(x3) > maxCoord || abs(y3) > maxCoord) { + return; // Skip triangles with extreme coordinates + } + + // Calculate bounding box + const minX = floor(min(x1, x2, x3)); + const maxX = ceil(max(x1, x2, x3)); + const minY = floor(min(y1, y2, y3)); + const maxY = ceil(max(y1, y2, y3)); + + // Clip to screen bounds + const screenMinX = max(0, minX); + const screenMaxX = min(width, maxX); + const screenMinY = max(0, minY); + const screenMaxY = min(height, maxY); + + // Skip if triangle is completely off-screen + if (screenMinX >= screenMaxX || screenMinY >= screenMaxY) return; + + // Store bounding box info for debug visualization + if (showBoundingBoxes) { + boundingBoxes.push({ + minX, maxX, minY, maxY, + screenMinX, screenMaxX, screenMinY, screenMaxY, + rejected: false + }); + } + + // Calculate area of the whole triangle (for barycentric coordinates) + const areaABC = (x2 - x1) * (y3 - y1) - (x3 - x1) * (y2 - y1); + + // Avoid degenerate triangles + if (abs(areaABC) < 0.5) return; + + const texWidth = texture.width; + const texHeight = texture.height; + + // Perspective-correct texture mapping: interpolate 1/w, u/w, v/w + // Use clip-space W (not NDC Z) for proper perspective correction! + const invW1 = 1 / w1; + const invW2 = 1 / w2; + const invW3 = 1 / w3; + + const u1OverW = uv1[0] * invW1; + const v1OverW = uv1[1] * invW1; + const u2OverW = uv2[0] * invW2; + const v2OverW = uv2[1] * invW2; + const u3OverW = uv3[0] * invW3; + const v3OverW = uv3[1] * invW3; + + // Iterate over bounding box and check if each pixel is inside the triangle + for (let y = screenMinY; y < screenMaxY; y++) { + for (let x = screenMinX; x < screenMaxX; x++) { + // Calculate barycentric coordinates using signed area method + const areaPBC = (x2 - x) * (y3 - y) - (x3 - x) * (y2 - y); + const areaPCA = (x3 - x) * (y1 - y) - (x1 - x) * (y3 - y); + + const u = areaPBC / areaABC; // Weight for vertex 1 + const v = areaPCA / areaABC; // Weight for vertex 2 + const w = 1 - u - v; // Weight for vertex 3 + + // Check if point is inside triangle + if (u >= 0 && v >= 0 && w >= 0) { + // Interpolate NDC Z for depth testing. Convention: lower Z = nearer + // (post-perspective-divide Z is in [-1, +1]; near plane → -1). + const depth = u * z1 + v * z2 + w * z3; + + // Z-test: skip this pixel if a nearer fragment is already there. + const bufferIndex = x + y * width; + if (depthBuffer.length > 0) { + if (depth > depthBuffer[bufferIndex]) { + continue; // existing pixel is closer to the camera + } + } + + renderStats.pixelsDrawn++; + + // Interpolate 1/w and uv/w using barycentric weights + const interpInvW = u * invW1 + v * invW2 + w * invW3; + + // Guard against very small interpInvW which causes UV explosion + if (interpInvW < 0.0001) continue; + + const interpUOverW = u * u1OverW + v * u2OverW + w * u3OverW; + const interpVOverW = u * v1OverW + v * v2OverW + w * v3OverW; + + // Recover perspective-correct UV by dividing by interpolated 1/w + const texU = interpUOverW / interpInvW; + const texV = interpVOverW / interpInvW; + + // Skip pixels with extreme UV values (clipping artifacts) + if (abs(texU) > 1000 || abs(texV) > 1000) continue; + + // Sample texture at UV coordinates (with wrapping) + // Use proper modulo for negative values + let texX = ((floor(texU * texWidth) % texWidth) + texWidth) % texWidth; + let texY = ((floor(texV * texHeight) % texHeight) + texHeight) % texHeight; + + // Get pixel from texture buffer + const pixelIndex = (texY * texWidth + texX) * 4; + const r = texture.pixels[pixelIndex]; + const g = texture.pixels[pixelIndex + 1]; + const b = texture.pixels[pixelIndex + 2]; + const a = floor(texture.pixels[pixelIndex + 3] * alphaMultiplier); + + // Update depth buffer and draw pixel + if (depthBuffer.length > 0) { + depthBuffer[bufferIndex] = depth; + } + + // Draw the textured pixel with alpha fade + color(r, g, b, a); + point(x, y); + } + } + } +} + +// Draws a triangle from three points, with optional fill mode. +// Usage: tri(x1, y1, x2, y2, x3, y3, mode = "fill") +// mode can be "fill", "outline", "out", or "inline", "in" +function tri() { + let x1, y1, x2, y2, x3, y3; + let mode = "fill"; + + // Apply the TODO: If any argument is NaN then just make it 'undefined' + for (let i = 0; i < arguments.length; i++) { + if (Number.isNaN(arguments[i])) { + arguments[i] = undefined; + } + } + + if (arguments.length === 6) { + // tri(x1, y1, x2, y2, x3, y3) + x1 = arguments[0]; + y1 = arguments[1]; + x2 = arguments[2]; + y2 = arguments[3]; + x3 = arguments[4]; + y3 = arguments[5]; + } else if (arguments.length === 7) { + // tri(x1, y1, x2, y2, x3, y3, mode) + x1 = arguments[0]; + y1 = arguments[1]; + x2 = arguments[2]; + y2 = arguments[3]; + x3 = arguments[4]; + y3 = arguments[5]; + mode = arguments[6]; + } else if (arguments.length === 1) { + // tri([x1, y1, x2, y2, x3, y3]) or tri({points: [...], mode: "..."}) + if (Array.isArray(arguments[0])) { + const coords = arguments[0]; + if (coords.length >= 6) { + x1 = coords[0]; + y1 = coords[1]; + x2 = coords[2]; + y2 = coords[3]; + x3 = coords[4]; + y3 = coords[5]; + } + } else if (arguments[0] && arguments[0].points) { + const obj = arguments[0]; + const coords = obj.points; + if (Array.isArray(coords) && coords.length >= 6) { + x1 = coords[0]; + y1 = coords[1]; + x2 = coords[2]; + y2 = coords[3]; + x3 = coords[4]; + y3 = coords[5]; + } + if (obj.mode) mode = obj.mode; + } + } else if (arguments.length === 2) { + // tri([x1, y1, x2, y2, x3, y3], mode) + if (Array.isArray(arguments[0])) { + const coords = arguments[0]; + if (coords.length >= 6) { + x1 = coords[0]; + y1 = coords[1]; + x2 = coords[2]; + y2 = coords[3]; + x3 = coords[4]; + y3 = coords[5]; + } + mode = arguments[1]; + } + } else { + return console.error("Invalid triangle call. Expected tri(x1, y1, x2, y2, x3, y3) or tri(x1, y1, x2, y2, x3, y3, mode)"); + } + + // Validate coordinates + if ([x1, y1, x2, y2, x3, y3].some(coord => coord === undefined || isNaN(coord))) { + return console.error("Invalid triangle coordinates:", { x1, y1, x2, y2, x3, y3 }); + } + + // Random parameters if undefined, null, or NaN. + if (nonvalue(x1)) x1 = randInt(width); + if (nonvalue(y1)) y1 = randInt(height); + if (nonvalue(x2)) x2 = randInt(width); + if (nonvalue(y2)) y2 = randInt(height); + if (nonvalue(x3)) x3 = randInt(width); + if (nonvalue(y3)) y3 = randInt(height); + + // Floor the coordinates + x1 = floor(x1); + y1 = floor(y1); + x2 = floor(x2); + y2 = floor(y2); + x3 = floor(x3); + y3 = floor(y3); + + if (mode === "fill" || mode === "") { + // Use the existing fillShape function + const points = [[x1, y1], [x2, y2], [x3, y3]]; + fillShape(points); + } else { + // Draw outline with specified mode + const thickness = parseInt(mode.split(":")[1]) || 1; + const outlineMode = mode.split(":")[0]; + + if (outlineMode === "outline" || outlineMode === "out") { + // Draw three lines forming the triangle outline + line(x1, y1, x2, y2); + line(x2, y2, x3, y3); + line(x3, y3, x1, y1); + } else if (outlineMode === "inline" || outlineMode === "in") { + // For inline mode, we would need to shrink the triangle inward + // For now, just draw the outline (this could be enhanced later) + line(x1, y1, x2, y2); + line(x2, y2, x3, y3); + line(x3, y3, x1, y1); + } else { + // Default to fill if mode is unrecognized + const points = [[x1, y1], [x2, y2], [x3, y3]]; + fillShape(points); + } + } +} + +// Renders a square grid at x, y given cols, rows, and scale. +// Buffer is optional, and if present will render the pixels at scale starting +// from the top left corner of the buffer, repeating if needed to fill the grid. + +function grid( + { + box: { x, y, w: cols, h: rows }, + transform: { scale, angle, width: twidth, height: theight, anchor }, + centers = [], + }, + buffer, +) { + const gridStart = performance.now(); // 🚨 PERFORMANCE TRACKING + + // 🚀 PERFORMANCE OPTIMIZATION: Use lower precision when running slowly + const isSlowFrame = graphPerf && graphPerf.lastFPS && graphPerf.lastFPS < 15; + + const oc = c.slice(); // Remember the original color. + + let w, h; + + // Note: `transform` is a bit overloaded here and looks for the presence + // of certain properties to determine the behavior. + // See `function paste` here in `graph` for an example usage. + if (scale !== undefined) { + if (number(scale)) scale = { x: scale, y: scale }; + w = cols * scale.x; + h = rows * scale.y; + + // ❤️‍🔥 + // TODO: Allow for scaleX and scaleY values on transform / let scale + // be an input array. + } else if (twidth !== undefined && theight !== undefined) { + w = twidth; + h = theight; + scale = { x: w / cols, y: h / rows }; + // ^ Give scale a separate width and height. + } + + const colPix = w / cols, + rowPix = h / rows; + + // A reversed axis runs from the far edge back, so shift the anchor by the + // (negative) span to keep a flip inside the same box as an unflipped draw. + if (scale.x < 0) x -= w; + if (scale.y < 0) y -= h; + + angle = wrap(angle, 360); // Keep angle positive. + + // Always make sure we are at the mid-point of the pixel we rotate around. + // given the image resolution's even / oddness on each axis. + // (Make some off by 1 adjustments for specific angles.) + + let xmod = 0, + ymod = 0; + + if (angle) { + // Odd width. + if (w % 2 !== 0 && h % 2 === 0) { + if (x % 1 !== 0 && angle === 90) xmod += 0.5; + + // if (angle === 90 || angle === 270) ymod += 1; + if (angle === 270) { + xmod += x % 1 !== 0 ? 1.0 : 0.5; + ymod += 0.5; + } + + if (angle === 180) { + ymod += 0.5; + if (x % 1 === 0) xmod += 0.5; + } + } + if (h % 2 !== 0 && w % 2 === 0) { + // Odd height. + if (y % 1 !== 0 && angle === 90) ymod += 0.5; + + if (angle === 270) { + xmod += 0.5; + ymod += y % 1 !== 0 ? 1.0 : 0.5; + } + + if (angle === 180) { + xmod += 0.5; + if (y % 1 === 0) ymod += 0.5; + } + } else if (w % 2 === 0 && h % 2 === 0) { + // Both even... + xmod += 0.5; + ymod += 0.5; + } else if (w % 2 !== 0 && h % 2 !== 0) { + // Both odd... + xmod += 0.5; + ymod += 0.5; + } + } + + // Make off by 1 adjustments for specific scale inverstions. + // (This is kind of hacky. 23.07.20.13.44) + if (scale.x < 0 && scale.y > 0) { + if (angle >= 90 && angle < 270) ymod += 1 * mathSign(-scale.y); + if (angle >= 180 && angle <= 270) xmod += 1 * mathSign(-scale.x); + } else if (scale.x > 0 && scale.y > 0) { + if (angle >= 90 && angle < 270) xmod += 1 * mathSign(-scale.x); + if (angle >= 180 && angle <= 270) ymod += 1 * mathSign(-scale.y); + } else if (scale.y < 0 && scale.x > 0) { + if (angle >= 90 && angle < 270) ymod += 1 * mathSign(-scale.y); + if (angle >= 180 && angle <= 270) xmod += 1 * mathSign(-scale.x); + } else if (scale.y < 0 && scale.x < 0) { + if (angle >= 90 && angle < 270) xmod += 1 * mathSign(-scale.x); + if (angle >= 180 && angle <= 270) ymod += 1 * mathSign(-scale.y); + } + + x += xmod; + y += ymod; + + // Rotate around the anchor, or center. + let centerX, centerY; + if (anchor) { + centerX = anchor.x + x; + centerY = anchor.y + y; + } else { + centerX = x + w / 2; + centerY = y + h / 2; + } + + angle = radians(angle); // Sets angle to 0 if it was undefined. + // Draw a scaled image if the buffer is present. + if (buffer) { + const bufWidth = buffer.width; + const bufHeight = buffer.height; + let bufPixels = buffer.pixels; + + if (!bufPixels) { + bufPixels = ensureBufferPixels(buffer); + } + + if (!bufPixels) { + console.warn("🎨 grid: buffer missing pixel data, skipping render", buffer); + return; + } + + // Use fast integer conversions and pre-calculate values + const scaleXAbs = ~~abs(scale.x); // Fast float-to-int conversion + const scaleYAbs = ~~abs(scale.y); + const isAngleZero = angle === 0; + + // Pre-calculate trigonometric values only if needed + let cosValue, sinValue; + if (!isAngleZero) { + cosValue = cos(angle); + sinValue = sin(angle); + } + + // Pre-calculate integer boundaries and scales + const bufferWidth = scaleXAbs; + const bufferHeight = scaleYAbs; + const halfBoxWidth = bufferWidth >> 1; // Fast division by 2 + const halfBoxHeight = bufferHeight >> 1; + const adjustedBufferWidth = bufferWidth + (halfBoxWidth << 1); // Fast multiplication by 2 + const adjustedBufferHeight = bufferHeight + (halfBoxHeight << 1); + + // Pre-calculate row and column pixel increments + const colPixInt = ~~(w / cols); + const rowPixInt = ~~(h / rows); + + // Fast path for simple scaling (no rotation, integer scales) + if ( + isAngleZero && + scale.x === scaleXAbs && + scale.y === scaleYAbs && + scale.x > 0 && + scale.y > 0 + ) { + // Ultra-fast nearest-neighbor scaling with direct buffer operations + for (let j = 0; j < rows; j += 1) { + const srcY = j % bufHeight; + const destStartY = ~~(y + j * rowPixInt); + const destEndY = ~~(y + (j + 1) * rowPixInt); + const pixelHeight = destEndY - destStartY; + + if (pixelHeight > 0 && destStartY >= 0 && destEndY <= height) { + for (let i = 0; i < cols; i += 1) { + const srcX = i % bufWidth; const srcIndex = (srcX + srcY * bufWidth) << 2; // Fast * 4 + if (srcIndex < bufPixels.length) { + // Extract color data directly + const r = bufPixels[srcIndex]; + const g = bufPixels[srcIndex + 1]; + const b = bufPixels[srcIndex + 2]; + const a = bufPixels[srcIndex + 3]; + + const destStartX = ~~(x + i * colPixInt); + const destEndX = ~~(x + (i + 1) * colPixInt); + const pixelWidth = destEndX - destStartX; + + if (pixelWidth > 0 && destStartX >= 0 && destEndX <= width) { + // Set color once and draw block efficiently + color(r, g, b, a); + + // Use box for rectangular fills when possible (faster than multiple lineh calls) + if (pixelWidth > 1 && pixelHeight > 1) { + box(destStartX, destStartY, pixelWidth, pixelHeight, "fill"); + } else if (pixelHeight === 1) { + // Single horizontal line + lineh(destStartX, destEndX - 1, destStartY); + } else { + // Vertical line or single pixel + for (let dy = 0; dy < pixelHeight; dy += 1) { + lineh(destStartX, destEndX - 1, destStartY + dy); + } + } + } + } + } + } + } + } else { + // Complex path for rotation and non-integer scaling (preserve existing functionality) + // 🚀 PERFORMANCE OPTIMIZATION: Reduce resolution when running slowly + const skipStep = isSlowFrame ? 2 : 1; // Skip every other pixel when slow + + for (let j = 0; j < rows; j += skipStep) { + const plotY = y + rowPix * j; + const repeatY = j % bufHeight; + + for (let i = 0; i < cols; i += skipStep) { + const plotX = x + colPix * i; + + let finalX, finalY; + + if (isAngleZero) { + finalX = plotX; + finalY = plotY; + } else { + // Rotate the plot coordinates around the center of the grid + const dx = plotX - centerX; + const dy = plotY - centerY; + finalX = dx * cosValue - dy * sinValue + centerX; + finalY = dx * sinValue + dy * cosValue + centerY; + } + + // Adjusted boundary checks + if ( + finalX < -adjustedBufferWidth || + finalX > width + adjustedBufferWidth || + finalY < -adjustedBufferHeight || + finalY > height + adjustedBufferHeight + ) { + continue; // Skip drawing this box + } + + // Find the proper color + const repeatX = i % bufWidth; + const pixIndex = (repeatX + bufWidth * repeatY) << 2; // Fast multiplication by 4 + + if (pixIndex < bufPixels.length) { + const colorData = [ + bufPixels[pixIndex], + bufPixels[pixIndex + 1], + bufPixels[pixIndex + 2], + bufPixels[pixIndex + 3], + ]; + color(...colorData); + + // Calculate destination pixel ranges ensuring no gaps + // Use colPix/rowPix which are the actual pixel sizes (w/cols, h/rows) + // A negative scale runs its axis backwards, so the cell's two + // edges arrive swapped; order them or the cell measures negative + // and never draws. Walking the source forwards across a reversed + // axis is what makes the mirror. + const edgeX = Math.round(x + i * colPix); // Round to nearest pixel for better centering + const edgeX2 = Math.round(x + (i + 1) * colPix); + const edgeY = Math.round(y + j * rowPix); + const edgeY2 = Math.round(y + (j + 1) * rowPix); + const destStartX = Math.min(edgeX, edgeX2); + const destEndX = Math.max(edgeX, edgeX2); + const destStartY = Math.min(edgeY, edgeY2); + const destEndY = Math.max(edgeY, edgeY2); + + const pixelWidth = destEndX - destStartX; + const pixelHeight = destEndY - destStartY; + + // Only draw if there's actually area to fill + if (pixelWidth > 0 && pixelHeight > 0) { + // Apply rotation if needed + if (isAngleZero) { + // Optimized no-rotation path + if (pixelWidth > 1 && pixelHeight > 1) { + // Use box for efficiency when drawing rectangles + box(destStartX, destStartY, pixelWidth, pixelHeight, "fill"); + } else { + // Fall back to line drawing for thin regions + for (let dy = 0; dy < pixelHeight; dy += 1) { + lineh(destStartX, destEndX - 1, destStartY + dy); + } + } + } else { + // Rotation path - draw larger boxes to fill gaps during rotation + // The gaps occur because rotated pixels don't align perfectly on the grid + const boxSize = Math.max(2, Math.ceil(Math.max(pixelWidth, pixelHeight) * 1.2)); + + for (let dy = 0; dy < pixelHeight; dy += 1) { + for (let dx = 0; dx < pixelWidth; dx += 1) { + const px = destStartX + dx; + const py = destStartY + dy; + + // Rotate around center + const relX = px - centerX; + const relY = py - centerY; + const rotX = relX * cosValue - relY * sinValue + centerX; + const rotY = relX * sinValue + relY * cosValue + centerY; + + // Draw overlapping boxes to prevent gaps + box(~~rotX, ~~rotY, boxSize, boxSize, "fill"); + } + } + } + } + } + } + } + } + } else { + // Draw a debug / blueprint grid if no buffer is present. + + // Plot a point in each of the four corners. + const right = x + w - 1, + bottom = y + h - 1; + + color(64, 64, 64); + plot(x, y); + plot(right, y); + plot(x, bottom); + plot(right, bottom); + color(...oc); + + // Draw each grid square, with optional center points. + for (let i = 0; i < cols; i += 1) { + const plotX = x + colPix * i; + for (let j = 0; j < rows; j += 1) { + const plotY = y + rowPix * j; + + // Lightly shade this grid square, alternating tint on evens and odds. + const alphaMod = oc[3] / 255; + color(oc[0], oc[1], oc[2], even(i + j) ? 50 * alphaMod : 75 * alphaMod); + box(plotX, plotY, scale.x, scale.y); + + // Color in the centers of each grid square. + centers.forEach((p) => { + color(oc[0], oc[1], oc[2], 100); + plot(plotX + p.x, plotY + p.y); + }); + } + } + + color(...oc); // Restore color. + } + + // 🚨 PERFORMANCE TRACKING + const gridEnd = performance.now(); + graphPerf.track('grid', gridEnd - gridStart); +} + +// Rendering stored drawings. +// Silently fails if `drawing` is left `undefined`. +// Params: +// drawing, x, y, scale = 1, angle = 0, thickness = 1 +// drawing, position, scale = 1, angle = 0, thickness = 1 +function draw() { + const args = arguments; + let drawing = args[0], + x, + y, + scale = 1, + angle = 0, + thickness = 1; + if (typeof args[1] === "number") { + x = args[1]; + y = args[2]; + scale = args[3] || scale; + angle = args[4] || angle; + thickness = args[5] || thickness; + } else if (typeof args[1] === "object") { + drawing = args[0]; + if (Array.isArray(args[1])) { + x = args[1][0]; + y = args[1][1]; + } else { + x = args[1].x; + y = args[1].y; + } + scale = args[2] || scale; + angle = args[3] || angle; + thickness = args[4] || thickness; + } + + if (drawing === undefined || drawing === null) return; + + // Store offset for later application (after rotation setup) + let xOffset = 0; + let yOffset = 0; + if (drawing.offset) { + xOffset = drawing.offset[0] * scale; + // Use baseline-corrected offset if available, otherwise fall back to original + yOffset = drawing.baselineOffset ? drawing.baselineOffset[1] * scale : drawing.offset[1] * scale; + } + + // 🖼️ Handle BDF pixel-based fonts (MatrixChunky8, etc.) + if (drawing.pixels && drawing.resolution && !drawing.commands) { + const [charWidth, charHeight] = drawing.resolution; + + // Apply rotation if needed + const angleRad = radians(angle); + const sinA = sin(angleRad); + const cosA = cos(angleRad); + const hasRotation = angle !== 0; + + // Calculate glyph center for rotation origin + const centerX = charWidth / 2; + const centerY = charHeight / 2; + + // Apply offset (for BDF fonts, apply directly without rotation) + x += xOffset; + y += yOffset; + + // Apply pan like vector fonts do + x = floor(x); + y = floor(y); + pan(x, y); + + // 🐛 Debug BDF rendering + // Convert BDF pixel data to screen coordinates and render + for (let row = 0; row < drawing.pixels.length; row++) { + const pixelRow = drawing.pixels[row]; + if (!Array.isArray(pixelRow)) continue; + + for (let col = 0; col < pixelRow.length; col++) { + if (pixelRow[col] === 1) { // Only render "on" pixels + // Start with pixel position relative to glyph center + let pixelX = col - centerX; + let pixelY = row - centerY; + + // Apply rotation around center + if (hasRotation) { + const rotatedX = pixelX * cosA - pixelY * sinA; + const rotatedY = pixelX * sinA + pixelY * cosA; + pixelX = rotatedX; + pixelY = rotatedY; + } + + // Translate back from center + pixelX += centerX; + pixelY += centerY; + + // Apply scale + pixelX *= scale; + pixelY *= scale; + + // Round for final pixel position (relative to pan origin) + const finalX = Math.round(pixelX); + const finalY = Math.round(pixelY); + + const isMatrixChunky = drawing?.fontName === "MatrixChunky8"; + + const matrixDebugActive = matrixDebugEnabled(); + if (!matrixDebugActive && matrixChunkyDebugCount !== 0) + matrixChunkyDebugCount = 0; + + if ( + isMatrixChunky && + matrixDebugActive && + row < 8 && + col < 8 + ) { + console.log("🧱 MatrixChunky8 draw", { + char: drawing?.char || drawing?.code || "?", + row, + col, + finalX, + finalY, + scale, + currentColor: c.slice(0, 4), + skip: skips.length, + mask: activeMask + ? { + x: activeMask.x, + y: activeMask.y, + width: activeMask.width, + height: activeMask.height, + } + : null, + }); + } + + let beforePixel; + let logged = false; + if ( + isMatrixChunky && + matrixDebugActive && + matrixChunkyDebugCount < 20 + ) { + beforePixel = pixel(finalX, finalY); + logged = true; + } + + // Render pixel (or scaled block if scale > 1) + if (scale === 1) { + point(finalX, finalY); + } else { + // For scaled pixels, fill in the entire scaled block + for (let sy = 0; sy < scale; sy++) { + for (let sx = 0; sx < scale; sx++) { + point(finalX + sx, finalY + sy); + } + } + } + + if (logged) { + const afterPixel = pixel(finalX, finalY); + matrixChunkyDebugCount += 1; + console.log("🪄 MatrixChunky8 point write", { + char: drawing?.char || drawing?.code || "?", + row, + col, + finalX, + finalY, + color: c.slice(0, 4), + before: beforePixel, + after: afterPixel, + }); + } + } + } + } + + pan(-x, -y); // Restore pan offset like vector fonts + return; // Exit early for BDF fonts + } + + // TODO: Eventually make this the call: rotatePoint(args[0], args[1], 0, 0); + angle = radians(angle); + const s = sin(angle); + const c = cos(angle); + + // Apply rotated offset for vector fonts + // When rotated, offset needs to be transformed by the rotation matrix + if (xOffset !== 0 || yOffset !== 0) { + const rotatedXOffset = xOffset * c - yOffset * s; + const rotatedYOffset = xOffset * s + yOffset * c; + // Round the offset to avoid fractional positioning that causes glyph corruption + x += Math.round(rotatedXOffset); + y += Math.round(rotatedYOffset); + } + + x = floor(x); + y = floor(y); + + pan(x, y); + + // 🍎 Build a poly line out of subsequent points sharing start & end points. + // 🧠 And plot other commands. + const gesture = []; // Keep track of continuous lines. + + function paintGesture() { + // Draw each gesture path and then kill it. + thickness === 1 ? poly(gesture) : pline(gesture, thickness); + gesture.length = 0; + } + + drawing?.commands?.forEach(({ name, args }, i) => { + args = args.map((a) => a * scale); + + // console.log(name, i, drawing.commands.length, drawing); + + if (name === "line") { + let x1 = args[0]; // x1 + let y1 = args[1]; // y1 + let x2 = args[2]; // x2 + let y2 = args[3]; // y2 + // Round rotated coordinates to avoid sub-pixel positioning artifacts + let nx1 = Math.round(x1 * c - y1 * s); + let ny1 = Math.round(x1 * s + y1 * c); + let nx2 = Math.round(x2 * c - y2 * s); + let ny2 = Math.round(x2 * s + y2 * c); + + if (nx1 !== nx2 || ny1 !== ny2) { + if (thickness === 1) { + gesture.push([nx1, ny1], [nx2, ny2]); + } else { + gesture.push({ x: nx1, y: ny1 }, { x: nx2, y: ny2 }); + } + } + + const nextCommand = drawing.commands[i + 1]; + if (nextCommand && nextCommand.name === "line") { + const nextArgs = nextCommand.args.map((a) => a * scale); + if (args[2] !== nextArgs[0] || args[3] !== nextArgs[1]) { + // If the last point of cur line and 1st point of next line are diff. + // Then we can paint! + paintGesture(); + } else { + // Otherwise we should pop off the last point to avoid repeats. + gesture.pop(); + } + } else { + paintGesture(); + } + } else if (name === "point") { + // Apply scale and rotation to point coordinates + let px = args[0]; + let py = args[1]; + // Round rotated coordinates to avoid sub-pixel positioning artifacts + let npx = Math.round(px * c - py * s); + let npy = Math.round(px * s + py * c); + + thickness === 1 + ? point(npx, npy) + : circle(npx, npy, thickness / 2, true); + } + }); + + pan(-x, -y); +} + +// Write out a line of text. +// TODO: Add directionality using a bresenham algorithm. +// - Must know about height. +// TODO: Abstract this to another level, similar to 'draw' above. +// - I would need to get the final drawing API and pass that to +// a module that builds on it, then also has functions that +// get added back to it. This would be *graph: layer 2*. +function printLine( + text, + font, + startX, + startY, + blockWidth = 6, + scale = 1, + xOffset = 0, + thickness = 1, + rotation = 0, + fontMetadata = null, + fallbackFont = null, +) { + if (!text) return; + + // Early culling for mask bounds + if (activeMask) { + const lineHeight = font?.A?.box?.height || 10; // Estimate line height + const scaledHeight = lineHeight * scale; + + // Check if the entire line is outside the mask bounds + if ( + startY >= activeMask.y + activeMask.height || + startY + scaledHeight <= activeMask.y || + startX >= activeMask.x + activeMask.width || + startX + text.length * blockWidth * scale <= activeMask.x + ) { + return; // Don't render if completely outside mask + } + } + + // Check if font supports proportional spacing using passed metadata instead of glyph object + // This avoids triggering the BDF proxy when checking font properties + + const isProportional = fontMetadata?.proportional === true || + fontMetadata?.bdfFont === "MatrixChunky8" || + fontMetadata?.name === "MatrixChunky8"; + + const getGlyphForChar = (char) => { + // Whitespace has no glyph — advance width handles spacing. + // Never fall back to "?" for spaces. + if (char.trim() === "") return null; + + let primaryGlyph = null; + let primaryQuestion = null; + let fallbackGlyph = null; + let fallbackQuestion = null; + + try { + primaryGlyph = font?.[char] || null; + primaryQuestion = font?.["?"] || null; + } catch (err) { + primaryGlyph = null; + primaryQuestion = null; + } + + // Prefer real glyphs over placeholders whenever possible. + if (primaryGlyph && !primaryGlyph.isPlaceholder) return primaryGlyph; + + if (fallbackFont) { + try { + fallbackGlyph = fallbackFont?.[char] || null; + fallbackQuestion = fallbackFont?.["?"] || null; + } catch (err) { + fallbackGlyph = null; + fallbackQuestion = null; + } + + if (fallbackGlyph && !fallbackGlyph.isPlaceholder) return fallbackGlyph; + if (fallbackQuestion) return fallbackQuestion; + } + + if (primaryGlyph) return primaryGlyph; + if (primaryQuestion) return primaryQuestion; + return null; + }; + + if (isProportional) { + // Use character advance widths from font metadata + const advances = fontMetadata?.advances || {}; + const bdfOverrides = fontMetadata?.bdfOverrides || {}; + + // Calculate character positions for proportional spacing + let currentX = startX + xOffset; + + [...text.toString()].forEach((char, i) => { + let charX = currentX; + let charY = startY; + + // Apply BDF overrides if they exist for this character + if (bdfOverrides[char]) { + const override = bdfOverrides[char]; + if (override.x !== undefined) { + charX += override.x * scale; + } + if (override.y !== undefined) { + charY += override.y * scale; + } + } + + // Get character advance width from font data, fallback to blockWidth + const charAdvance = advances[char] || blockWidth; + + draw( + getGlyphForChar(char), + charX, + charY, + scale, + rotation, + thickness, + ); + + // Advance to next character position + currentX += charAdvance * scale; + }); + } else { + // Use original monospace logic for fonts without proportional flag + [...text.toString()].forEach((char, i) => { + draw( + getGlyphForChar(char), + startX + blockWidth * scale * i + xOffset, + startY, + scale, + rotation, + thickness, + ); + }); + } +} + +function noise16() { + // Determine the area to process (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // Don't apply pan translation to mask bounds - mask is already set at current pan position + minX = Math.max(0, activeMask.x); + minY = Math.max(0, activeMask.y); + maxX = Math.min(width, activeMask.x + activeMask.width); + maxY = Math.min(height, activeMask.y + activeMask.height); + } + + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + const i = (y * width + x) * 4; + pixels[i] = byteInterval17(randInt(16)); // r + pixels[i + 1] = byteInterval17(randInt(16)); // g + pixels[i + 2] = byteInterval17(randInt(16)); // b + pixels[i + 3] = 255; // a + } + } +} + +function noise16DIGITPAIN() { + // Determine the area to process (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // Don't apply pan translation to mask bounds - mask is already set at current pan position + minX = Math.max(0, activeMask.x); + minY = Math.max(0, activeMask.y); + maxX = Math.min(width, activeMask.x + activeMask.width); + maxY = Math.min(height, activeMask.y + activeMask.height); + } + + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + const i = (y * width + x) * 4; + pixels[i] = byteInterval17(randInt(16)) * 0.6; // r + pixels[i + 1] = byteInterval17(randInt(16)) * 0.15; // g + pixels[i + 2] = byteInterval17(randInt(16)) * 0.55; // b + pixels[i + 3] = 255; // a + } + } +} + +function noise16Aesthetic() { + // Determine the area to process (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // Don't apply pan translation to mask bounds - mask is already set at current pan position + minX = Math.max(0, activeMask.x); + minY = Math.max(0, activeMask.y); + maxX = Math.min(width, activeMask.x + activeMask.width); + maxY = Math.min(height, activeMask.y + activeMask.height); + } + + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + const i = (y * width + x) * 4; + pixels[i] = byteInterval17(randInt(16)) * 0.4; // r + pixels[i + 1] = byteInterval17(randInt(16)) * 0.15; // g + pixels[i + 2] = byteInterval17(randInt(16)) * 0.8; // b + pixels[i + 3] = 255; // a + } + } +} + +function noise16Sotce() { + // Determine the area to process (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // Don't apply pan translation to mask bounds - mask is already set at current pan position + minX = Math.max(0, activeMask.x); + minY = Math.max(0, activeMask.y); + maxX = Math.min(width, activeMask.x + activeMask.width); + maxY = Math.min(height, activeMask.y + activeMask.height); + } + + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + const i = (y * width + x) * 4; + if (randomFlip()) pixels[i] = byteInterval17(14 + randInt(2)); // r + if (randomFlip()) pixels[i + 1] = byteInterval17(8 + randInt(2)) * 0.9; // g + if (randomFlip()) pixels[i + 2] = byteInterval17(8 + randInt(2)) * 0.9; // b + pixels[i + 3] = 255; // a + } + } +} + +function noiseTinted(tint, amount, saturation) { + // console.log("Tinting:", tint, amount, saturation); + tint = findColor(tint); + for (let i = 0; i < pixels.length; i += 4) { + const grayscale = randInt(255); + pixels[i] = lerp( + lerp(grayscale, randInt(255), saturation), + tint[0], + amount, + ); // r + pixels[i + 1] = lerp( + lerp(grayscale, randInt(255), saturation), + tint[1], + amount, + ); // g + pixels[i + 2] = lerp( + lerp(grayscale, randInt(255), saturation), + tint[2], + amount, + ); // b + pixels[i + 3] = 255; // a + } +} + +// Accumulated fractional steps for smooth fractional spinning +let spinAccumulator = 0; + +// Accumulated fractional zoom for smooth zooming (additive like scroll/spin) +let zoomAccumulator = 0.0; +let zoomTimeOffset = Math.random() * 1000; // Random time offset for organic variation + +// Accumulated fractional scroll for smooth scrolling +let scrollAccumulatorX = 0; +let scrollAccumulatorY = 0; + +// Reset scroll accumulators - called when pieces change +function resetScrollState() { + scrollAccumulatorX = 0; + scrollAccumulatorY = 0; +} + +// Accumulated fractional shear for smooth shearing +let shearAccumulatorX = 0; +let shearAccumulatorY = 0; + +// Accumulated radial displacement for smooth sucking +let suckAccumulator = 0.0; + +// Per-pixel shear accumulation for ensuring all pixels eventually move +let pixelShearAccumX = null; +let pixelShearAccumY = null; + +// Scroll the entire pixel buffer by x and/or y pixels with wrapping +function scroll(dx = 0, dy = 0) { + if (dx === 0 && dy === 0) return; // No change needed + + // Accumulate fractional scroll amounts + scrollAccumulatorX += dx; + scrollAccumulatorY += dy; + + // Extract integer parts for actual scrolling + // Use trunc (not floor) for symmetric negative handling: + // floor(-0.3) = -1 (overshoots), trunc(-0.3) = 0 (correct) + const integerDx = Math.trunc(scrollAccumulatorX); + const integerDy = Math.trunc(scrollAccumulatorY); + + // Keep fractional remainders + scrollAccumulatorX -= integerDx; + scrollAccumulatorY -= integerDy; + + // Only proceed if we have integer pixels to scroll + if (integerDx === 0 && integerDy === 0) { + return; + } + + // Determine the area to scroll (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // 🔧 FIX: Use floor for all boundaries to avoid 1-pixel artifacts + // When ceil is mixed with floor, floating-point arithmetic can cause the + // effective bounds to be 1 pixel off, creating scroll skip artifacts. + // Using floor consistently gives us the pixel-aligned inner bounds. + const maskMinX = Math.floor(activeMask.x); + const maskMinY = Math.floor(activeMask.y); + const maskMaxX = Math.floor(activeMask.x + activeMask.width); + const maskMaxY = Math.floor(activeMask.y + activeMask.height); + + minX = Math.max(0, Math.min(width, maskMinX)); + maxX = Math.max(0, Math.min(width, maskMaxX)); + minY = Math.max(0, Math.min(height, maskMinY)); + maxY = Math.max(0, Math.min(height, maskMaxY)); + } + + const boundsWidth = maxX - minX; + const boundsHeight = maxY - minY; + + // Early exit if bounds are invalid + if (boundsWidth <= 0 || boundsHeight <= 0) { + return; + } + + // Use integer scroll amounts + let finalDx = ((integerDx % boundsWidth) + boundsWidth) % boundsWidth; + let finalDy = ((integerDy % boundsHeight) + boundsHeight) % boundsHeight; + + if (finalDx === 0 && finalDy === 0) { + return; // No effective shift after normalization + } + + // 🚀 TRY GPU SCROLL FIRST + if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("scroll") && gpuSpinModule?.gpuScroll && pixels && width && height) { + const mask = activeMask ? { + x: minX, + y: minY, + width: boundsWidth, + height: boundsHeight + } : null; + + const success = gpuSpinModule.gpuScroll(pixels, width, height, finalDx, finalDy, mask); + if (success) { + gpuOk("scroll"); + return; + } + gpuFailed("scroll"); + } + + // CPU FALLBACK + // Create a complete copy of the working area for safe reading + // 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it + if (pixels.buffer && pixels.buffer.detached) { + console.warn('🚨 Pixels buffer detached in scroll, recreating from screen dimensions'); + pixels = new Uint8ClampedArray(width * height * 4); + pixels.fill(0); // Fill with transparent black + } + + const tempPixels = new Uint8ClampedArray(pixels); + + // 🚀 OPTIMIZED: Bulk row-based copying for maximum performance + // Process each row separately with intelligent chunking + for (let y = 0; y < boundsHeight; y++) { + const srcY = minY + ((y + boundsHeight - finalDy) % boundsHeight); + const destY = minY + y; + + if (finalDx === 0) { + // 🚀 FASTEST PATH: No horizontal scroll - single bulk copy per row + const srcRowStart = (srcY * width + minX) * 4; + const destRowStart = (destY * width + minX) * 4; + const rowBytes = boundsWidth * 4; + pixels.set(tempPixels.subarray(srcRowStart, srcRowStart + rowBytes), destRowStart); + } else { + // 🚀 FAST PATH: Horizontal scroll with wrapping - copy in 2 chunks per row + // Calculate where the wrap happens + const srcStartX = minX + finalDx; + + // Chunk 1: From scroll offset to end of bounds + const chunk1Width = boundsWidth - finalDx; + if (chunk1Width > 0) { + const srcStart = (srcY * width + srcStartX) * 4; + const destStart = (destY * width + minX) * 4; + const chunk1Bytes = chunk1Width * 4; + pixels.set(tempPixels.subarray(srcStart, srcStart + chunk1Bytes), destStart); + } + + // Chunk 2: From start of bounds to scroll offset (wrapped portion) + const chunk2Width = finalDx; + if (chunk2Width > 0) { + const srcStart = (srcY * width + minX) * 4; + const destStart = (destY * width + minX + chunk1Width) * 4; + const chunk2Bytes = chunk2Width * 4; + pixels.set(tempPixels.subarray(srcStart, srcStart + chunk2Bytes), destStart); + } + } + } +} + +// Flip the working area vertically (top-to-bottom) +function flip() { + // Determine the area to flip (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // 🔧 FIX: Use floor for all boundaries to avoid 1-pixel artifacts + // When ceil is mixed with floor, floating-point arithmetic can cause the + // effective bounds to be 1 pixel off, creating visual skip artifacts. + // Using floor consistently gives us the pixel-aligned inner bounds. + const maskMinX = Math.floor(activeMask.x); + const maskMinY = Math.floor(activeMask.y); + const maskMaxX = Math.floor(activeMask.x + activeMask.width); + const maskMaxY = Math.floor(activeMask.y + activeMask.height); + + minX = Math.max(0, Math.min(width, maskMinX)); + maxX = Math.max(0, Math.min(width, maskMaxX)); + minY = Math.max(0, Math.min(height, maskMinY)); + maxY = Math.max(0, Math.min(height, maskMaxY)); + } + + const boundsWidth = maxX - minX; + const boundsHeight = maxY - minY; + if (boundsWidth <= 0 || boundsHeight <= 1) return; + + // Try GPU first + if (gpuSpinEnabled && gpuSpinAvailable && gpuSpinModule?.gpuComposite && pixels && width && height) { + const mask = { + x: minX, + y: minY, + width: boundsWidth, + height: boundsHeight + }; + const success = gpuSpinModule.gpuComposite(pixels, width, height, { + zoom: 1.0, + zoomAnchorX: 0.5, + zoomAnchorY: 0.5, + scrollX: 0, + scrollY: 0, + flipY: true, + contrast: 1.0, + brightness: 0, + mask + }); + if (success) return; + } + + // CPU fallback + if (pixels.buffer && pixels.buffer.detached) { + console.warn("🚨 Pixels buffer detached in flip, recreating from screen dimensions"); + pixels = new Uint8ClampedArray(width * height * 4); + pixels.fill(0); + } + + const tempPixels = new Uint8ClampedArray(pixels); + + for (let y = 0; y < boundsHeight; y++) { + const srcY = maxY - 1 - y; + const destY = minY + y; + const srcRowStart = (srcY * width + minX) * 4; + const destRowStart = (destY * width + minX) * 4; + const rowBytes = boundsWidth * 4; + pixels.set(tempPixels.subarray(srcRowStart, srcRowStart + rowBytes), destRowStart); + } +} + +// Rotates pixels in concentric rings around a specified anchor point +// steps: positive for clockwise, negative for counterclockwise +// anchorX, anchorY: optional anchor point (defaults to center of working area) +// 🚀 Pre-allocated buffers for performance +let spinBuffer; +let zoomBuffer; + +// 🧪 EXPERIMENTAL: Block-based processing for better performance on large images +let useBlockProcessing = true; // Can be toggled at runtime +const BLOCK_SIZE = 64; // Process in 64x64 blocks + +let spinSkipCounter = 0; + +// 🚀 GPU EFFECTS: WebGL2-accelerated effects via OffscreenCanvas +// Runtime-configurable: set via setGpuConfig() from kidlisp.com preferences. +let gpuEnabled = true; // Master GPU toggle (default on) +let gpuSpinModule = null; +let gpuSpinEnabled = gpuEnabled; +let gpuSpinAvailable = null; // null = not checked yet +let gpuFloodAvailable = null; // null = not checked yet (separate check for float textures) +let gpuFloodEnabled = gpuEnabled; +let gpuLayerCompositeAvailable = null; // null = not checked yet +let gpuLayerCompositeEnabled = gpuEnabled; +let gpuInitPromise = null; // Promise for initialization + +// 🎯 Adaptive GPU failure tracking — auto-disable effects that keep failing +const GPU_FAIL_THRESHOLD = 3; // Disable after this many failures +const GPU_CACHE_KEY = "ac-gpu-disabled-v4"; // v4: Kawase blur + mobileSafeMode blocks all effects on Mali/Adreno +const gpuFailCounts = { + spin: 0, composite: 0, blur: 0, sharpen: 0, + shear: 0, suck: 0, flood: 0, zoom: 0, scroll: 0, + contrast: 0, brightness: 0, invert: 0, compositeLayers: 0, +}; +const gpuDisabled = {}; // Tracks which effects have been auto-disabled +let gpuFailoverResetCallback = null; // Called when an effect gets disabled + +// Load cached disabled effects from localStorage +try { + const cached = typeof localStorage !== "undefined" && localStorage.getItem(GPU_CACHE_KEY); + if (cached) { + const parsed = JSON.parse(cached); + for (const key of Object.keys(parsed)) { + gpuDisabled[key] = true; + gpuFailCounts[key] = GPU_FAIL_THRESHOLD; // Mark as already failed + } + console.log(`🎮 GPU: Loaded cached disabled effects: ${Object.keys(parsed).join(", ")}`); + } +} catch {} + +function gpuFailed(effectName) { + gpuFrameLog.failed.add(effectName); + gpuFailCounts[effectName] = (gpuFailCounts[effectName] || 0) + 1; + if (gpuFailCounts[effectName] >= GPU_FAIL_THRESHOLD && !gpuDisabled[effectName]) { + gpuDisabled[effectName] = true; + console.warn(`🎮 GPU ${effectName}: Auto-disabled after ${GPU_FAIL_THRESHOLD} failures — using CPU fallback`); + gpuTelemetry.report("gpu-disabled", effectName); + // Persist to localStorage so we skip detection on next load + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(GPU_CACHE_KEY, JSON.stringify(gpuDisabled)); + } + } catch {} + // Signal that a GPU failover happened — KidLisp checks this to reset + if (typeof self !== "undefined") self.__gpuFailoverOccurred = true; + if (gpuFailoverResetCallback) gpuFailoverResetCallback(); + } + return false; +} + +// Register a callback to reset the piece when GPU effects fail over +function onGpuFailover(callback) { + gpuFailoverResetCallback = callback; +} + +function gpuOk(effectName) { + if (gpuFailCounts[effectName] > 0) gpuFailCounts[effectName] = 0; + gpuFrameLog.ok.add(effectName); +} + +function gpuAllowed(effectName) { + return !gpuDisabled[effectName]; +} + +function getGpuStatus() { + return { failCounts: { ...gpuFailCounts }, disabled: { ...gpuDisabled } }; +} + +// 📊 GPU frame logging — tracks effect usage every 8 frames +const gpuFrameLog = { + frameCount: 0, + ok: new Set(), // effects that succeeded this period + failed: new Set(), // effects that failed this period + disabled: new Set(), // effects skipped (auto-disabled) + rendererCached: null, // GPU renderer string (cached on first query) +}; + +function gpuLogTick() { + gpuFrameLog.frameCount++; + if (gpuFrameLog.frameCount % 8 !== 0) return; + + // Capture renderer on first tick + if (!gpuFrameLog.rendererCached) { + try { + if (typeof OffscreenCanvas !== "undefined") { + const c = new OffscreenCanvas(1, 1); + const g = c.getContext("webgl2"); + if (g) { + const dbg = g.getExtension("WEBGL_debug_renderer_info"); + gpuFrameLog.rendererCached = dbg + ? g.getParameter(dbg.UNMASKED_RENDERER_WEBGL) + : "unknown"; + g.getExtension("WEBGL_lose_context")?.loseContext(); + } else { + gpuFrameLog.rendererCached = "no-webgl2"; + } + } else { + gpuFrameLog.rendererCached = "no-offscreen"; + } + } catch { + gpuFrameLog.rendererCached = "error"; + } + } + + const ok = [...gpuFrameLog.ok]; + const failed = [...gpuFrameLog.failed]; + const dis = Object.keys(gpuDisabled).filter(k => gpuDisabled[k]); + + // Log once on frame 8 (first status), then only on failures + if (gpuFrameLog.frameCount === 8) { + console.log(`🎮 GPU renderer=${gpuFrameLog.rendererCached} ok=[${ok}] fail=[${failed}] disabled=[${dis}]`); + } + + // Report to telemetry on frame 8 (first status report) and when failures occur + if (gpuFrameLog.frameCount === 8 || failed.length > 0) { + gpuTelemetry.report("gpu-status", null, { + frame: gpuFrameLog.frameCount, + renderer: gpuFrameLog.rendererCached, + ok, + failed, + disabled: dis, + }); + } + + // Reset per-period sets + gpuFrameLog.ok.clear(); + gpuFrameLog.failed.clear(); +} + +// 📡 GPU telemetry — batches failure reports and sends to /api/kidlisp-log +const gpuTelemetry = (() => { + const queue = []; + let flushTimer = null; + let deviceInfo = null; + + function getDevice() { + if (deviceInfo) return deviceInfo; + const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; + deviceInfo = { + userAgent: ua, + mobile: /Mobi|Android|iPhone|iPad/i.test(ua), + screen: typeof self !== "undefined" && self.screen + ? { w: self.screen.width, h: self.screen.height } + : null, + gpu: null, // filled on first report + }; + return deviceInfo; + } + + function report(type, effect, detail) { + const device = getDevice(); + // Try to capture WebGL renderer info on first report + if (!device.gpu && typeof OffscreenCanvas !== "undefined") { + try { + const c = new OffscreenCanvas(1, 1); + const g = c.getContext("webgl2"); + if (g) { + const dbg = g.getExtension("WEBGL_debug_renderer_info"); + if (dbg) { + device.gpu = { + vendor: g.getParameter(dbg.UNMASKED_VENDOR_WEBGL), + renderer: g.getParameter(dbg.UNMASKED_RENDERER_WEBGL), + }; + } + g.getExtension("WEBGL_lose_context")?.loseContext(); + } + } catch { /* ignore */ } + } + + queue.push({ + type, + effect, + detail: detail || null, + device, + gpuStatus: getGpuStatus(), + }); + + // Flush after short delay (batch nearby events) + if (!flushTimer) { + flushTimer = setTimeout(flush, 2000); + } + } + + async function flush() { + if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } + if (!queue.length) return; + const events = queue.splice(0, queue.length); + try { + await fetch("/api/kidlisp-log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ events }), + keepalive: true, + }); + } catch { /* telemetry failures are silent */ } + } + + return { report, flush }; +})(); + +// 🚀 Initialize GPU effects module eagerly (call this at startup) +async function initGpuEffects() { + if (gpuInitPromise) return gpuInitPromise; + + gpuInitPromise = (async () => { + try { + gpuSpinModule = await import('./gpu-effects.mjs'); + gpuSpinAvailable = gpuSpinModule.isGpuEffectsAvailable(); + gpuFloodAvailable = gpuSpinModule.isGpuFloodAvailable?.() ?? false; + gpuLayerCompositeAvailable = gpuSpinModule.isGpuLayerCompositeAvailable?.() ?? false; + // GPU init status logs (enable with window.acGpuDebug = true) + // if (gpuSpinAvailable) console.log('🎮 GPU Effects: Available and enabled'); + // else console.log('🎮 GPU Effects: Not available, using CPU fallback'); + // if (gpuFloodAvailable) console.log('🎮 GPU Flood: Available (JFA algorithm)'); + // else console.log('🎮 GPU Flood: Not available (no float texture support), using CPU fallback'); + // if (gpuLayerCompositeAvailable) console.log('🎮 GPU Layer Composite: Available (up to 8 layers)'); + // else console.log('🎮 GPU Layer Composite: Not available, using CPU fallback'); + } catch (e) { + console.warn('🎮 GPU Effects: Module load failed, using CPU fallback', e); + gpuSpinAvailable = false; + gpuFloodAvailable = false; + gpuLayerCompositeAvailable = false; + } + return gpuSpinAvailable; + })(); + + return gpuInitPromise; +} + +// 🧪 EXPERIMENTAL: Toggle GPU effects for performance testing +function setGpuSpin(enabled) { + gpuSpinEnabled = enabled; + console.log(`🎮 GPU Effects ${enabled ? 'ENABLED' : 'DISABLED'}`); +} + +// 🧪 EXPERIMENTAL: Toggle GPU flood fill for performance testing +function setGpuFlood(enabled) { + gpuFloodEnabled = enabled; + console.log(`🎮 GPU Flood ${enabled ? 'ENABLED' : 'DISABLED'}`); +} + +// 🧪 EXPERIMENTAL: Toggle GPU contrast for performance testing +let gpuContrastEnabled = gpuEnabled; +function setGpuContrast(enabled) { + gpuContrastEnabled = enabled; + console.log(`🎮 GPU Contrast ${enabled ? 'ENABLED' : 'DISABLED'}`); +} + +// 🧪 EXPERIMENTAL: Toggle GPU layer compositing for performance testing +function setGpuLayerComposite(enabled) { + gpuLayerCompositeEnabled = enabled; + console.log(`🎮 GPU Layer Composite ${enabled ? 'ENABLED' : 'DISABLED'}`); +} + +// 🎛️ Unified GPU config — called from kidlisp.com preferences via postMessage +// config: { gpu: bool, effects: { spin: bool, flood: bool, contrast: bool, composite: bool } } +function setGpuConfig(config) { + gpuEnabled = !!config.gpu; + const effects = config.effects || {}; + // When master GPU is off, all effects are off regardless of individual settings + gpuSpinEnabled = gpuEnabled && (effects.spin !== false); + gpuFloodEnabled = gpuEnabled && (effects.flood !== false); + gpuContrastEnabled = gpuEnabled && (effects.contrast !== false); + gpuLayerCompositeEnabled = gpuEnabled && (effects.composite !== false); + console.log(`🎮 GPU Config: master=${gpuEnabled}, spin=${gpuSpinEnabled}, flood=${gpuFloodEnabled}, contrast=${gpuContrastEnabled}, composite=${gpuLayerCompositeEnabled}`); + // Re-init GPU module if enabling for the first time + if (gpuEnabled && !gpuSpinModule) initGpuEffects(); +} + +/** + * GPU-accelerated multi-layer compositing + * Composites multiple layers onto the current pixel buffer in a single GPU pass + * + * @param {Array<{pixels: Uint8ClampedArray, x: number, y: number, width: number, height: number, alpha: number}>} layers + * @returns {boolean} - true if GPU was used, false if fell back to CPU + */ +function compositeLayers(layers) { + if (!layers || layers.length === 0) return true; + + const compositeStart = performance.now(); + + // 🚀 TRY GPU COMPOSITE FIRST + if (gpuLayerCompositeEnabled && gpuLayerCompositeAvailable && gpuAllowed("compositeLayers") && gpuSpinModule && pixels && width && height) { + const result = gpuSpinModule.gpuCompositeLayers(pixels, width, height, layers); + + if (result.success) { + gpuOk("compositeLayers"); + const compositeTime = performance.now() - compositeStart; + graphPerf.track("composite-layers-gpu", compositeTime); + return true; + } + gpuFailed("compositeLayers"); + } + + // 💻 CPU FALLBACK: Composite each layer individually + const cpuStart = performance.now(); + + for (const layer of layers) { + if (!layer.pixels) continue; + + const alpha = layer.alpha !== undefined ? layer.alpha : 255; + const destX = Math.floor(layer.x || 0); + const destY = Math.floor(layer.y || 0); + const srcW = layer.width; + const srcH = layer.height; + const src = layer.pixels; + + // Clamp to screen bounds + const startX = Math.max(0, destX); + const startY = Math.max(0, destY); + const endX = Math.min(width, destX + srcW); + const endY = Math.min(height, destY + srcH); + + if (startX >= endX || startY >= endY) continue; + + const alphaFactor = alpha / 255.0; + + for (let dy = startY; dy < endY; dy++) { + const srcY = dy - destY; + const srcRowStart = srcY * srcW * 4; + const dstRowStart = dy * width * 4; + + for (let dx = startX; dx < endX; dx++) { + const srcX = dx - destX; + const srcIdx = srcRowStart + srcX * 4; + const dstIdx = dstRowStart + dx * 4; + + const sA = src[srcIdx + 3] * alphaFactor; + if (sA < 1) continue; // Skip nearly transparent + + const sR = src[srcIdx]; + const sG = src[srcIdx + 1]; + const sB = src[srcIdx + 2]; + + if (sA >= 254) { + // Opaque - direct copy + pixels[dstIdx] = sR; + pixels[dstIdx + 1] = sG; + pixels[dstIdx + 2] = sB; + pixels[dstIdx + 3] = 255; + } else { + // Alpha blend + const invAlpha = 255 - sA; + pixels[dstIdx] = (sA * sR + invAlpha * pixels[dstIdx]) >> 8; + pixels[dstIdx + 1] = (sA * sG + invAlpha * pixels[dstIdx + 1]) >> 8; + pixels[dstIdx + 2] = (sA * sB + invAlpha * pixels[dstIdx + 2]) >> 8; + pixels[dstIdx + 3] = Math.min(255, pixels[dstIdx + 3] + (sA >> 1)); + } + } + } + } + + const cpuTime = performance.now() - cpuStart; + graphPerf.track("composite-layers-cpu", cpuTime); + return false; +} + +/** + * GPU-accelerated batched effects - applies zoom, scroll, contrast, brightness in ONE pass + * This is much faster than calling each effect separately on slower hardware + * + * @param {Object} options - Effect parameters + * @param {number} options.zoom - Zoom scale (1.0 = no change) + * @param {number} options.zoomAnchorX - Zoom anchor X (0-1, default 0.5) + * @param {number} options.zoomAnchorY - Zoom anchor Y (0-1, default 0.5) + * @param {number} options.scrollX - Horizontal scroll (pixels) + * @param {number} options.scrollY - Vertical scroll (pixels) + * @param {number} options.contrast - Contrast level (1.0 = no change) + * @param {number} options.brightness - Brightness adjustment (-255 to +255, 0 = no change) + * @returns {boolean} - true if GPU was used, false if fell back to CPU + */ +function batchedEffects(options = {}) { + const { + zoom = 1.0, + zoomAnchorX = 0.5, + zoomAnchorY = 0.5, + scrollX = 0, + scrollY = 0, + contrast: contrastLevel = 1.0, + brightness: brightnessLevel = 0 + } = options; + + // Early exit if nothing to do + const hasZoom = zoom !== 1.0; + const hasScroll = scrollX !== 0 || scrollY !== 0; + const hasContrast = contrastLevel !== 1.0; + const hasBrightness = brightnessLevel !== 0; + + if (!hasZoom && !hasScroll && !hasContrast && !hasBrightness) { + return true; + } + + const batchStart = performance.now(); + + // Build mask if active + let mask = null; + if (activeMask) { + const maskX = activeMask.x + panTranslation.x; + const maskY = activeMask.y + panTranslation.y; + mask = { + x: Math.max(0, Math.floor(maskX)), + y: Math.max(0, Math.floor(maskY)), + width: Math.min(width, Math.floor(maskX + activeMask.width)) - Math.max(0, Math.floor(maskX)), + height: Math.min(height, Math.floor(maskY + activeMask.height)) - Math.max(0, Math.floor(maskY)) + }; + } + + // 🚀 TRY GPU BATCHED EFFECTS + if (gpuSpinAvailable && gpuAllowed("composite") && gpuSpinModule && pixels && width && height) { + const result = gpuSpinModule.gpuComposite?.(pixels, width, height, { + zoom, + zoomAnchorX, + zoomAnchorY, + scrollX, + scrollY, + contrast: contrastLevel, + brightness: brightnessLevel, + mask + }); + + if (result) { + gpuOk("composite"); + const batchTime = performance.now() - batchStart; + graphPerf.track("batched-effects-gpu", batchTime); + return true; + } + gpuFailed("composite"); + } + + // 💻 CPU FALLBACK - Apply effects individually + const cpuStart = performance.now(); + + if (hasZoom) zoom_cpu(zoom, zoomAnchorX, zoomAnchorY); + if (hasScroll) scroll_cpu(scrollX, scrollY); + if (hasContrast) contrast(contrastLevel); + if (hasBrightness) brightness(brightnessLevel); + + const cpuTime = performance.now() - cpuStart; + graphPerf.track("batched-effects-cpu", cpuTime); + return false; +} + +// Internal CPU-only zoom (for fallback) +function zoom_cpu(scale, anchorX = 0.5, anchorY = 0.5) { + // CPU fallback for batched zoom - call the full zoom function + // which will skip its own GPU attempt and fall through to CPU path + zoom(scale, anchorX, anchorY); +} + +// Internal CPU-only scroll (for fallback) +function scroll_cpu(dx, dy) { + // CPU fallback for batched scroll - call the full scroll function + // which will skip its own GPU attempt and fall through to CPU path + scroll(dx, dy); +} + +// 🧪 EXPERIMENTAL: Toggle block-based processing for performance testing +function setBlockProcessing(enabled) { + useBlockProcessing = enabled; + console.log(`🧪 Block processing ${enabled ? 'ENABLED' : 'DISABLED'} (${BLOCK_SIZE}x${BLOCK_SIZE} blocks)`); +} + +// Each ring rotates by exactly 'steps' pixels, preserving all data +function spin(steps = 0, anchorX = null, anchorY = null) { + const spinStart = performance.now(); + + if (steps === 0) { + graphPerf.track("spin", 0); + return; + } + + // 🚀 TRY GPU SPIN FIRST + if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("spin") && gpuSpinModule && pixels && width && height) { + const mask = activeMask ? { + x: activeMask.x + panTranslation.x, + y: activeMask.y + panTranslation.y, + width: activeMask.width, + height: activeMask.height + } : null; + + const success = gpuSpinModule.gpuSpin(pixels, width, height, steps, anchorX, anchorY, mask); + if (success) { + gpuOk("spin"); + const spinEnd = performance.now(); + graphPerf.track("spin", spinEnd - spinStart); + return; + } + gpuFailed("spin"); + } + + // 🚀 CPU FALLBACK: Use block-based processing for better performance (26% faster) + return spinBlockBased(steps, anchorX, anchorY, spinStart); +} + +// 🚀 SIMD Helper: Process individual pixel with wrapping and sampling +function processSpinPixel(srcX, srcY, destIdx, minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight) { + // Fast wrapping (same logic as regular spin) + let wrappedSrcX = srcX; + let wrappedSrcY = srcY; + + if (srcX < minX || srcX >= minX + workingWidth) { + const normalizedX = srcX - minX; + wrappedSrcX = minX + (normalizedX - workingWidth * Math.floor(normalizedX / workingWidth)); + if (wrappedSrcX < minX) wrappedSrcX += workingWidth; + } + if (srcY < minY || srcY >= minY + workingHeight) { + const normalizedY = srcY - minY; + wrappedSrcY = minY + (normalizedY - workingHeight * Math.floor(normalizedY / workingHeight)); + if (wrappedSrcY < minY) wrappedSrcY += workingHeight; + } + + // Fast nearest neighbor with bounds clamping + const nearestSrcX = Math.max(minX, Math.min(maxXMinus1, Math.round(wrappedSrcX))); + const nearestSrcY = Math.max(minY, Math.min(maxYMinus1, Math.round(wrappedSrcY))); + + // Direct array access with pre-calculated offsets + const srcIdx = (nearestSrcY * width + nearestSrcX) * 4; + + // Unrolled RGBA copy for maximum speed + pixels[destIdx] = spinBuffer[srcIdx]; + pixels[destIdx + 1] = spinBuffer[srcIdx + 1]; + pixels[destIdx + 2] = spinBuffer[srcIdx + 2]; + pixels[destIdx + 3] = spinBuffer[srcIdx + 3]; +} + +// Static buffers for SIMD spin function +spin.coordBuffers = null; +spin.destIndices = null; + +// 🚀 EXPERIMENTAL: SIMD-optimized spin processing using typed arrays +function spinSimd(steps, anchorX, anchorY, spinStart) { + // Same early exits as regular spin + if (graphPerf && graphPerf.lastFPS && graphPerf.lastFPS < 6) { + spinSkipCounter++; + if (spinSkipCounter % 2 !== 0) { + graphPerf.track('spin', 0); + return; + } + } else { + spinSkipCounter = 0; + } + + if (steps === 0) { + graphPerf.track('spin', 0); + return; + } + + // Handle fractional steps + spinAccumulator += steps; + const integerSteps = floor(spinAccumulator); + spinAccumulator -= integerSteps; + if (integerSteps === 0) { + graphPerf.track('spin', 0); + return; + } + + // Determine the area to process + let minX = 0, minY = 0, maxX = width, maxY = height; + if (activeMask) { + minX = activeMask.x + panTranslation.x; + minY = activeMask.y + panTranslation.y; + maxX = activeMask.x + activeMask.width + panTranslation.x; + maxY = activeMask.y + activeMask.height + panTranslation.y; + } + + const workingWidth = maxX - minX; + const workingHeight = maxY - minY; + const centerX = anchorX !== null ? anchorX : minX + floor(workingWidth / 2); + const centerY = anchorY !== null ? anchorY : minY + floor(workingHeight / 2); + + // 🛡️ SAFETY CHECK: Buffer management + if (pixels.buffer && pixels.buffer.detached) { + console.warn('🚨 Pixels buffer detached in spinSimd, recreating'); + pixels = new Uint8ClampedArray(width * height * 4); + pixels.fill(0); + } + + const bufferSize = width * height * 4; + if (!spinBuffer || spinBuffer.length !== bufferSize) { + spinBuffer = new Uint8ClampedArray(bufferSize); + } + + if (pixels.length !== bufferSize) { + pixels = new Uint8ClampedArray(bufferSize); + pixels.fill(0); + } + + spinBuffer.set(pixels); + + // 🚀 SIMD: Create Float32Array views for vectorized coordinate math + const coordCount = (maxY - minY) * (maxX - minX); + const maxCoords = Math.ceil(coordCount / 4) * 4; // Round up to multiple of 4 + + // Reuse coordinate buffers to avoid allocation overhead + if (!spinSimd.coordBuffers || spinSimd.coordBuffers.length < maxCoords * 2) { + spinSimd.coordBuffers = new Float32Array(maxCoords * 2); // x,y pairs + spinSimd.srcIndices = new Uint32Array(maxCoords); + spinSimd.destIndices = new Uint32Array(maxCoords); + } + + const coords = spinSimd.coordBuffers; + const srcIndices = spinSimd.srcIndices; + const destIndices = spinSimd.destIndices; + + // Pre-calculate constants for SIMD operations + const twoPi = 2 * PI; + const maxXMinus1 = maxX - 1; + const maxYMinus1 = maxY - 1; + + let coordIndex = 0; + + // 🚀 SIMD: Generate all coordinates in vectorized batches + for (let destY = minY; destY < maxY; destY++) { + const dy = destY - centerY; + const dy2 = dy * dy; + const destRowOffset = destY * width; + + for (let destX = minX; destX < maxX; destX++) { + const dx = destX - centerX; + const distanceSquared = dx * dx + dy2; + + // Fast path for center pixels (same as regular spin) + if (distanceSquared < 1.0) { + const idx = (destRowOffset + destX) * 4; + pixels[idx] = spinBuffer[idx]; + pixels[idx + 1] = spinBuffer[idx + 1]; + pixels[idx + 2] = spinBuffer[idx + 2]; + pixels[idx + 3] = spinBuffer[idx + 3]; + continue; + } + + // Skip very distant pixels + if (distanceSquared > 16000000) continue; + + // Store coordinates for SIMD processing + coords[coordIndex * 2] = dx; + coords[coordIndex * 2 + 1] = dy; + destIndices[coordIndex] = (destRowOffset + destX) * 4; + coordIndex++; + } + } + + // 🚀 SIMD: Process coordinates in groups of 4 for vectorized operations + const groupsOf4 = Math.floor(coordIndex / 4); + const remainder = coordIndex % 4; + + // Process groups of 4 coordinates simultaneously + for (let group = 0; group < groupsOf4; group++) { + const baseIndex = group * 4; + + // Load 4 dx,dy pairs into SIMD-friendly arrays + const dx0 = coords[baseIndex * 2], dy0 = coords[baseIndex * 2 + 1]; + const dx1 = coords[(baseIndex + 1) * 2], dy1 = coords[(baseIndex + 1) * 2 + 1]; + const dx2 = coords[(baseIndex + 2) * 2], dy2 = coords[(baseIndex + 2) * 2 + 1]; + const dx3 = coords[(baseIndex + 3) * 2], dy3 = coords[(baseIndex + 3) * 2 + 1]; + + // 🚀 SIMD: Vectorized distance calculations + const dist0 = Math.sqrt(dx0 * dx0 + dy0 * dy0); + const dist1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); + const dist2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + const dist3 = Math.sqrt(dx3 * dx3 + dy3 * dy3); + + // 🚀 SIMD: Vectorized angle calculations + const angle0 = Math.atan2(dy0, dx0); + const angle1 = Math.atan2(dy1, dx1); + const angle2 = Math.atan2(dy2, dx2); + const angle3 = Math.atan2(dy3, dx3); + + // 🚀 SIMD: Vectorized angle transformations + const totalChange0 = integerSteps / dist0; + const totalChange1 = integerSteps / dist1; + const totalChange2 = integerSteps / dist2; + const totalChange3 = integerSteps / dist3; + + let srcAngle0 = angle0 - totalChange0; + let srcAngle1 = angle1 - totalChange1; + let srcAngle2 = angle2 - totalChange2; + let srcAngle3 = angle3 - totalChange3; + + // 🚀 SIMD: Vectorized angle normalization + srcAngle0 = srcAngle0 - twoPi * Math.floor(srcAngle0 / twoPi); + srcAngle1 = srcAngle1 - twoPi * Math.floor(srcAngle1 / twoPi); + srcAngle2 = srcAngle2 - twoPi * Math.floor(srcAngle2 / twoPi); + srcAngle3 = srcAngle3 - twoPi * Math.floor(srcAngle3 / twoPi); + + if (srcAngle0 < 0) srcAngle0 += twoPi; + if (srcAngle1 < 0) srcAngle1 += twoPi; + if (srcAngle2 < 0) srcAngle2 += twoPi; + if (srcAngle3 < 0) srcAngle3 += twoPi; + + // 🚀 SIMD: Vectorized coordinate conversion using fast trig lookup + const srcX0 = centerX + dist0 * cos(srcAngle0); + const srcY0 = centerY + dist0 * sin(srcAngle0); + const srcX1 = centerX + dist1 * cos(srcAngle1); + const srcY1 = centerY + dist1 * sin(srcAngle1); + const srcX2 = centerX + dist2 * cos(srcAngle2); + const srcY2 = centerY + dist2 * sin(srcAngle2); + const srcX3 = centerX + dist3 * cos(srcAngle3); + const srcY3 = centerY + dist3 * sin(srcAngle3); + + // Process each of the 4 pixels (wrapping and sampling) + processSimdPixel(srcX0, srcY0, destIndices[baseIndex], minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight); + processSimdPixel(srcX1, srcY1, destIndices[baseIndex + 1], minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight); + processSimdPixel(srcX2, srcY2, destIndices[baseIndex + 2], minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight); + processSimdPixel(srcX3, srcY3, destIndices[baseIndex + 3], minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight); + } + + // Handle remaining pixels (less than 4) + for (let i = groupsOf4 * 4; i < coordIndex; i++) { + const dx = coords[i * 2]; + const dy = coords[i * 2 + 1]; + const distance = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dy, dx); + const totalAngleChange = integerSteps / distance; + let srcAngle = angle - totalAngleChange; + srcAngle = srcAngle - twoPi * Math.floor(srcAngle / twoPi); + if (srcAngle < 0) srcAngle += twoPi; + const srcX = centerX + distance * Math.cos(srcAngle); + const srcY = centerY + distance * Math.sin(srcAngle); + processSimdPixel(srcX, srcY, destIndices[i], minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight); + } + + const spinEnd = performance.now(); + graphPerf.track('spin', spinEnd - spinStart); +} + +// 🚀 SIMD Helper: Process individual pixel with wrapping and sampling +function processSimdPixel(srcX, srcY, destIdx, minX, minY, maxXMinus1, maxYMinus1, workingWidth, workingHeight) { + // Fast wrapping (same logic as regular spin) + let wrappedSrcX = srcX; + let wrappedSrcY = srcY; + + if (srcX < minX || srcX >= minX + workingWidth) { + const normalizedX = srcX - minX; + wrappedSrcX = minX + (normalizedX - workingWidth * Math.floor(normalizedX / workingWidth)); + if (wrappedSrcX < minX) wrappedSrcX += workingWidth; + } + if (srcY < minY || srcY >= minY + workingHeight) { + const normalizedY = srcY - minY; + wrappedSrcY = minY + (normalizedY - workingHeight * Math.floor(normalizedY / workingHeight)); + if (wrappedSrcY < minY) wrappedSrcY += workingHeight; + } + + // Fast nearest neighbor with bounds clamping + const nearestSrcX = Math.max(minX, Math.min(maxXMinus1, Math.round(wrappedSrcX))); + const nearestSrcY = Math.max(minY, Math.min(maxYMinus1, Math.round(wrappedSrcY))); + + // Direct array access with pre-calculated offsets + const srcIdx = (nearestSrcY * width + nearestSrcX) * 4; + + // Unrolled RGBA copy for maximum speed + pixels[destIdx] = spinBuffer[srcIdx]; + pixels[destIdx + 1] = spinBuffer[srcIdx + 1]; + pixels[destIdx + 2] = spinBuffer[srcIdx + 2]; + pixels[destIdx + 3] = spinBuffer[srcIdx + 3]; +} + +// Static buffers for SIMD spin function +spinSimd.coordBuffers = null; +spinSimd.srcIndices = null; +spinSimd.destIndices = null; + +// 🧪 EXPERIMENTAL: Block-based spin processing for better performance on large images +function spinBlockBased(steps, anchorX, anchorY, spinStart) { + // Same early exits as regular spin + if (graphPerf && graphPerf.lastFPS && graphPerf.lastFPS < 6) { + spinSkipCounter++; + if (spinSkipCounter % 2 !== 0) { + graphPerf.track('spin', 0); + return; + } + } else { + spinSkipCounter = 0; + } + + if (steps === 0) { + graphPerf.track('spin', 0); + return; + } + + // Handle fractional steps + spinAccumulator += steps; + const integerSteps = floor(spinAccumulator); + spinAccumulator -= integerSteps; + if (integerSteps === 0) { + graphPerf.track('spin', 0); + return; + } + + // Determine the area to process + let minX = 0, minY = 0, maxX = width, maxY = height; + if (activeMask) { + minX = activeMask.x + panTranslation.x; + minY = activeMask.y + panTranslation.y; + maxX = activeMask.x + activeMask.width + panTranslation.x; + maxY = activeMask.y + activeMask.height + panTranslation.y; + } + + const workingWidth = maxX - minX; + const workingHeight = maxY - minY; + const centerX = anchorX !== null ? anchorX : minX + floor(workingWidth / 2); + const centerY = anchorY !== null ? anchorY : minY + floor(workingHeight / 2); + + // Setup buffers + if (pixels.buffer && pixels.buffer.detached) { + console.warn('🚨 Pixels buffer detached in spin, recreating from screen dimensions'); + pixels = new Uint8ClampedArray(width * height * 4); + pixels.fill(0); + } + + const bufferSize = width * height * 4; + if (!spinBuffer || spinBuffer.length !== bufferSize) { + spinBuffer = new Uint8ClampedArray(bufferSize); + } + + if (pixels.length !== bufferSize) { + pixels = new Uint8ClampedArray(bufferSize); + pixels.fill(0); + } + + spinBuffer.set(pixels); + + // Vortex algorithm: angle change = steps / distance from center. + // Pixels near the center rotate more than pixels at the edges, + // creating the characteristic spiral/swirl pattern. + const twoPi = 2 * PI; + const maxXMinus1 = maxX - 1; + const maxYMinus1 = maxY - 1; + + for (let destY = minY; destY < maxY; destY++) { + const dy = destY - centerY; + const dy2 = dy * dy; + const destRowOffset = destY * width; + + for (let destX = minX; destX < maxX; destX++) { + const dx = destX - centerX; + const distanceSquared = dx * dx + dy2; + + // Center pixel — no rotation needed + if (distanceSquared < 1.0) { + const idx = (destRowOffset + destX) * 4; + pixels[idx] = spinBuffer[idx]; + pixels[idx + 1] = spinBuffer[idx + 1]; + pixels[idx + 2] = spinBuffer[idx + 2]; + pixels[idx + 3] = spinBuffer[idx + 3]; + continue; + } + + const distance = sqrt(distanceSquared); + const angle = Math.atan2(dy, dx); + const totalAngleChange = integerSteps / distance; + + let sourceAngle = angle - totalAngleChange; + sourceAngle = sourceAngle - twoPi * floor(sourceAngle / twoPi); + if (sourceAngle < 0) sourceAngle += twoPi; + + const srcXf = centerX + distance * cos(sourceAngle); + const srcYf = centerY + distance * Math.sin(sourceAngle); + + // Wrap into bounds + let wrappedSrcX = srcXf; + let wrappedSrcY = srcYf; + + if (srcXf < minX || srcXf >= maxX) { + const normalizedX = srcXf - minX; + wrappedSrcX = minX + (normalizedX - workingWidth * floor(normalizedX / workingWidth)); + if (wrappedSrcX < minX) wrappedSrcX += workingWidth; + } + if (srcYf < minY || srcYf >= maxY) { + const normalizedY = srcYf - minY; + wrappedSrcY = minY + (normalizedY - workingHeight * floor(normalizedY / workingHeight)); + if (wrappedSrcY < minY) wrappedSrcY += workingHeight; + } + + const nearestSrcX = Math.max(minX, Math.min(maxXMinus1, Math.round(wrappedSrcX))); + const nearestSrcY = Math.max(minY, Math.min(maxYMinus1, Math.round(wrappedSrcY))); + + const srcIdx = (nearestSrcY * width + nearestSrcX) * 4; + const destIdx = (destRowOffset + destX) * 4; + + pixels[destIdx] = spinBuffer[srcIdx]; + pixels[destIdx + 1] = spinBuffer[srcIdx + 1]; + pixels[destIdx + 2] = spinBuffer[srcIdx + 2]; + pixels[destIdx + 3] = spinBuffer[srcIdx + 3]; + } + } + + const spinEnd = performance.now(); + graphPerf.track('spin', spinEnd - spinStart); +} + +// Zoom the entire pixel buffer with 1.0 as neutral (no change) +// level < 1.0 zooms out, level > 1.0 zooms in, level = 1.0 does nothing +// anchorX, anchorY: 0.0 = top/left, 0.5 = center, 1.0 = bottom/right +// Uses bilinear sampling with hard-edge thresholding for smooth scaling with crisp output +function zoom(level = 1, anchorX = 0.5, anchorY = 0.5) { + if (level === 1.0) return; // No change needed - neutral zoom + + const zoomStartTime = performance.now(); + + // For large zoom changes, apply immediately + // For small zoom changes, accumulate for aggregative effects + let actualZoomLevel; + + if (Math.abs(level - 1.0) >= 0.1) { + // Large zoom change - apply immediately (e.g., zoom(2) or zoom(0.5)) + actualZoomLevel = level; + // Still update accumulator to maintain state consistency + zoomAccumulator = 0; // Reset since we're applying a direct zoom + } else { + // Small zoom change - accumulate for aggregative effects (e.g., zoom(1.025)) + const zoomDelta = level - 1.0; + zoomAccumulator += zoomDelta; + + // Only apply when accumulated change is significant enough + const threshold = 0.05; // Increased from 0.01 to 0.05 for better performance + + if (Math.abs(zoomAccumulator) < threshold) return; + + // Apply the accumulated zoom change + const zoomToApply = Math.sign(zoomAccumulator) * threshold; + actualZoomLevel = 1.0 + zoomToApply; + + // Reduce accumulator by what we applied + zoomAccumulator -= zoomToApply; + } + + // 🚀 TRY GPU ZOOM FIRST + if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("zoom") && gpuSpinModule?.gpuZoom && pixels && width && height) { + const mask = activeMask ? { + x: activeMask.x, + y: activeMask.y, + width: activeMask.width, + height: activeMask.height + } : null; + + const success = gpuSpinModule.gpuZoom(pixels, width, height, actualZoomLevel, anchorX, anchorY, mask); + if (success) { + gpuOk("zoom"); + const zoomEndTime = performance.now(); + graphPerf.track('zoom', zoomEndTime - zoomStartTime); + return; + } + gpuFailed("zoom"); + } + + // CPU FALLBACK + // Determine the area to process (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + minX = activeMask.x; + minY = activeMask.y; + maxX = activeMask.x + activeMask.width; + maxY = activeMask.y + activeMask.height; + } + + const workingWidth = maxX - minX; + const workingHeight = maxY - minY; + + // Calculate anchor point in pixel coordinates within working area + const anchorPixelX = minX + workingWidth * anchorX; + const anchorPixelY = minY + workingHeight * anchorY; + + // Create a copy of the current pixels to read from + // 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it + if (pixels.buffer && pixels.buffer.detached) { + console.warn('🚨 Pixels buffer detached in zoom, recreating from screen dimensions'); + // This should not happen if setBuffer works correctly, but just in case... + pixels = new Uint8ClampedArray(width * height * 4); + pixels.fill(0); // Fill with transparent black + } + + // 🚀 OPTIMIZATION: Reuse zoom buffer instead of allocating new one each call + const bufferSize = width * height * 4; + if (!zoomBuffer || zoomBuffer.length !== bufferSize) { + zoomBuffer = new Uint8ClampedArray(bufferSize); + } + + // 🛡️ SAFETY CHECK: Ensure pixels buffer size matches expected dimensions before copying + if (pixels.length !== bufferSize) { + console.error(`🚨 ZOOM ERROR: Buffer size mismatch - pixels=${pixels.length}, expected=${bufferSize} (${width}x${height})`); + + // Defensive handling: only copy what fits to prevent RangeError + const copySize = Math.min(pixels.length, bufferSize); + zoomBuffer.fill(0); // Clear buffer first + if (copySize > 0) { + zoomBuffer.set(pixels.subarray(0, copySize)); + console.warn(`🚨 ZOOM RECOVERY: Partial copy of ${copySize} bytes to prevent crash`); + } + } else { + zoomBuffer.set(pixels); + } + + const scale = actualZoomLevel; + const invScale = 1.0 / scale; + const invWorkingWidth = 1.0 / workingWidth; + + // 🚀 SIMD: Create typed arrays for vectorized processing + const pixelCount = (maxY - minY) * (maxX - minX); + const maxPixels = Math.ceil(pixelCount / 4) * 4; // Round up to multiple of 4 + + // Reuse coordinate buffers to avoid allocation overhead + if (!zoom.coordBuffers || zoom.coordBuffers.length < maxPixels * 4) { + zoom.srcCoords = new Float32Array(maxPixels * 2); // x,y pairs for source coordinates + zoom.destIndices = new Uint32Array(maxPixels); + } + + const srcCoords = zoom.srcCoords; + const destIndices = zoom.destIndices; + let coordIndex = 0; + + // 🚀 SIMD: Generate all coordinates in vectorized batches + for (let destY = minY; destY < maxY; destY++) { + const destRowOffset = destY * width; + const srcYBase = (destY - anchorPixelY) * invScale + anchorPixelY; + + // Pre-calculate Y wrapping once per row + let wrappedSrcYBase = ((srcYBase - minY) % workingHeight + workingHeight) % workingHeight + minY; + const nearestY = Math.round(wrappedSrcYBase); + const finalSrcY = minY + ((nearestY - minY) % workingHeight + workingHeight) % workingHeight; + + if (finalSrcY >= minY && finalSrcY < maxY) { + const srcRowOffset = finalSrcY * width; + + for (let destX = minX; destX < maxX; destX++) { + // Calculate source X coordinate + const srcX = (destX - anchorPixelX) * invScale + anchorPixelX; + + // Store coordinates for SIMD processing + srcCoords[coordIndex * 2] = srcX; + srcCoords[coordIndex * 2 + 1] = finalSrcY; // Use pre-calculated Y + destIndices[coordIndex] = (destRowOffset + destX) * 4; + coordIndex++; + } + } + } + + // 🚀 SIMD: Process coordinates in groups of 4 for vectorized operations + const groupsOf4 = Math.floor(coordIndex / 4); + + for (let group = 0; group < groupsOf4; group++) { + const baseIndex = group * 4; + + // Load 4 source X coordinates for vectorized processing + const srcX0 = srcCoords[baseIndex * 2]; + const srcX1 = srcCoords[(baseIndex + 1) * 2]; + const srcX2 = srcCoords[(baseIndex + 2) * 2]; + const srcX3 = srcCoords[(baseIndex + 3) * 2]; + + // Use same Y coordinate for this row (already calculated) + const srcY = srcCoords[baseIndex * 2 + 1]; + const srcRowOffset = srcY * width; + + // 🚀 SIMD: Vectorized X coordinate wrapping + const normalizedX0 = (srcX0 - minX) * invWorkingWidth; + const normalizedX1 = (srcX1 - minX) * invWorkingWidth; + const normalizedX2 = (srcX2 - minX) * invWorkingWidth; + const normalizedX3 = (srcX3 - minX) * invWorkingWidth; + + const wrappedNormX0 = normalizedX0 - Math.floor(normalizedX0); + const wrappedNormX1 = normalizedX1 - Math.floor(normalizedX1); + const wrappedNormX2 = normalizedX2 - Math.floor(normalizedX2); + const wrappedNormX3 = normalizedX3 - Math.floor(normalizedX3); + + const wrappedSrcX0 = minX + wrappedNormX0 * workingWidth; + const wrappedSrcX1 = minX + wrappedNormX1 * workingWidth; + const wrappedSrcX2 = minX + wrappedNormX2 * workingWidth; + const wrappedSrcX3 = minX + wrappedNormX3 * workingWidth; + + // 🚀 SIMD: Vectorized nearest neighbor sampling + const nearestX0 = Math.round(wrappedSrcX0); + const nearestX1 = Math.round(wrappedSrcX1); + const nearestX2 = Math.round(wrappedSrcX2); + const nearestX3 = Math.round(wrappedSrcX3); + + const finalSrcX0 = minX + ((nearestX0 - minX) % workingWidth + workingWidth) % workingWidth; + const finalSrcX1 = minX + ((nearestX1 - minX) % workingWidth + workingWidth) % workingWidth; + const finalSrcX2 = minX + ((nearestX2 - minX) % workingWidth + workingWidth) % workingWidth; + const finalSrcX3 = minX + ((nearestX3 - minX) % workingWidth + workingWidth) % workingWidth; + + // Process each of the 4 pixels if coordinates are valid + if (finalSrcX0 >= minX && finalSrcX0 < maxX) { + processZoomPixel(srcRowOffset + finalSrcX0, destIndices[baseIndex]); + } + if (finalSrcX1 >= minX && finalSrcX1 < maxX) { + processZoomPixel(srcRowOffset + finalSrcX1, destIndices[baseIndex + 1]); + } + if (finalSrcX2 >= minX && finalSrcX2 < maxX) { + processZoomPixel(srcRowOffset + finalSrcX2, destIndices[baseIndex + 2]); + } + if (finalSrcX3 >= minX && finalSrcX3 < maxX) { + processZoomPixel(srcRowOffset + finalSrcX3, destIndices[baseIndex + 3]); + } + } + + // Handle remaining pixels (less than 4) + for (let i = groupsOf4 * 4; i < coordIndex; i++) { + const srcX = srcCoords[i * 2]; + const srcY = srcCoords[i * 2 + 1]; + const srcRowOffset = srcY * width; + + // X coordinate wrapping + const normalizedX = (srcX - minX) * invWorkingWidth; + const wrappedNormX = normalizedX - Math.floor(normalizedX); + const wrappedSrcX = minX + wrappedNormX * workingWidth; + const nearestX = Math.round(wrappedSrcX); + const finalSrcX = minX + ((nearestX - minX) % workingWidth + workingWidth) % workingWidth; + + if (finalSrcX >= minX && finalSrcX < maxX) { + processZoomPixel(srcRowOffset + finalSrcX, destIndices[i]); + } + } + + // Don't reset zoom accumulator - let it continue accumulating like scroll and spin +} + +// 🚀 SIMD Helper: Process individual zoom pixel with fast copying +function processZoomPixel(srcPixelIndex, destIdx) { + const srcIdx = srcPixelIndex * 4; + + // Unrolled RGBA copy for maximum speed + pixels[destIdx] = zoomBuffer[srcIdx]; + pixels[destIdx + 1] = zoomBuffer[srcIdx + 1]; + pixels[destIdx + 2] = zoomBuffer[srcIdx + 2]; + pixels[destIdx + 3] = zoomBuffer[srcIdx + 3]; +} + +// Static buffers for SIMD zoom function +zoom.srcCoords = null; +zoom.destIndices = null; + +// 🚀 EXPERIMENTAL: SIMD-optimized zoom processing using typed arrays +function zoomSimd(actualZoomLevel, anchorX, anchorY) { + // Determine the area to process (mask or full screen) + let minX = 0, minY = 0, maxX = width, maxY = height; + if (activeMask) { + minX = activeMask.x; + minY = activeMask.y; + maxX = activeMask.x + activeMask.width; + maxY = activeMask.y + activeMask.height; + } + + const workingWidth = maxX - minX; + const workingHeight = maxY - minY; + const anchorPixelX = minX + workingWidth * anchorX; + const anchorPixelY = minY + workingHeight * anchorY; + + // 🛡️ SAFETY CHECK: Buffer management + if (pixels.buffer && pixels.buffer.detached) { + console.warn('🚨 Pixels buffer detached in zoomSimd, recreating'); + pixels = new Uint8ClampedArray(width * height * 4); + pixels.fill(0); + } + + const bufferSize = width * height * 4; + if (!zoomBuffer || zoomBuffer.length !== bufferSize) { + zoomBuffer = new Uint8ClampedArray(bufferSize); + } + zoomBuffer.set(pixels); + + const scale = actualZoomLevel; + const invScale = 1.0 / scale; + + // 🚀 SIMD: Create typed arrays for vectorized processing + const pixelCount = (maxY - minY) * (maxX - minX); + const maxPixels = Math.ceil(pixelCount / 4) * 4; // Round up to multiple of 4 + + // Reuse coordinate buffers to avoid allocation overhead + if (!zoomSimd.coordBuffers || zoomSimd.coordBuffers.length < maxPixels * 4) { + zoomSimd.srcCoords = new Float32Array(maxPixels * 2); // x,y pairs for source coordinates + zoomSimd.destIndices = new Uint32Array(maxPixels); + zoomSimd.srcIndices = new Uint32Array(maxPixels); + } + + const srcCoords = zoomSimd.srcCoords; + const destIndices = zoomSimd.destIndices; + const srcIndices = zoomSimd.srcIndices; + + // Pre-calculate constants for SIMD operations + const invWorkingWidth = 1.0 / workingWidth; + const invWorkingHeight = 1.0 / workingHeight; + + let coordIndex = 0; + + // 🚀 SIMD: Generate all coordinates in vectorized batches + for (let destY = minY; destY < maxY; destY++) { + const destRowOffset = destY * width; + const srcYBase = (destY - anchorPixelY) * invScale + anchorPixelY; + + // Pre-calculate Y wrapping once per row + let wrappedSrcYBase = ((srcYBase - minY) % workingHeight + workingHeight) % workingHeight + minY; + const nearestY = Math.round(wrappedSrcYBase); + const finalSrcY = minY + ((nearestY - minY) % workingHeight + workingHeight) % workingHeight; + + if (finalSrcY >= minY && finalSrcY < maxY) { + const srcRowOffset = finalSrcY * width; + + for (let destX = minX; destX < maxX; destX++) { + // Calculate source X coordinate + const srcX = (destX - anchorPixelX) * invScale + anchorPixelX; + + // Store coordinates for SIMD processing + srcCoords[coordIndex * 2] = srcX; + srcCoords[coordIndex * 2 + 1] = finalSrcY; // Use pre-calculated Y + destIndices[coordIndex] = (destRowOffset + destX) * 4; + coordIndex++; + } + } + } + + // 🚀 SIMD: Process coordinates in groups of 4 for vectorized operations + const groupsOf4 = Math.floor(coordIndex / 4); + const remainder = coordIndex % 4; + + // Process groups of 4 coordinates simultaneously + for (let group = 0; group < groupsOf4; group++) { + const baseIndex = group * 4; + + // Load 4 source X coordinates for vectorized processing + const srcX0 = srcCoords[baseIndex * 2]; + const srcX1 = srcCoords[(baseIndex + 1) * 2]; + const srcX2 = srcCoords[(baseIndex + 2) * 2]; + const srcX3 = srcCoords[(baseIndex + 3) * 2]; + + // Use same Y coordinate for this row (already calculated) + const srcY = srcCoords[baseIndex * 2 + 1]; + const srcRowOffset = srcY * width; + + // 🚀 SIMD: Vectorized X coordinate wrapping + const normalizedX0 = (srcX0 - minX) * invWorkingWidth; + const normalizedX1 = (srcX1 - minX) * invWorkingWidth; + const normalizedX2 = (srcX2 - minX) * invWorkingWidth; + const normalizedX3 = (srcX3 - minX) * invWorkingWidth; + + const wrappedNormX0 = normalizedX0 - Math.floor(normalizedX0); + const wrappedNormX1 = normalizedX1 - Math.floor(normalizedX1); + const wrappedNormX2 = normalizedX2 - Math.floor(normalizedX2); + const wrappedNormX3 = normalizedX3 - Math.floor(normalizedX3); + + const wrappedSrcX0 = minX + wrappedNormX0 * workingWidth; + const wrappedSrcX1 = minX + wrappedNormX1 * workingWidth; + const wrappedSrcX2 = minX + wrappedNormX2 * workingWidth; + const wrappedSrcX3 = minX + wrappedNormX3 * workingWidth; + + // 🚀 SIMD: Vectorized nearest neighbor sampling + const nearestX0 = Math.round(wrappedSrcX0); + const nearestX1 = Math.round(wrappedSrcX1); + const nearestX2 = Math.round(wrappedSrcX2); + const nearestX3 = Math.round(wrappedSrcX3); + + const finalSrcX0 = minX + ((nearestX0 - minX) % workingWidth + workingWidth) % workingWidth; + const finalSrcX1 = minX + ((nearestX1 - minX) % workingWidth + workingWidth) % workingWidth; + const finalSrcX2 = minX + ((nearestX2 - minX) % workingWidth + workingWidth) % workingWidth; + const finalSrcX3 = minX + ((nearestX3 - minX) % workingWidth + workingWidth) % workingWidth; + + // Process each of the 4 pixels if coordinates are valid + if (finalSrcX0 >= minX && finalSrcX0 < maxX) { + processZoomSimdPixel(srcRowOffset + finalSrcX0, destIndices[baseIndex]); + } + if (finalSrcX1 >= minX && finalSrcX1 < maxX) { + processZoomSimdPixel(srcRowOffset + finalSrcX1, destIndices[baseIndex + 1]); + } + if (finalSrcX2 >= minX && finalSrcX2 < maxX) { + processZoomSimdPixel(srcRowOffset + finalSrcX2, destIndices[baseIndex + 2]); + } + if (finalSrcX3 >= minX && finalSrcX3 < maxX) { + processZoomSimdPixel(srcRowOffset + finalSrcX3, destIndices[baseIndex + 3]); + } + } + + // Handle remaining pixels (less than 4) + for (let i = groupsOf4 * 4; i < coordIndex; i++) { + const srcX = srcCoords[i * 2]; + const srcY = srcCoords[i * 2 + 1]; + const srcRowOffset = srcY * width; + + // X coordinate wrapping + const normalizedX = (srcX - minX) * invWorkingWidth; + const wrappedNormX = normalizedX - Math.floor(normalizedX); + const wrappedSrcX = minX + wrappedNormX * workingWidth; + const nearestX = Math.round(wrappedSrcX); + const finalSrcX = minX + ((nearestX - minX) % workingWidth + workingWidth) % workingWidth; + + if (finalSrcX >= minX && finalSrcX < maxX) { + processZoomSimdPixel(srcRowOffset + finalSrcX, destIndices[i]); + } + } +} + +// 🚀 SIMD Helper: Process individual zoom pixel with fast copying +function processZoomSimdPixel(srcPixelIndex, destIdx) { + const srcIdx = srcPixelIndex * 4; + + // Unrolled RGBA copy for maximum speed + pixels[destIdx] = zoomBuffer[srcIdx]; + pixels[destIdx + 1] = zoomBuffer[srcIdx + 1]; + pixels[destIdx + 2] = zoomBuffer[srcIdx + 2]; + pixels[destIdx + 3] = zoomBuffer[srcIdx + 3]; +} + +// Static buffers for SIMD zoom function +zoomSimd.srcCoords = null; +zoomSimd.destIndices = null; +zoomSimd.srcIndices = null; + +// Radial displacement transformation with pixel-perfect nearest neighbor sampling +// Creates discrete, lossless pixel movement without blur or center holes +function suck(strength = 1, centerX, centerY) { + if (strength === 0.0) return; // No change needed - neutral suck + + // Accumulate the suck strength like zoom does + suckAccumulator += strength; + + // Apply transformation when accumulator reaches threshold + const threshold = 0.5; + if (Math.abs(suckAccumulator) < threshold) return; + + // Determine the area to process (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + minX = activeMask.x; + minY = activeMask.y; + maxX = activeMask.x + activeMask.width; + maxY = activeMask.y + activeMask.height; + } + + const workingWidth = maxX - minX; + const workingHeight = maxY - minY; + + // Default center point to middle of working area + const centerPixelX = centerX !== undefined ? centerX : minX + workingWidth * 0.5; + const centerPixelY = centerY !== undefined ? centerY : minY + workingHeight * 0.5; + + const displacementAmount = Math.abs(suckAccumulator); + // Positive strength = suck inward (source further out), Negative = blow outward + const direction = suckAccumulator > 0 ? 1 : -1; + + if (displacementAmount < 0.01) { + suckAccumulator = 0.0; + return; + } + + // 🚀 TRY GPU SUCK FIRST + if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("suck") && gpuSpinModule?.gpuSuck && pixels && width && height) { + const mask = activeMask ? { + x: activeMask.x + panTranslation.x, + y: activeMask.y + panTranslation.y, + width: activeMask.width, + height: activeMask.height + } : null; + + const success = gpuSpinModule.gpuSuck( + pixels, width, height, displacementAmount, direction, + centerPixelX, centerPixelY, mask + ); + if (success) { + gpuOk("suck"); + suckAccumulator = 0.0; + return; + } + gpuFailed("suck"); + } + + // 🐢 CPU FALLBACK + + // 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it + if (pixels.buffer && pixels.buffer.detached) { + console.warn('🚨 Pixels buffer detached in suck, recreating from screen dimensions'); + pixels = new Uint8ClampedArray(width * height * 4); + pixels.fill(0); + } + + // Create snapshot for pixel-perfect sampling + const tempPixels = new Uint8ClampedArray(pixels); + + // Apply radial displacement — each dest pixel pulls from further/closer along + // the radial line from center. + // Suck (direction=1): srcDistance = distance + displacement (pull from further out) + // Blow (direction=-1): srcDistance = distance - displacement (pull from closer in) + for (let destY = minY; destY < maxY; destY++) { + const destRowOffset = destY * width; + + for (let destX = minX; destX < maxX; destX++) { + const dx = destX - centerPixelX; + const dy = destY - centerPixelY; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Very center pixels stay put to prevent holes + if (distance < 1.0) { + const destIdx = (destRowOffset + destX) * 4; + pixels[destIdx] = tempPixels[destIdx]; + pixels[destIdx + 1] = tempPixels[destIdx + 1]; + pixels[destIdx + 2] = tempPixels[destIdx + 2]; + pixels[destIdx + 3] = tempPixels[destIdx + 3]; + continue; + } + + // Source is along the same radial ray, shifted by displacement + const srcDistance = distance + (direction * displacementAmount); + + // Compute source position along the same radial direction + // Use dx/dy ratio to avoid atan2 (faster, no angle discontinuities = no spiral) + const scale = srcDistance / distance; + let srcX = centerPixelX + dx * scale; + let srcY = centerPixelY + dy * scale; + + // Clamp to bounds (no wrapping — pixels at the edge just repeat) + srcX = Math.max(minX, Math.min(maxX - 1.0, srcX)); + srcY = Math.max(minY, Math.min(maxY - 1.0, srcY)); + + // Bilinear interpolation for sub-pixel precision + const srcX_floor = Math.floor(srcX); + const srcY_floor = Math.floor(srcY); + const wx = srcX - srcX_floor; + const wy = srcY - srcY_floor; + + const x1 = Math.max(minX, Math.min(maxX - 1, srcX_floor)); + const y1 = Math.max(minY, Math.min(maxY - 1, srcY_floor)); + const x2 = Math.max(minX, Math.min(maxX - 1, srcX_floor + 1)); + const y2 = Math.max(minY, Math.min(maxY - 1, srcY_floor + 1)); + + const idx1 = (y1 * width + x1) * 4; + const idx2 = (y1 * width + x2) * 4; + const idx3 = (y2 * width + x1) * 4; + const idx4 = (y2 * width + x2) * 4; + + const destIdx = (destRowOffset + destX) * 4; + + for (let c = 0; c < 4; c++) { + const topInterp = tempPixels[idx1 + c] * (1 - wx) + tempPixels[idx2 + c] * wx; + const bottomInterp = tempPixels[idx3 + c] * (1 - wx) + tempPixels[idx4 + c] * wx; + pixels[destIdx + c] = Math.round(topInterp * (1 - wy) + bottomInterp * wy); + } + } + } + + // Reset accumulator after applying transformation + suckAccumulator = 0.0; +} + +// Accumulated blur strength for smooth progressive blurring +let blurAccumulator = 0.0; + +// Reusable temporary buffer for blur operations to avoid memory allocation on each call +let blurTempBuffer = null; +let blurTempBufferSize = 0; + +// Function to clean up blur buffers and reset state +function cleanupBlurBuffers() { + blurTempBuffer = null; + blurTempBufferSize = 0; + blurAccumulator = 0.0; +} + +// Efficient Gaussian blur using separable filtering with linear sampling optimization +// Creates smooth blur effect by applying horizontal then vertical Gaussian convolution +function blur(strength = 1, quality = "medium") { + if (strength <= 0.1) return; // No blur needed - neutral blur + + // Start timing for performance monitoring + const blurStartTime = performance.now(); + + // Scale strength to match legacy single-pass blur behavior. + // The two-pass separable Gaussian is ~3x stronger than the old + // single-pass weighted blur at the same radius value. + const scaledStrength = strength / 3; + + // Accumulate the blur strength for progressive blurring + blurAccumulator += scaledStrength; + + // Apply blur when accumulator reaches threshold + const threshold = 0.5; + if (Math.abs(blurAccumulator) < threshold) return; + + // 🚀 TRY GPU BLUR FIRST + if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("blur") && gpuSpinModule?.gpuBlur && pixels && width && height) { + const mask = activeMask ? { + x: activeMask.x + panTranslation.x, + y: activeMask.y + panTranslation.y, + width: activeMask.width, + height: activeMask.height + } : null; + + const success = gpuSpinModule.gpuBlur(pixels, width, height, Math.abs(blurAccumulator), mask); + if (success) { + gpuOk("blur"); + blurAccumulator = 0.0; + const blurEndTime = performance.now(); + graphPerf.track('blur', blurEndTime - blurStartTime); + return; + } + gpuFailed("blur"); + } + + // CPU FALLBACK + // Determine the area to process (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // Apply pan translation to mask bounds and ensure they're within screen bounds + minX = Math.max(0, Math.min(width, activeMask.x + panTranslation.x)); + maxX = Math.max( + 0, + Math.min(width, activeMask.x + activeMask.width + panTranslation.x), + ); + minY = Math.max(0, Math.min(height, activeMask.y + panTranslation.y)); + maxY = Math.max( + 0, + Math.min(height, activeMask.y + activeMask.height + panTranslation.y), + ); + } + + const workingWidth = maxX - minX; + const workingHeight = maxY - minY; + + // Early exit if bounds are invalid + if (workingWidth <= 0 || workingHeight <= 0) { + blurAccumulator = 0.0; + return; + } + + // 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it + if (pixels.buffer && pixels.buffer.detached) { + console.warn('🚨 Pixels buffer detached in blur, recreating from screen dimensions'); + pixels = new Uint8ClampedArray(width * height * 4); + pixels.fill(0); // Fill with transparent black + } + + // Calculate blur parameters based on strength and quality + const blurRadius = Math.max(1, Math.floor(Math.abs(blurAccumulator))); + const kernelSize = Math.min(blurRadius * 2 + 1, 15); // Cap at 15 for performance + + // Generate Gaussian weights using Pascal triangle approximation + const weights = generateGaussianWeights(kernelSize, quality); + const radius = Math.floor(kernelSize / 2); + + try { + // Reuse temporary buffer to avoid repeated large allocations + const requiredSize = pixels.length; + if (!blurTempBuffer || blurTempBufferSize !== requiredSize) { + // Only allocate new buffer if size changed or buffer doesn't exist + blurTempBuffer = new Uint8ClampedArray(requiredSize); + blurTempBufferSize = requiredSize; + } + + // Clear the temp buffer to ensure clean state + blurTempBuffer.fill(0); + + // PASS 1: Horizontal blur + applyHorizontalBlur(pixels, blurTempBuffer, weights, radius, minX, minY, maxX, maxY); + + // PASS 2: Vertical blur (from temp back to main buffer) + applyVerticalBlur(blurTempBuffer, pixels, weights, radius, minX, minY, maxX, maxY); + + } catch (error) { + console.warn('🚨 Blur operation failed:', error); + // Reset temp buffer on error to prevent issues on next call + blurTempBuffer = null; + blurTempBufferSize = 0; + } + + // Log timing information for performance monitoring + const blurEndTime = performance.now(); + const blurDuration = blurEndTime - blurStartTime; + // console.log(`⚡ blur: ${blurDuration.toFixed(3)}ms`); + + // Reset accumulator after applying blur + blurAccumulator = 0.0; +} + +// Generate optimized Gaussian weights for blur kernel +function generateGaussianWeights(kernelSize, quality) { + const weights = new Array(kernelSize); + const radius = Math.floor(kernelSize / 2); + const sigma = radius / 3.0; // Standard deviation for nice falloff + + if (quality === "fast") { + // Simple box blur approximation - all weights equal + const weight = 1.0 / kernelSize; + for (let i = 0; i < kernelSize; i++) { + weights[i] = weight; + } + } else { + // Proper Gaussian weights + let sum = 0.0; + for (let i = 0; i < kernelSize; i++) { + const x = i - radius; + const weight = Math.exp(-(x * x) / (2 * sigma * sigma)); + weights[i] = weight; + sum += weight; + } + + // Normalize weights to sum to 1.0 + for (let i = 0; i < kernelSize; i++) { + weights[i] /= sum; + } + } + + return weights; +} + +// Apply horizontal Gaussian blur pass with optimized sampling +function applyHorizontalBlur(sourcePixels, destPixels, weights, radius, minX, minY, maxX, maxY) { + // Safety checks + if (!sourcePixels || !destPixels || !weights) return; + if (radius < 0 || radius >= weights.length / 2) return; + + for (let y = minY; y < maxY; y++) { + const rowOffset = y * width; + + for (let x = minX; x < maxX; x++) { + const destIdx = (rowOffset + x) * 4; + + // Bounds check for destination index + if (destIdx < 0 || destIdx + 3 >= destPixels.length) continue; + + let r = 0, g = 0, b = 0, a = 0; + + // Apply convolution kernel horizontally + for (let k = -radius; k <= radius; k++) { + // Handle boundary conditions with clamping + const srcX = Math.max(minX, Math.min(maxX - 1, x + k)); + const srcIdx = (rowOffset + srcX) * 4; + + // Bounds check for source index + if (srcIdx < 0 || srcIdx + 3 >= sourcePixels.length) continue; + + const weightIdx = k + radius; + if (weightIdx < 0 || weightIdx >= weights.length) continue; + + const weight = weights[weightIdx]; + + r += sourcePixels[srcIdx] * weight; + g += sourcePixels[srcIdx + 1] * weight; + b += sourcePixels[srcIdx + 2] * weight; + a += sourcePixels[srcIdx + 3] * weight; + } + + // Store blurred values with clamping + destPixels[destIdx] = Math.round(Math.max(0, Math.min(255, r))); + destPixels[destIdx + 1] = Math.round(Math.max(0, Math.min(255, g))); + destPixels[destIdx + 2] = Math.round(Math.max(0, Math.min(255, b))); + destPixels[destIdx + 3] = Math.round(Math.max(0, Math.min(255, a))); + } + } +} + +// Apply vertical Gaussian blur pass with optimized sampling +function applyVerticalBlur(sourcePixels, destPixels, weights, radius, minX, minY, maxX, maxY) { + // Safety checks + if (!sourcePixels || !destPixels || !weights) return; + if (radius < 0 || radius >= weights.length / 2) return; + + for (let y = minY; y < maxY; y++) { + const rowOffset = y * width; + + for (let x = minX; x < maxX; x++) { + const destIdx = (rowOffset + x) * 4; + + // Bounds check for destination index + if (destIdx < 0 || destIdx + 3 >= destPixels.length) continue; + + let r = 0, g = 0, b = 0, a = 0; + + // Apply convolution kernel vertically + for (let k = -radius; k <= radius; k++) { + // Handle boundary conditions with clamping + const srcY = Math.max(minY, Math.min(maxY - 1, y + k)); + const srcIdx = (srcY * width + x) * 4; + + // Bounds check for source index + if (srcIdx < 0 || srcIdx + 3 >= sourcePixels.length) continue; + + const weightIdx = k + radius; + if (weightIdx < 0 || weightIdx >= weights.length) continue; + + const weight = weights[weightIdx]; + + r += sourcePixels[srcIdx] * weight; + g += sourcePixels[srcIdx + 1] * weight; + b += sourcePixels[srcIdx + 2] * weight; + a += sourcePixels[srcIdx + 3] * weight; + } + + // Store blurred values with clamping + destPixels[destIdx] = Math.round(Math.max(0, Math.min(255, r))); + destPixels[destIdx + 1] = Math.round(Math.max(0, Math.min(255, g))); + destPixels[destIdx + 2] = Math.round(Math.max(0, Math.min(255, b))); + destPixels[destIdx + 3] = Math.round(Math.max(0, Math.min(255, a))); + } + } +} + +// Apply sharpening filter to enhance edges and details +// strength: 0 = no sharpening, 1 = normal sharpening, >1 = aggressive sharpening +function sharpen(strength = 1) { + if (strength <= 0) return; // No sharpening needed + + // Start timing for performance monitoring + const sharpenStartTime = performance.now(); + + // 🚀 TRY GPU SHARPEN FIRST + if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("sharpen") && gpuSpinModule?.gpuSharpen && pixels && width && height) { + const mask = activeMask ? { + x: activeMask.x + panTranslation.x, + y: activeMask.y + panTranslation.y, + width: activeMask.width, + height: activeMask.height + } : null; + + const success = gpuSpinModule.gpuSharpen(pixels, width, height, strength, mask); + if (success) { + gpuOk("sharpen"); + const sharpenEndTime = performance.now(); + graphPerf.track('sharpen', sharpenEndTime - sharpenStartTime); + return; + } + gpuFailed("sharpen"); + } + + // CPU FALLBACK + // Determine the area to process (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // Apply pan translation to mask bounds and ensure they're within screen bounds + minX = Math.max(0, Math.min(width, activeMask.x + panTranslation.x)); + maxX = Math.max( + 0, + Math.min(width, activeMask.x + activeMask.width + panTranslation.x), + ); + minY = Math.max(0, Math.min(height, activeMask.y + panTranslation.y)); + maxY = Math.max( + 0, + Math.min(height, activeMask.y + activeMask.height + panTranslation.y), + ); + } + + const workingWidth = maxX - minX; + const workingHeight = maxY - minY; + + // Early exit if bounds are invalid + if (workingWidth <= 0 || workingHeight <= 0) return; + + // 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it + if (pixels.buffer && pixels.buffer.detached) { + console.warn('🚨 Pixels buffer detached in sharpen, recreating from screen dimensions'); + pixels = new Uint8ClampedArray(width * height * 4); + pixels.fill(0); // Fill with transparent black + } + + try { + // Create temporary buffer for sharpen operation + const tempBuffer = new Uint8ClampedArray(pixels.length); + + // Copy original pixels to temp buffer + tempBuffer.set(pixels); + + // Unsharp mask convolution kernel (center weight adjusted by strength) + // This is a 3x3 kernel that subtracts a blurred version from the original + const centerWeight = 1 + (4 * strength); + const edgeWeight = -strength; + + // Apply sharpening convolution + for (let y = minY + 1; y < maxY - 1; y++) { + for (let x = minX + 1; x < maxX - 1; x++) { + const centerIdx = (y * width + x) * 4; + + // Skip transparent pixels + if (tempBuffer[centerIdx + 3] === 0) continue; + + // Apply 3x3 unsharp mask kernel for each color channel + for (let channel = 0; channel < 3; channel++) { // RGB only, preserve alpha + let sum = 0; + + // Center pixel (enhanced) + sum += tempBuffer[centerIdx + channel] * centerWeight; + + // Surrounding pixels (subtracted) + sum += tempBuffer[((y - 1) * width + x) * 4 + channel] * edgeWeight; // top + sum += tempBuffer[((y + 1) * width + x) * 4 + channel] * edgeWeight; // bottom + sum += tempBuffer[(y * width + (x - 1)) * 4 + channel] * edgeWeight; // left + sum += tempBuffer[(y * width + (x + 1)) * 4 + channel] * edgeWeight; // right + + // Clamp result to valid range + pixels[centerIdx + channel] = Math.max(0, Math.min(255, Math.round(sum))); + } + // Alpha channel stays unchanged + } + } + + } catch (error) { + console.warn('🚨 Sharpen operation failed:', error); + } + + // Log timing information for performance monitoring + const sharpenEndTime = performance.now(); + graphPerf.track('sharpen', sharpenEndTime - sharpenStartTime); +} + +// Sort pixels by color within the masked area (or entire screen if no mask) +// Sorts by luminance (brightness) - darker pixels first, lighter pixels last +function sort() { + // Determine the area to sort (mask or full screen) + let minX = 0, + minY = 0, + maxX = width, + maxY = height; + if (activeMask) { + // Apply pan translation to mask bounds + const maskX = activeMask.x + panTranslation.x; + const maskY = activeMask.y + panTranslation.y; + minX = Math.max(0, Math.floor(maskX)); + minY = Math.max(0, Math.floor(maskY)); + maxX = Math.min(width, Math.floor(maskX + activeMask.width)); + maxY = Math.min(height, Math.floor(maskY + activeMask.height)); + } + + // Collect all pixels in the area + const pixelsToSort = []; + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + const index = (y * width + x) * 4; + const r = pixels[index]; + const g = pixels[index + 1]; + const b = pixels[index + 2]; + const a = pixels[index + 3]; + + // Calculate luminance for sorting (standard formula) + const luminance = 0.299 * r + 0.587 * g + 0.114 * b; + + pixelsToSort.push({ + r, + g, + b, + a, + luminance, + }); + } + } + + // Sort by luminance (darker to lighter) + pixelsToSort.sort((a, b) => a.luminance - b.luminance); + + // Write sorted pixels back to their positions + let sortedIndex = 0; + for (let y = minY; y < maxY; y++) { + for (let x = minX; x < maxX; x++) { + const index = (y * width + x) * 4; + const sortedPixel = pixelsToSort[sortedIndex]; + + pixels[index] = sortedPixel.r; + pixels[index + 1] = sortedPixel.g; + pixels[index + 2] = sortedPixel.b; + pixels[index + 3] = sortedPixel.a; + + sortedIndex++; + } + } +} + +// Copy a region from the current screen buffer into a new buffer +// Returns a buffer object that can be used with paste() +function copyRegion(x, y, w, h) { + x = Math.floor(x); + y = Math.floor(y); + w = Math.floor(w); + h = Math.floor(h); + + // Apply pan translation to coordinates + x += panTranslation.x; + y += panTranslation.y; + + // Clamp to screen bounds + x = Math.max(0, Math.min(x, width)); + y = Math.max(0, Math.min(y, height)); + w = Math.max(0, Math.min(w, width - x)); + h = Math.max(0, Math.min(h, height - y)); + + if (w <= 0 || h <= 0) { + return null; + } + + // Create new buffer using ImageData like makeBuffer does + const imageData = new ImageData(w, h); + const buffer = { + pixels: imageData.data, + width: imageData.width, + height: imageData.height, + }; + + // Copy pixels from screen buffer to new buffer + for (let srcY = 0; srcY < h; srcY++) { + for (let srcX = 0; srcX < w; srcX++) { + const srcIndex = ((y + srcY) * width + (x + srcX)) * 4; + const destIndex = (srcY * w + srcX) * 4; + + if (srcIndex >= 0 && srcIndex < pixels.length - 3) { + buffer.pixels[destIndex] = pixels[srcIndex]; + buffer.pixels[destIndex + 1] = pixels[srcIndex + 1]; + buffer.pixels[destIndex + 2] = pixels[srcIndex + 2]; + buffer.pixels[destIndex + 3] = pixels[srcIndex + 3]; + } + } + } + + return buffer; +} + +let stolen; + +// TODO: Steal should run copyregion and keep the buffer in a global 'stolen' variable. +function steal(x, y, width, height) { + stolen = copyRegion(x, y, width, height); + return stolen; +} + +// Paste the stolen buffer at the specified coordinates with optional scaling +function putback(x, y, scale = 1) { + if (!stolen) return; + const result = paste(stolen, x, y, scale); + return result; +} + +// KidPix-style shear function +// shearX: horizontal shear factor (positive = right lean, negative = left lean) +// shearY: vertical shear factor (positive = down lean, negative = up lean) +function shear(shearX = 0, shearY = 0) { + if (shearX === 0 && shearY === 0) return; + + // Accumulate fractional shear amounts + shearAccumulatorX += shearX; + shearAccumulatorY += shearY; + + const finalShearX = shearAccumulatorX; + const finalShearY = shearAccumulatorY; + + shearAccumulatorX = 0; + shearAccumulatorY = 0; + + // 🚀 TRY GPU SHEAR FIRST + if (gpuSpinEnabled && gpuSpinAvailable && gpuAllowed("shear") && gpuSpinModule?.gpuShear && pixels && width && height) { + const mask = activeMask ? { + x: activeMask.x + panTranslation.x, + y: activeMask.y + panTranslation.y, + width: activeMask.width, + height: activeMask.height + } : null; + + const success = gpuSpinModule.gpuShear(pixels, width, height, finalShearX, finalShearY, mask); + if (success) { + gpuOk("shear"); + return; + } + gpuFailed("shear"); + } + + // 🐢 CPU FALLBACK + // Work area bounds + let minX = 0, + maxX = width, + minY = 0, + maxY = height; + if (activeMask) { + minX = Math.max(0, Math.min(width, activeMask.x + panTranslation.x)); + maxX = Math.max( + 0, + Math.min(width, activeMask.x + activeMask.width + panTranslation.x), + ); + minY = Math.max(0, Math.min(height, activeMask.y + panTranslation.y)); + maxY = Math.max( + 0, + Math.min(height, activeMask.y + activeMask.height + panTranslation.y), + ); + } + + const workingWidth = maxX - minX; + const workingHeight = maxY - minY; + if (workingWidth <= 0 || workingHeight <= 0) return; + + // 🛡️ SAFETY CHECK: If pixels buffer is detached, recreate it + if (pixels.buffer && pixels.buffer.detached) { + console.warn('🚨 Pixels buffer detached in shear, recreating from screen dimensions'); + pixels = new Uint8ClampedArray(width * height * 4); + pixels.fill(0); // Fill with transparent black + } + + const tempPixels = new Uint8ClampedArray(pixels); + // Horizontal shear: each row shifts more based on distance from center + if (finalShearX !== 0) { + for (let y = 0; y < workingHeight; y++) { + const distFromCenter = y - workingHeight / 2; + const rowShift = Math.round(finalShearX * distFromCenter); + + for (let x = 0; x < workingWidth; x++) { + let srcX = x - rowShift; + srcX = ((srcX % workingWidth) + workingWidth) % workingWidth; + + const srcOffset = ((minY + y) * width + (minX + srcX)) * 4; + const destOffset = ((minY + y) * width + (minX + x)) * 4; + + pixels[destOffset] = tempPixels[srcOffset]; + pixels[destOffset + 1] = tempPixels[srcOffset + 1]; + pixels[destOffset + 2] = tempPixels[srcOffset + 2]; + pixels[destOffset + 3] = tempPixels[srcOffset + 3]; + } + } + tempPixels.set(pixels); + } // Vertical shear: each column shifts more based on distance from center + if (finalShearY !== 0) { + for (let x = 0; x < workingWidth; x++) { + const distFromCenter = x - workingWidth / 2; + const colShift = Math.round(finalShearY * distFromCenter); + + for (let y = 0; y < workingHeight; y++) { + let srcY = y - colShift; + srcY = ((srcY % workingHeight) + workingHeight) % workingHeight; + + const srcOffset = ((minY + srcY) * width + (minX + x)) * 4; + const destOffset = ((minY + y) * width + (minX + x)) * 4; + + pixels[destOffset] = tempPixels[srcOffset]; + pixels[destOffset + 1] = tempPixels[srcOffset + 1]; + pixels[destOffset + 2] = tempPixels[srcOffset + 2]; + pixels[destOffset + 3] = tempPixels[srcOffset + 3]; + } + } + } +} + +// b. Geometric Abstractions + +// For producing a projection matrix. +// For matrix & linear algebra help: https://www.youtube.com/playlist?list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab +class Camera { + type = "perspective"; + matrix; + #x = 0; + #rotX = 0; + #y = 0; + #rotY = 0; + #z = 0; + #rotZ = 0; + fov; + + near = 0.01; + far = 5000; + + position = [0, 0, 0, 1]; + rotation = [0, 0, 0]; + scale = [1, 1, 1]; + + // centerCached; // Saved after each call to `center()`. + + perspectiveMatrix; + #transformMatrix; + + // Takes x, y, z position and an optional scale (xyz) array. + constructor(fov = 80, { x, y, z, scale } = { x: 0, y: 0, z: 0, scale: 1 }) { + this.fov = fov; + + this.x = x; + this.y = y; + this.z = z; + + if (scale) this.scale = scale; + + this.#perspective(this.fov); + this.#transform(); + this.matrix = this.#transformMatrix; + } + + set rotX(n) { + this.#rotX = n; + this.#perspective(this.fov); + this.#transform(); + this.matrix = this.#transformMatrix; + this.rotation[0] = n; + } + + get rotX() { + return this.#rotX; + } + + set rotY(n) { + this.#rotY = n; + this.#perspective(this.fov); + this.#transform(); + this.matrix = this.#transformMatrix; + this.rotation[1] = n; + } + + get rotY() { + return this.#rotY; + } + + set rotZ(n) { + this.#rotZ = n; + this.#perspective(this.fov); + this.#transform(); + this.matrix = this.#transformMatrix; + this.rotation[2] = n; + } + + get rotZ() { + return this.#rotZ; + } + + // Returns the rotation of the camera in radians. + get rot() { + return [this.#rotX, this.#rotY, this.#rotZ]; + } + + set x(n = 0) { + this.#x = n; + this.#perspective(this.fov); + this.#transform(); + this.matrix = this.#transformMatrix; + this.position[0] = n; + } + + get x() { + return this.#x; + } + + set y(n = 0) { + this.#y = n; + this.#perspective(this.fov); + this.#transform(); + this.matrix = this.#transformMatrix; + this.position[1] = n; + } + + get y() { + return this.#y; + } + + set z(n = 0) { + this.#z = n; + this.#perspective(this.fov); + this.#transform(); + this.matrix = this.#transformMatrix; + this.position[2] = n; + } + + get z() { + return this.#z; + } + + forward(n) { + this.#z -= n; + this.#perspective(this.fov); + this.#transform(); + this.matrix = this.#transformMatrix; + } + + #perspective(fov) { + const zNear = this.near; + const zFar = this.far; + + this.perspectiveMatrix = mat4.perspective( + mat4.create(), + radians(fov), + width / height, + zNear, + zFar, + ); + + // See: https://github.com/BennyQBD/3DSoftwareRenderer/blob/641f59125351d9565e744a90ad86256c3970a724/src/Matrix4f.java#L89 + // And compare it with: https://glmatrix.net/docs/mat4.js.html#line1508 + + const zRange = zNear - zFar; + const ten = (-zNear - zFar) / zRange; + const fourteen = (2 * zFar * zNear) / zRange; + + this.perspectiveMatrix[10] = ten; // Zero the Z component. + this.perspectiveMatrix[14] = fourteen; + this.perspectiveMatrix[11] = 1; // Flip the Y so we see things rightside up. + } + + get perspective() { + return this.perspectiveMatrix; + } + + // Recalculate the camera matrix for a new display constraint. + // TODO: Eventually this should be redundant for custom cameras + // that don't hook into the `screen`. 24.02.21.15.26 + resize() { + this.forward(0); + } + + // Get an XYZ position on a plane at a given depth, + // relative to screen coordinates. + ray(X = width / 2, Y = height / 2, depth = 1, flippedY = false) { + this.#perspective(this.fov); + + // 1. Camera World Space + const pos = [...this.position]; + + if (flippedY) pos[1] *= -1; // TODO: This is a little janky now, both CPU + // and GPU should have the same Y. + + const rotX = mat4.fromXRotation(mat4.create(), radians(this.#rotX)); + const rotY = mat4.fromYRotation(mat4.create(), radians(this.#rotY)); + const rotZ = mat4.fromZRotation(mat4.create(), radians(this.#rotZ)); + + const rotatedX = mat4.multiply(mat4.create(), rotX, mat4.create()); + const rotatedY = mat4.multiply(mat4.create(), rotY, rotatedX); + const rotatedZ = mat4.multiply(mat4.create(), rotatedY, rotZ); + + const scaled = mat4.scale(mat4.create(), rotatedZ, this.scale); + + const world = scaled; + + // Camera World Space -> Inverted Perspective Projection + const invertedProjection = mat4.invert( + mat4.create(), + this.perspectiveMatrix, + ); + const invWorldPersProj = mat4.mul(mat4.create(), world, invertedProjection); + + // 2. Screen Point -> Inverted World Perspective Projection + + // Normalize from screen coordinates. + X = 1 - X / width; + Y = 1 - Y / height; + + // 2. -> Normalized Device Space (NDS) + let x = 2.0 * X - 1; + let y = 2.0 * Y - 1; + + // NDS -> Homogeneous Space + // (flipped Z, because we are in a left-handed coordinate system.) + const screenPos = vec4.fromValues(x, -y, 1, 1); + + // Adjust Z depth of plane... (scale screen position) + const shiftedScreenPos = vec4.scale(vec4.create(), screenPos, depth); + + // Get near plane. + const xyz = vec4.transformMat4( + vec4.create(), + shiftedScreenPos, + invWorldPersProj, + ); + + // Subtract transformed point from camera position. + const worldPos = vec4.sub(vec4.create(), pos, xyz); + + return worldPos; + } + + #transform() { + // TODO: Why does this and the FPS camera control need to be inverted? + // Can't I just somehow invert the matrix to avoid all the swapping? + // Maybe it has something to do with rotation order? + // The default in three.js is XYZ, but here I'm using YXZ which I + // had to override there. 22.10.09.20.31 + + // Translation. + const panned = mat4.translate(mat4.create(), mat4.create(), [ + this.#x, + this.#y, + this.#z, + ]); + + // Rotation + const rotY = mat4.fromYRotation(mat4.create(), radians(-this.#rotY)); // FLIPPED + const rotX = mat4.fromXRotation(mat4.create(), radians(this.#rotX)); + const rotZ = mat4.fromZRotation(mat4.create(), radians(this.#rotZ)); + const rotatedY = mat4.multiply(mat4.create(), rotY, panned); + const rotatedX = mat4.multiply(mat4.create(), rotX, rotatedY); + const rotatedZ = mat4.multiply(mat4.create(), rotZ, rotatedX); + + // Scale + // TODO: Add support for camera scaling. + const scaled = mat4.scale(mat4.create(), rotatedZ, this.scale); + + // Perspective + this.#transformMatrix = mat4.multiply( + mat4.create(), + this.perspectiveMatrix, + scaled, + ); + } +} + +// For moving a camera round over time. +// TODO: Add a track? (Rollercoaster / coast) +// TODO: Only supports sideways movement right now. +class Dolly { + camera; + + xVel = 0; + yVel = 0; + zVel = 0; + dec = 0.9; + + constructor(camera) { + this.camera = camera; + } + + sim() { + this.xVel *= this.dec; + this.yVel *= this.dec; + this.zVel *= this.dec; + + if (abs(this.xVel) > 0) this.camera.x += this.xVel; + if (abs(this.yVel) > 0) this.camera.y += this.yVel; + if (abs(this.zVel) > 0) this.camera.z += this.zVel; + } + + push({ x, y, z }) { + // Strafe x and z. + const xz = vec2.rotate( + vec2.create(), + vec2.fromValues(x, z), + vec2.fromValues(0, 0), + radians(-this.camera.rotY), // Take the camera Y axis for strafing. + ); + + this.xVel += xz[0] || 0; + this.yVel += y || 0; + this.zVel += xz[1] || 0; + } +} + +let formId = 0; + +// Debug flag for showing wireframes +let showWireframes = false; +let showBoundingBoxes = false; + +// Buffer to store wireframe lines for deferred rendering +let wireframeLines = []; + +// Buffer to store bounding box debug info +let boundingBoxes = []; + +// Stats for tracking rendering info +let renderStats = { + originalTriangles: 0, + clippedTriangles: 0, + subdividedTriangles: 0, + wireframeSegmentsTotal: 0, + wireframeSegmentsTextured: 0, + wireframeSegmentsGradient: 0, + wireframeSegmentsClipped: 0, + wireframeSegmentsOther: 0, + pixelsDrawn: 0, + trianglesRejected: 0, +}; + +// Function to toggle wireframe rendering +function setShowClippedWireframes(enabled) { + showWireframes = enabled; +} + +// Function to clear wireframe buffer (called at start of frame) +function clearWireframeBuffer() { + wireframeLines = []; + boundingBoxes = []; + renderStats = { + originalTriangles: 0, + clippedTriangles: 0, + subdividedTriangles: 0, + wireframeSegmentsTotal: 0, + wireframeSegmentsTextured: 0, + wireframeSegmentsGradient: 0, + wireframeSegmentsClipped: 0, + wireframeSegmentsOther: 0, + pixelsDrawn: 0, + trianglesRejected: 0, + }; +} + +// Function to get current render stats +function getRenderStats() { + return renderStats; +} + +// Function to toggle bounding box debug visualization +function setShowBoundingBoxes(enabled) { + showBoundingBoxes = enabled; +} + +// Function to draw all buffered bounding boxes (called after main rendering) +function drawBoundingBoxDebug() { + if (!showBoundingBoxes) return; + + // Draw each bounding box + boundingBoxes.forEach(box => { + const { minX, maxX, minY, maxY, screenMinX, screenMaxX, screenMinY, screenMaxY, rejected } = box; + + // Draw original bounding box in blue + color(0, 100, 255, 100); + line(minX, minY, maxX, minY); + line(maxX, minY, maxX, maxY); + line(maxX, maxY, minX, maxY); + line(minX, maxY, minX, minY); + + // Draw clipped bounding box in green (accepted) or red (rejected) + if (rejected) { + color(255, 0, 0, 200); + } else { + color(0, 255, 0, 200); + } + line(screenMinX, screenMinY, screenMaxX, screenMinY); + line(screenMaxX, screenMinY, screenMaxX, screenMaxY); + line(screenMaxX, screenMaxY, screenMinX, screenMaxY); + line(screenMinX, screenMaxY, screenMinX, screenMinY); + }); +} + +// Cohen-Sutherland line clipping algorithm +// Clips a line to the screen bounds [0, 0, width, height] +function clipLineToScreen(x1, y1, x2, y2) { + // Region codes + const INSIDE = 0; // 0000 + const LEFT = 1; // 0001 + const RIGHT = 2; // 0010 + const BOTTOM = 4; // 0100 + const TOP = 8; // 1000 + + // Compute region code for a point + function computeOutCode(x, y) { + let code = INSIDE; + if (x < 0) code |= LEFT; + else if (x >= width) code |= RIGHT; + if (y < 0) code |= TOP; + else if (y >= height) code |= BOTTOM; + return code; + } + + let outcode1 = computeOutCode(x1, y1); + let outcode2 = computeOutCode(x2, y2); + let accept = false; + + while (true) { + if (!(outcode1 | outcode2)) { + // Both points inside - accept line + accept = true; + break; + } else if (outcode1 & outcode2) { + // Both points share an outside region - reject line + break; + } else { + // Line needs clipping + let x, y; + // Pick point outside rectangle + const outcodeOut = outcode1 ? outcode1 : outcode2; + + // Find intersection point using line equation + if (outcodeOut & TOP) { + x = x1 + (x2 - x1) * (0 - y1) / (y2 - y1); + y = 0; + } else if (outcodeOut & BOTTOM) { + x = x1 + (x2 - x1) * (height - 1 - y1) / (y2 - y1); + y = height - 1; + } else if (outcodeOut & RIGHT) { + y = y1 + (y2 - y1) * (width - 1 - x1) / (x2 - x1); + x = width - 1; + } else if (outcodeOut & LEFT) { + y = y1 + (y2 - y1) * (0 - x1) / (x2 - x1); + x = 0; + } + + // Replace point outside rectangle with intersection point + if (outcodeOut === outcode1) { + x1 = x; + y1 = y; + outcode1 = computeOutCode(x1, y1); + } else { + x2 = x; + y2 = y; + outcode2 = computeOutCode(x2, y2); + } + } + } + + if (accept) { + return { x1, y1, x2, y2 }; + } else { + return null; // Line is completely outside + } +} + +// Function to add wireframe line to buffer +function addWireframeLine(x1, y1, x2, y2, color, category = "other") { + // Reject lines with NaN or Infinity first + if (!isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) { + return; + } + + // Reject zero-length lines + const dx = x2 - x1; + const dy = y2 - y1; + if (dx === 0 && dy === 0) { + return; + } + + // Very strict bounds check: only allow lines where BOTH endpoints are reasonably close to visible area + // This prevents perspective-reversed geometry from creating artifacts + const minX = -width * 0.1; + const maxX = width * 1.1; + const minY = -height * 0.1; + const maxY = height * 1.1; + + const p1Valid = x1 >= minX && x1 <= maxX && y1 >= minY && y1 <= maxY; + const p2Valid = x2 >= minX && x2 <= maxX && y2 >= minY && y2 <= maxY; + + if (!p1Valid || !p2Valid) { + // At least one endpoint is outside the acceptable range + return; + } + + wireframeLines.push({ x1, y1, x2, y2, color, category }); + + // Update wireframe stats + renderStats.wireframeSegmentsTotal++; + switch (category) { + case "textured": + renderStats.wireframeSegmentsTextured++; + break; + case "gradient": + renderStats.wireframeSegmentsGradient++; + break; + case "clipped": + renderStats.wireframeSegmentsClipped++; + break; + default: + renderStats.wireframeSegmentsOther++; + break; + } +} + +// Function to draw all buffered wireframes (called at end of frame) +function drawBufferedWireframes() { + if (wireframeLines.length > 0) { + const savedColor = [c[0], c[1], c[2], c[3]]; + withForceReplaceMode(() => { + for (const wf of wireframeLines) { + // Skip lines that are far off-screen (likely perspective artifacts) + const margin = max(width, height) * 0.5; + const p1OutOfBounds = abs(wf.x1) > width + margin || abs(wf.y1) > height + margin || wf.x1 < -margin || wf.y1 < -margin; + const p2OutOfBounds = abs(wf.x2) > width + margin || abs(wf.y2) > height + margin || wf.x2 < -margin || wf.y2 < -margin; + + if (p1OutOfBounds || p2OutOfBounds) { + continue; + } + + const clipped = clipLineToScreen(wf.x1, wf.y1, wf.x2, wf.y2); + if (!clipped) continue; + + setColor(wf.color[0], wf.color[1], wf.color[2], wf.color[3]); + line(clipped.x1, clipped.y1, clipped.x2, clipped.y2); + } + }); + setColor(...savedColor); + wireframeLines = []; + } +} + +// 🪧 sign - Create 3D text as a line-based Form +// Converts text into line geometry that can be positioned/rotated in 3D space +// +// Usage: +// const label = sign("Player1", { scale: 0.05, color: [0,1,0,1] }); +// label.position = [x, y, z]; +// ink(255).form(label); +// +// Options: +// scale: number - Size in world units (default: 0.1) +// color: [r,g,b,a] - Normalized RGBA color (default: [1,1,1,1]) +// align: "left" | "center" | "right" - Text alignment (default: "left") +// glyphs: object - Pre-loaded glyph data (required, from Typeface.glyphs) +// +let signGlyphCache = {}; // Cache for loaded glyphs + +function sign(text, options = {}) { + const { + scale = 0.1, + color = [1, 1, 1, 1], + align = "left", + glyphs = {}, // Pass in typeface.glyphs from the piece + } = options; + + const positions = []; + const colors = []; + + // Normalize color to [r,g,b,a] format with values 0-1 + const [r, g, b, a = 1] = Array.isArray(color) + ? color.map(c => c > 1 ? c / 255 : c) // Convert 0-255 to 0-1 if needed + : [1, 1, 1, 1]; + const lineColor = [r, g, b, a]; + + let cursorX = 0; + let totalWidth = 0; + const charData = []; + + // First pass: calculate total width and collect glyph data + for (const char of text) { + const charCode = char.charCodeAt(0); + let glyph = glyphs[char] || glyphs[charCode] || signGlyphCache[charCode]; + + // Fallback for space + if (char === ' ') { + charData.push({ char, glyph: null, advance: 3 * scale }); + totalWidth += 3 * scale; + continue; + } + + if (glyph) { + const advance = (glyph.advance || glyph.dwidth?.x || 4) * scale; + charData.push({ char, glyph, advance }); + totalWidth += advance; + } else { + // Missing glyph - use a placeholder box + charData.push({ char, glyph: null, advance: 4 * scale, missing: true }); + totalWidth += 4 * scale; + } + } + + // Calculate starting X based on alignment + if (align === "center") { + cursorX = -totalWidth / 2; + } else if (align === "right") { + cursorX = -totalWidth; + } + + // Second pass: generate line geometry + for (const { char, glyph, advance, missing } of charData) { + if (char === ' ') { + cursorX += advance; + continue; + } + + if (missing) { + // Draw a small box for missing glyphs + const s = 3 * scale; + const h = 6 * scale; + positions.push( + [cursorX, 0, 0, 1], [cursorX + s, 0, 0, 1], + [cursorX + s, 0, 0, 1], [cursorX + s, -h, 0, 1], + [cursorX + s, -h, 0, 1], [cursorX, -h, 0, 1], + [cursorX, -h, 0, 1], [cursorX, 0, 0, 1] + ); + colors.push(lineColor, lineColor, lineColor, lineColor, lineColor, lineColor, lineColor, lineColor); + cursorX += advance; + continue; + } + + // Get glyph offset for positioning + const offsetX = (glyph.offset?.[0] || 0) * scale; + const offsetY = (glyph.offset?.[1] || 0) * scale; + + // Process glyph commands + if (glyph.commands) { + for (const cmd of glyph.commands) { + if (cmd.name === "line") { + const [x1, y1, x2, y2] = cmd.args; + positions.push( + [cursorX + offsetX + x1 * scale, -(offsetY + y1 * scale), 0, 1], + [cursorX + offsetX + x2 * scale, -(offsetY + y2 * scale), 0, 1] + ); + colors.push(lineColor, lineColor); + } else if (cmd.name === "point") { + // Convert points to tiny crosses for visibility in 3D + const [x, y] = cmd.args; + const px = cursorX + offsetX + x * scale; + const py = -(offsetY + y * scale); + const ps = scale * 0.3; // Point size + positions.push( + [px - ps, py, 0, 1], [px + ps, py, 0, 1], + [px, py - ps, 0, 1], [px, py + ps, 0, 1] + ); + colors.push(lineColor, lineColor, lineColor, lineColor); + } + } + } else if (glyph.pixels) { + // Fallback for pixel-based glyphs: convert to dot pattern + const [charWidth, charHeight] = glyph.resolution || [4, 8]; + for (let row = 0; row < glyph.pixels.length; row++) { + const pixelRow = glyph.pixels[row]; + if (!Array.isArray(pixelRow)) continue; + for (let col = 0; col < pixelRow.length; col++) { + if (pixelRow[col] === 1) { + const px = cursorX + offsetX + col * scale; + const py = -(offsetY + row * scale); + const ps = scale * 0.25; + // Small cross for each pixel + positions.push( + [px, py - ps, 0, 1], [px, py + ps, 0, 1] + ); + colors.push(lineColor, lineColor); + } + } + } + } + + cursorX += advance; + } + + // Create and return the Form + if (positions.length === 0) { + // Empty text - return minimal form + positions.push([0, 0, 0, 1], [0, 0, 0, 1]); + colors.push([0, 0, 0, 0], [0, 0, 0, 0]); + } + + return new Form( + { type: "line", positions, colors }, + { pos: [0, 0, 0], scale: 1 } + ); +} + +// Helper to cache glyph data for sign() +function cacheSignGlyph(charCode, glyphData) { + signGlyphCache[charCode] = glyphData; +} + +function clearSignGlyphCache() { + signGlyphCache = {}; +} + +// Mesh +class Form { + primitive = "triangle"; + type = "triangle"; + + limiter = 0; // Only enabled on CPU rendered `line` at the moment. 22.11.06.18.19 + + uid; // = nanoid(4); // An id to keep across threads. Takes ~4 milliseconds. 😢 + + tag; // Gets sent to the GPU as a named / marked tag. + // Currently only available on `buffered` types in `3d.mjs` 23.02.07.09.46 + + // Model + vertices = []; + indices = []; + + // TODO: Texture and color should be optional, and perhaps based on type. + // TODO: Should this use a parameter called shader? + texture; // = makeBuffer(32, 32); + color; + colorModifier; + noFade = false; // Set to true to disable near-plane alpha fading + + // GPU Specific Params & Buffers + gpuVerticesSent = 0; + gpuReset = false; // Assumes this object is being recreated on the GPU. + gpuKeep = true; + gpuConvertColors = true; + gpuTransformed = false; + gpuRecolored = false; + MAX_POINTS = 100000; // Some buffered geometry gpu calls may use this hint. + uvs = []; + + #gradientColors = [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + ]; + + /* I haven't needed support for this yet so it's left commented. 22.10.13.23.12 + #texCoords = [ + [0.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [1.0, 1.0, 0.0, 0.0], + ]; + */ + + // Transform + position = [0, 0, 0]; + rotation = [0, 0, 0]; + scale = [1, 1, 1]; + + gradients = true; + + // Blending + alpha = 1.0; + + constructor( + // Model + // `type` can be "triangle", or "line" or "line:buffered" + // `positions` and colors can be sent and then verticies will be generated + { + type, + vertices, + uvs = [], + positions, + colors, + texCoords, + gradients, + indices, + keep = true, + }, + fill, + // Transform + transform, + ) { + this.gradients = gradients; // A flag to decide if we use gradients or not. Only for line3d right now. 22.11.06.02.00 + + // Give an incremental id per session. + this.uid = formId; + formId += 1; + + // Set the primitive type. + this.primitive = type; + this.type = type; + + // Decide whether to throw this away after being drawn once + this.gpuKeep = keep; + + // Take into account form -> primitive relationships. + if (type === "quad") this.primitive = "triangle"; + if (type === "triangle:buffered") this.primitive = "triangle"; + if (type === "line:buffered") this.primitive = "line"; + + this.indices = indices || repeat(positions?.length, (i) => i); + + // 🌩️ Ingest positions and turn them into vertices. + // ("Import" a model...) + + // Switch fill to transform if the was skipped. + if (fill?.pos || fill?.rot || fill?.scale) { + transform = fill; + fill = undefined; + } + + // Assign texture or color. + if (fill?.tex) this.texture = fill.tex; + if (fill?.color) this.color = fill.color || c.slice(); + if (fill?.alpha) this.alpha = fill.alpha; + + // TODO: There is no maxed out notice here. + if (positions?.length > 0) + this.addPoints({ positions, colors, texCoords }, this.indices); + + // Or just set vertices directly. + if (vertices?.length > 0) { + this.vertices = vertices; + this.uvs = uvs; + } + + this.position = transform?.pos || [0, 0, 0]; + this.rotation = transform?.rot || [0, 0, 0]; + + if (typeof transform.scale === "number") { + this.scale = [transform.scale, transform.scale, transform.scale]; + } else { + this.scale = transform?.scale || [1, 1, 1]; + } + } + + // TODO: This needs to support color (and eventually N vertex attributes). + + resetUID() { + this.uid = nanoid(4); + //this.uid = formId; + //formId += 1; + } + + // Clears vertex and index attributes to prepare for replacement geometry. + clear() { + this.uvs = []; + this.vertices = []; + this.indices = []; + this.gpuReset = true; + this.gpuVerticesSent = 0; + } + + // How close we are to being beyond the max points allotted by the GPU for + // buffer geometries. + maxProgress() { + return this.vertices.length / (this.MAX_POINTS + 1); + } + + addPoints(attributes, indices) { + const incomingLength = attributes.positions.length; + const verticesLength = this.vertices.length; + const pointsAvailable = this.MAX_POINTS - verticesLength; + + let end = incomingLength; + let maxedOut = false; + + /* Left for debugging. 22.10.30.18.30 + if (this.MAX_POINTS === 256) { + console.log( + "Incoming:", incomingLength, "Current:", verticesLength, + "Max:", this.MAX_POINTS + ); + } + */ + + if (pointsAvailable < incomingLength) { + end = pointsAvailable; + maxedOut = true; + if (debug) + console.warn( + "Max. cutoff in GPU form!", + this, + incomingLength, + pointsAvailable, + ); + } + + // Create new vertices from incoming positions. + for (let i = 0; i < end; i++) { + // Use custom texCoords if provided, otherwise generate from position + const texCoord = attributes.texCoords?.[i] || [ + attributes.positions[i][X] / 2 + 0.5, + attributes.positions[i][Y] / 2 + 0.5, + ]; + + // 🔥 + // TODO: + // Wrap based on MAX_POINTS. + + this.uvs.push(...texCoord); // For sending to the GPU. + + // Optionally put color through a special function here. + if (attributes.colors?.[i] && typeof this.colorModifier === "function") { + attributes.colors[i] = this.colorModifier(attributes.colors[i]); + } + + this.vertices.push( + // For sending to the CPU. + new Vertex( + attributes.positions[i], + attributes.colors?.[i], + // this.#gradientColors[i % 3], + texCoord, //this.#texCoords[i % 3] // Replace to enable bespoke texture coordinates. + attributes.normals?.[i], + ), + ); + + // TODO: This may need to be turned back on for the GPU? + // What was with the -1 here? 22.11.06.17.42 + // if (!indices) this.indices.push(verticesLength - 1 + i); + if (!indices) this.indices.push(verticesLength + i); + // console.log(indices, !indices, i, verticesLength); + } + + if (indices) this.indices = indices; + + // Create indices from pre-indexed positions or generate + // a linear set of indices based on length. + + // TODO: How inefficient is this? 22.10.30.17.30 + // this.indices = indices || repeat(this.vertices.length, (i) => i); + + return maxedOut; + } + + // Get the world position of this form's local vertex. + transformVertex(vertex) { + // Build a matrix to represent this form's position, rotation and scale. + const panned = mat4.fromTranslation(mat4.create(), [ + this.position[X], + this.position[Y], + this.position[Z], + ]); + + const rotX = mat4.fromXRotation(mat4.create(), radians(this.rotation[X])); + const rotY = mat4.fromYRotation(mat4.create(), radians(this.rotation[Y])); + const rotZ = mat4.fromZRotation(mat4.create(), radians(this.rotation[Z])); + + const rotatedX = mat4.mul(mat4.create(), panned, rotX); + const rotatedY = mat4.mul(mat4.create(), rotatedX, rotY); + const rotatedZ = mat4.mul(mat4.create(), rotatedY, rotZ); + + const matrix = rotatedZ; + + //mat4.translate(matrix, matrix, this.position); + + // Apply scale. + mat4.scale(matrix, matrix, this.scale); + + // Apply the world matrix. + //matrix = mat4.mul(mat4.create(), worldMatrix, matrix); + + // const transformedVertices = []; + // Transform each vertex by the matrix. + //this.vertices.forEach((vertex) => { + return vertex.transformWorld(matrix); + //}); + } + graph({ matrix: cameraMatrix }) { + // Build a matrix to represent this form's position, rotation and scale. + // CORRECT ORDER: Scale → Rotate → Translate + // This makes objects rotate around their local center, then move to world position + + // 1. Scale + const scaled = mat4.scale(mat4.create(), mat4.create(), this.scale); + + // 2. Rotate (apply rotations to the scaled matrix) + const rotX = mat4.fromXRotation(mat4.create(), radians(this.rotation[X])); + const rotY = mat4.fromYRotation(mat4.create(), radians(this.rotation[Y])); + const rotZ = mat4.fromZRotation(mat4.create(), radians(this.rotation[Z])); + + const rotatedX = mat4.multiply(mat4.create(), rotX, scaled); + const rotatedY = mat4.multiply(mat4.create(), rotY, rotatedX); + const rotatedZ = mat4.multiply(mat4.create(), rotZ, rotatedY); + + // 3. Translate (move to world position after rotating) + const panned = mat4.fromTranslation(mat4.create(), [ + this.position[X] * -1, + this.position[Y], + this.position[Z] * -1, + ]); + + const transformed = mat4.multiply(mat4.create(), panned, rotatedZ); + + // Render wireframe lines for line type forms using untransformed vertices + if (this.type === "line" && this.vertices.length > 0) { + // Use the full combined matrix for screen-space conversion + const fullMatrix = mat4.multiply(mat4.create(), cameraMatrix, transformed); + + withForceReplaceMode(() => { + const savedColor = [c[0], c[1], c[2], c[3]]; + const baseColor = this.color; + // Render lines between pairs of vertices using original vertices + for (let i = 0; i < this.vertices.length; i += 2) { + if (i + 1 < this.vertices.length) { + const a = this.vertices[i]; + const b = this.vertices[i + 1]; + // Create temporary transformed vertices for projection + const transformedA = a.transform(fullMatrix); + const transformedB = b.transform(fullMatrix); + + const clippedLine = clipLineToFrustum(transformedA, transformedB); + if (clippedLine.length < 2) continue; + const [lineA, lineB] = clippedLine; + + // Apply perspective divide and screen space transformation + const screenA = toScreenSpace(perspectiveDivide(lineA)); + const screenB = toScreenSpace(perspectiveDivide(lineB)); + if ( + !Number.isFinite(screenA.pos[0]) || !Number.isFinite(screenA.pos[1]) || + !Number.isFinite(screenB.pos[0]) || !Number.isFinite(screenB.pos[1]) + ) { + continue; + } + + const clipped = clipLineToScreen( + screenA.pos[0], + screenA.pos[1], + screenB.pos[0], + screenB.pos[1], + ); + if (!clipped) continue; + + if (baseColor && baseColor.length >= 3) { + setColor( + baseColor[0], + baseColor[1], + baseColor[2], + baseColor[3] ?? 255, + ); + } else if (a.color && b.color) { + const avg = [ + floor((a.color[0] + b.color[0]) / 2 * 255), + floor((a.color[1] + b.color[1]) / 2 * 255), + floor((a.color[2] + b.color[2]) / 2 * 255), + floor((a.color[3] + b.color[3]) / 2 * 255), + ]; + setColor(...avg); + } + + // Draw simple 2D line guaranteed to overwrite underlying pixels + line(clipped.x1, clipped.y1, clipped.x2, clipped.y2); + } + } + setColor(...savedColor); + }); + } + + // Render filled triangles with gradient colors or textures + if (this.type === "triangle" && this.vertices.length >= 3) { + const fullMatrix = mat4.multiply(mat4.create(), cameraMatrix, transformed); + + // Render triangles (groups of 3 vertices) + for (let i = 0; i < this.vertices.length; i += 3) { + if (i + 2 < this.vertices.length) { + const v0 = this.vertices[i]; + const v1 = this.vertices[i + 1]; + const v2 = this.vertices[i + 2]; + + // Transform all three vertices + const t0 = v0.transform(fullMatrix); + const t1 = v1.transform(fullMatrix); + const t2 = v2.transform(fullMatrix); + + // Count original triangle + renderStats.originalTriangles++; + + // Perform full clip-space clipping against all frustum planes + // This handles triangles that extend beyond the screen edges + let clippedVertices = [t0, t1, t2]; + + // Clip against all frustum planes in clip-space (before perspective divide) + clippedVertices = clipInClipSpace(clippedVertices, ["near", "left", "right", "bottom", "top"]); + + // Skip if triangle was completely clipped + if (clippedVertices.length < 3) { + continue; + } + + // Belt-and-suspenders: skip any polygon where a vertex has W too small + // for a reliable perspective divide. Clip-space clipping should already + // enforce this via NEAR_CLIP_Z, but numerical drift (especially with the + // projection's W_clip = Z_view convention) can leave a vertex just under. + let anyWTooSmall = false; + for (let vi = 0; vi < clippedVertices.length; vi++) { + if (clippedVertices[vi].pos[W] < MIN_PERSPECTIVE_W) { anyWTooSmall = true; break; } + } + if (anyWTooSmall) continue; + + // Count clipped triangle (only if it resulted in a polygon) + if (clippedVertices.length >= 3) { + renderStats.clippedTriangles++; + } + + // Calculate fade factor based on closest vertex to camera (use Z from clip space) + const nearPlane = NEAR_CLIP_Z; + const fadePlane = 0.5; + const minZ = min(...clippedVertices.map(v => v.pos[2])); + let alphaMultiplier = 1.0; + if (!this.noFade && minZ < fadePlane) { + // Fade from 1.0 at fadePlane to 0.0 at nearPlane + alphaMultiplier = (minZ - nearPlane) / (fadePlane - nearPlane); + alphaMultiplier = max(0, min(1, alphaMultiplier)); // Clamp 0-1 + } + + // Apply perspective divide and screen space transformation to all clipped vertices + const screenVertices = clippedVertices.map(vertex => { + const persp = perspectiveDivide(vertex); + return toScreenSpace(persp); + }); + + // Skip if any vertex has invalid coordinates (NaN from failed perspective divide) + const hasInvalidVertex = screenVertices.some(v => + !Number.isFinite(v.pos[0]) || !Number.isFinite(v.pos[1]) + ); + if (hasInvalidVertex) continue; + + // Apply screen-space clipping to handle edge cases + const finalVertices = clipToScreen(screenVertices); + + // Skip if triangle was completely clipped away + if (finalVertices.length < 3) continue; + + // Render triangles from clipped vertices (triangulate if we have more than 3 vertices) + // After screen-space clipping, we have a convex polygon to triangulate + for (let j = 1; j < finalVertices.length - 1; j++) { + const s0 = finalVertices[0]; + const s1 = finalVertices[j]; + const s2 = finalVertices[j + 1]; + + // Extract screen coordinates + const x0 = s0.pos[0], y0 = s0.pos[1]; + const x1 = s1.pos[0], y1 = s1.pos[1]; + const x2 = s2.pos[0], y2 = s2.pos[1]; + + // Check if this form has a texture + if (this.texture) { + // Subdivide triangle if it's too large in screen space + // Pass W component for perspective-correct UV interpolation + const subdivided = subdivideTriangleIfNeeded( + x0, y0, s0.texCoords, s0.pos[2], s0.pos[3], // x, y, uv, z, w + x1, y1, s1.texCoords, s1.pos[2], s1.pos[3], + x2, y2, s2.texCoords, s2.pos[2], s2.pos[3], + 300 // Subdivide triangles larger than 300px to reduce overdraw + ); + + // Count subdivided triangles (only count if more than 1 triangle resulted) + if (subdivided.length > 1) { + renderStats.subdividedTriangles += subdivided.length; + } + + // Draw each sub-triangle + for (const tri of subdivided) { + // tri structure: [x0, y0, uv0, z0, w0, x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2] + drawTexturedTriangle( + tri[0], tri[1], tri[2], tri[3], tri[4], // x0, y0, uv0, z0, w0 + tri[5], tri[6], tri[7], tri[8], tri[9], // x1, y1, uv1, z1, w1 + tri[10], tri[11], tri[12], tri[13], tri[14], // x2, y2, uv2, z2, w2 + this.texture, + alphaMultiplier + ); + + // Buffer wireframe overlay for textured triangles + if (showWireframes) { + // Only add wireframes if all vertices have positive W (in front of camera) + const w0 = tri[4]; + const w1 = tri[9]; + const w2 = tri[14]; + + if (w0 > 0.01 && w1 > 0.01 && w2 > 0.01) { + // Yellow for subdivided, white for non-subdivided + const wireColor = subdivided.length > 1 ? [255, 255, 0, 255] : [255, 255, 255, 255]; + addWireframeLine(tri[0], tri[1], tri[5], tri[6], wireColor, "textured"); // v0 to v1 + addWireframeLine(tri[5], tri[6], tri[10], tri[11], wireColor, "textured"); // v1 to v2 + addWireframeLine(tri[10], tri[11], tri[0], tri[1], wireColor, "textured"); // v2 to v0 + } + } + } + } else { + // Get vertex colors (RGBA 0-1 range from vertex, convert to 0-255) + const color0 = [ + floor(s0.color[0] * 255), + floor(s0.color[1] * 255), + floor(s0.color[2] * 255), + floor(s0.color[3] * 255 * alphaMultiplier) + ]; + const color1 = [ + floor(s1.color[0] * 255), + floor(s1.color[1] * 255), + floor(s1.color[2] * 255), + floor(s1.color[3] * 255 * alphaMultiplier) + ]; + const color2 = [ + floor(s2.color[0] * 255), + floor(s2.color[1] * 255), + floor(s2.color[2] * 255), + floor(s2.color[3] * 255 * alphaMultiplier) + ]; + + // Draw filled gradient triangle using barycentric interpolation with Z-depth testing + drawGradientTriangle( + x0, y0, color0, s0.pos[2], // x, y, color, z + x1, y1, color1, s1.pos[2], + x2, y2, color2, s2.pos[2] + ); + + // Buffer wireframe overlay for gradient triangles + if (showWireframes) { + // Only add wireframes if all vertices have positive W (in front of camera) + const w0 = s0.pos[3]; + const w1 = s1.pos[3]; + const w2 = s2.pos[3]; + + if (w0 > 0.01 && w1 > 0.01 && w2 > 0.01) { + const green = [0, 255, 0, 255]; // Green for gradient triangles + addWireframeLine(x0, y0, x1, y1, green, "gradient"); // v0 to v1 + addWireframeLine(x1, y1, x2, y2, green, "gradient"); // v1 to v2 + addWireframeLine(x2, y2, x0, y0, green, "gradient"); // v2 to v0 + } + } + } + } + + // Buffer wireframe for the clipped polygon boundary (if clipping created extra vertices) + if (showWireframes && finalVertices.length > 3) { + const red = [255, 0, 0, 255]; + for (let k = 0; k < finalVertices.length; k++) { + const vA = finalVertices[k]; + const vB = finalVertices[(k + 1) % finalVertices.length]; + + // Only add wireframe if both vertices are in front of camera + if (vA.pos[3] > 0.01 && vB.pos[3] > 0.01) { + addWireframeLine(vA.pos[0], vA.pos[1], vB.pos[0], vB.pos[1], red, "clipped"); + } + } + } + } + } + } + + // Still return transformed vertices for compatibility + const transformedVertices = []; + const matrix = mat4.multiply(mat4.create(), cameraMatrix, transformed); + this.vertices.forEach((vertex) => { + transformedVertices.push(vertex.transform(matrix)); + }); + + return transformedVertices; + } +} + +// Constants for accessing Vector / Position components. +const X = 0; +const Y = 1; +const Z = 2; +const W = 3; + +// A single point in space with color and texture coordinate information. +class Vertex { + static X = 0; + static Y = 1; + static Z = 2; + static W = 3; + + pos; + color; + texCoords; + normal; + constructor( + position = [0, 0, 0, 1], + color = [1, 1, 1, 1], + textureCoordinates = [0, 0], + normal = null, + ) { + this.pos = position; + this.color = color; + this.texCoords = textureCoordinates; + + if (normal !== null) { + this.normal = vec3.fromValues(...normal); + } else { + this.normal = vec3.fromValues(0, 0, 1); // Default normal pointing up + } + } + + // TODO: Optimize this function for large vertex counts. 22.10.13.00.14 + transform(matrix) { + // Camera + const vert = new Vertex( + vec4.transformMat4( + vec4.create(), + [ + this.pos[X] * -1, // FLIPPED + this.pos[Y], + this.pos[Z] * -1, // FLIPPED + this.pos[W], + ], + matrix, + ), + this.color, + this.texCoords, + ); + // console.log(matrix, vert); + return vert; + } + + transformWorld(matrix) { + return new Vertex( + vec4.transformMat4(vec4.create(), this.pos, matrix), + this.color, + this.texCoords, + ); + } +} + +// Sutherland-Hodgman clipping algorithm for clip-space (before perspective divide) +// This properly handles near-plane clipping by testing against w component +// Minimum clip-space Z kept after near-plane clipping. Because the current +// projection has W_clip = Z_view, this is effectively the minimum W we allow +// post-clip, and must be comfortably above 0 so perspective divide stays stable. +// Too small → near-camera triangles "shoot off" the screen. +// Too large → noticeable geometry popping right in front of the camera. +const NEAR_CLIP_Z = 0.02; +const MIN_PERSPECTIVE_W = 0.001; + +function clipInClipSpace(vertices, clippingBoundary) { + let clipped = []; + + function inside(vertex, edge) { + const p = vertex.pos; + const w = p[W]; + switch (edge) { + case "left": + return p[X] >= -w; + case "right": + return p[X] <= w; + case "bottom": + return p[Y] >= -w; + case "top": + return p[Y] <= w; + case "near": + // Clip at Z_clip >= NEAR_CLIP_Z. With this projection W_clip = Z_view and + // Z_clip ≈ Z_view - tiny, so this also guarantees W >= NEAR_CLIP_Z post-clip, + // which keeps the perspective divide numerically stable. A too-small threshold + // lets near-camera vertices through with tiny W, producing huge post-divide + // screen coordinates ("shooting off" triangles). + return p[Z] >= NEAR_CLIP_Z; + case "far": + return p[Z] <= w; + } + } + + function computeIntersection(v1, v2, edge) { + const p1 = v1.pos; + const p2 = v2.pos; + let t; + + switch (edge) { + case "left": + t = (-p1[W] - p1[X]) / ((p2[X] - p1[X]) + (p2[W] - p1[W])); + break; + case "right": + t = (p1[W] - p1[X]) / ((p2[X] - p1[X]) - (p2[W] - p1[W])); + break; + case "bottom": + t = (-p1[W] - p1[Y]) / ((p2[Y] - p1[Y]) + (p2[W] - p1[W])); + break; + case "top": + t = (p1[W] - p1[Y]) / ((p2[Y] - p1[Y]) - (p2[W] - p1[W])); + break; + case "near": + t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); + break; + case "far": + t = (p1[W] - p1[Z]) / ((p2[Z] - p1[Z]) - (p2[W] - p1[W])); + break; + } + + // Handle NaN/Inf and clamp t to valid range + if (!Number.isFinite(t)) t = 0.5; + t = max(0, min(1, t)); + + // Interpolate position (all components linear in clip space) + const newPos = vec4.lerp(vec4.create(), p1, p2, t); + + // Interpolate color linearly + const newColor = [ + v1.color[0] + (v2.color[0] - v1.color[0]) * t, + v1.color[1] + (v2.color[1] - v1.color[1]) * t, + v1.color[2] + (v2.color[2] - v1.color[2]) * t, + v1.color[3] + (v2.color[3] - v1.color[3]) * t + ]; + + // Interpolate texture coordinates linearly in clip space + // Perspective correction will happen during rasterization + const newTexCoords = [ + v1.texCoords[0] + (v2.texCoords[0] - v1.texCoords[0]) * t, + v1.texCoords[1] + (v2.texCoords[1] - v1.texCoords[1]) * t + ]; + + return new Vertex(newPos, newColor, newTexCoords); + } + + for (const edge of clippingBoundary) { + const input = clipped.length > 0 ? clipped : vertices; + clipped = []; + + if (input.length === 0) break; + + let prevVertex = input[input.length - 1]; + + for (let i = 0; i < input.length; i++) { + const curVertex = input[i]; + + if (inside(curVertex, edge)) { + if (!inside(prevVertex, edge)) { + const intersection = computeIntersection(prevVertex, curVertex, edge); + clipped.push(intersection); + } + clipped.push(curVertex); + } else if (inside(prevVertex, edge)) { + const intersection = computeIntersection(prevVertex, curVertex, edge); + clipped.push(intersection); + } + + prevVertex = curVertex; + } + } + + return clipped; +} + +// Legacy NDC-space clipping function (kept for compatibility) +// Sutherland-Hodgman clipping algorithm +function clip(vertices, clippingBoundary) { + let clipped = []; + + function inside(p, edge) { + switch (edge) { + case "left": + return p[X] >= -1; + case "right": + return p[X] <= 1; + case "bottom": + return p[Y] >= -1; + case "top": + return p[Y] <= 1; + case "near": + return p[Z] >= 0; + case "far": + return p[Z] <= 1; + } + } + + function computeIntersection(p1, p2, edge) { + let t; + switch (edge) { + case "left": + t = (-1 - p1[X]) / (p2[X] - p1[X]); + break; + case "right": + t = (1 - p1[X]) / (p2[X] - p1[X]); + break; + case "bottom": + t = (-1 - p1[Y]) / (p2[Y] - p1[Y]); + break; + case "top": + t = (1 - p1[Y]) / (p2[Y] - p1[Y]); + break; + case "near": + t = (0 - p1[Z]) / (p2[Z] - p1[Z]); + break; + case "far": + t = (1 - p1[Z]) / (p2[Z] - p1[Z]); + break; + } + + return vec4.lerp(vec4.create(), p1, p2, t); + } + + for (const edge of clippingBoundary) { + const input = clipped.length > 0 ? clipped : vertices; + clipped = []; + + if (input.length === 0) break; + + let prevVertex = input[input.length - 1]; + + for (let i = 0; i < input.length; i++) { + const curVertex = input[i]; + + if (inside(curVertex.pos, edge)) { + if (!inside(prevVertex.pos, edge)) { + const intersection = computeIntersection( + prevVertex.pos, + curVertex.pos, + edge, + ); + clipped.push(new Vertex(intersection, curVertex.color)); + } + clipped.push(curVertex); + } else if (inside(prevVertex.pos, edge)) { + const intersection = computeIntersection( + prevVertex.pos, + curVertex.pos, + edge, + ); + clipped.push(new Vertex(intersection, curVertex.color)); + } + + prevVertex = curVertex; + } + } + + return clipped; +} + +// for 3d line clipping +function clipLineToFrustum(v1, v2) { + const clippingBoundary = ["left", "right", "bottom", "top", "near", "far"]; + + function inside(p, edge) { + switch (edge) { + case "left": + return p[X] >= -p[W]; + case "right": + return p[X] <= p[W]; + case "bottom": + return p[Y] >= -p[W]; + case "top": + return p[Y] <= p[W]; + case "near": + return p[Z] >= NEAR_CLIP_Z; + case "far": + return p[Z] <= p[W]; + } + } + + function computeIntersection(p1, p2, edge) { + let t; + switch (edge) { + case "left": + t = (-p1[W] - p1[X]) / (p2[X] - p1[X] + p2[W] - p1[W]); + break; + case "right": + t = (p1[W] - p1[X]) / (p2[X] - p1[X] - p2[W] + p1[W]); + break; + case "bottom": + t = (-p1[W] - p1[Y]) / (p2[Y] - p1[Y] + p2[W] - p1[W]); + break; + case "top": + t = (p1[W] - p1[Y]) / (p2[Y] - p1[Y] - p2[W] + p1[W]); + break; + case "near": + t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); + break; + case "far": + t = (p1[W] - p1[Z]) / (p2[Z] - p1[Z] - p2[W] + p1[W]); + break; + } + + if (!Number.isFinite(t)) t = 0.5; + t = max(0, min(1, t)); + + return t; + } + + let clippedVertices = [v1, v2]; + + for (const edge of clippingBoundary) { + if (clippedVertices.length < 2) break; + + const [p1, p2] = clippedVertices; + const p1Inside = inside(p1.pos, edge); + const p2Inside = inside(p2.pos, edge); + + if (p1Inside && p2Inside) { + // Both inside, keep both + continue; + } else if (!p1Inside && !p2Inside) { + // Both outside, discard both + clippedVertices = []; + break; + } else { + // One inside, one outside + const t = computeIntersection(p1.pos, p2.pos, edge); + const intersectionVertex = new Vertex( + vec4.lerp(vec4.create(), p1.pos, p2.pos, t), + [ + p1.color[0] + (p2.color[0] - p1.color[0]) * t, + p1.color[1] + (p2.color[1] - p1.color[1]) * t, + p1.color[2] + (p2.color[2] - p1.color[2]) * t, + p1.color[3] + (p2.color[3] - p1.color[3]) * t, + ], + [ + p1.texCoords[0] + (p2.texCoords[0] - p1.texCoords[0]) * t, + p1.texCoords[1] + (p2.texCoords[1] - p1.texCoords[1]) * t, + ], + ); + + if (p1Inside) { + // p1 inside, p2 outside + clippedVertices = [p1, intersectionVertex]; + } else { + // p1 outside, p2 inside + clippedVertices = [intersectionVertex, p2]; + } + } + } + + return clippedVertices; +} + +function perspectiveDivide(vertex) { + // Safety check: avoid division by very small W values + const w = vertex.pos[W]; + if (abs(w) < MIN_PERSPECTIVE_W) { + // Return a vertex marked as invalid (will be filtered out) + const vert = new Vertex([NaN, NaN, NaN, w]); + vert.color = vertex.color; + vert.texCoords = vertex.texCoords; + return vert; + } + + const vert = new Vertex([ + vertex.pos[X] / w, + vertex.pos[Y] / w, + vertex.pos[Z] / w, + w, + ]); + + vert.color = vertex.color; + vert.texCoords = vertex.texCoords; + + return vert; +} + +function toScreenSpace(vertex) { + // Flip Y. + const x = vertex.pos[X]; + const y = -vertex.pos[Y]; + const z = vertex.pos[Z]; + + const sX = ((x + 1.0) / 2.0) * width; + const sY = ((y + 1.0) / 2.0) * height; + + const vert = new Vertex([sX, sY, z, vertex.pos[W]]); + vert.color = vertex.color; + vert.texCoords = vertex.texCoords; + return vert; +} + +// Sutherland-Hodgman screen-space clipping for triangles +// Clips polygon to screen bounds [0, width] x [0, height] and interpolates attributes +function clipToScreen(vertices) { + if (vertices.length < 3) return vertices; + + const edges = [ + { axis: 'x', sign: 1, bound: 0 }, // left: x >= 0 + { axis: 'x', sign: -1, bound: width }, // right: x <= width + { axis: 'y', sign: 1, bound: 0 }, // top: y >= 0 + { axis: 'y', sign: -1, bound: height } // bottom: y <= height + ]; + + let polygon = vertices; + + for (const edge of edges) { + if (polygon.length < 3) break; + + const clipped = []; + const prev = polygon[polygon.length - 1]; + + for (let i = 0; i < polygon.length; i++) { + const curr = polygon[i]; + + const prevVal = edge.axis === 'x' ? prev.pos[0] : prev.pos[1]; + const currVal = edge.axis === 'x' ? curr.pos[0] : curr.pos[1]; + + const prevInside = edge.sign > 0 ? prevVal >= edge.bound : prevVal <= edge.bound; + const currInside = edge.sign > 0 ? currVal >= edge.bound : currVal <= edge.bound; + + if (currInside) { + if (!prevInside) { + // Entering: add intersection + const t = (edge.bound - prevVal) / (currVal - prevVal); + if (Number.isFinite(t) && t >= 0 && t <= 1) { + clipped.push(lerpVertex(prev, curr, t)); + } + } + clipped.push(curr); + } else if (prevInside) { + // Leaving: add intersection + const t = (edge.bound - prevVal) / (currVal - prevVal); + if (Number.isFinite(t) && t >= 0 && t <= 1) { + clipped.push(lerpVertex(prev, curr, t)); + } + } + } + + polygon = clipped; + } + + return polygon; +} + +// Interpolation between two vertices in screen space +// After perspective divide, 1/w and attribute/w vary linearly — not w or attributes directly +function lerpVertex(v1, v2, t) { + const w1 = v1.pos[3]; + const w2 = v2.pos[3]; + + // Fall back to simple linear interpolation if W values are degenerate + const safeW = + w1 > MIN_PERSPECTIVE_W && + w2 > MIN_PERSPECTIVE_W && + Number.isFinite(w1) && + Number.isFinite(w2); + + let newW; + if (safeW) { + // Perspective-correct: interpolate 1/w linearly in screen space + const invW1 = 1 / w1; + const invW2 = 1 / w2; + const newInvW = invW1 + (invW2 - invW1) * t; + newW = newInvW > 0.0001 ? 1 / newInvW : w1 + (w2 - w1) * t; + } else { + newW = w1 + (w2 - w1) * t; + } + + const vert = new Vertex([ + v1.pos[0] + (v2.pos[0] - v1.pos[0]) * t, + v1.pos[1] + (v2.pos[1] - v1.pos[1]) * t, + v1.pos[2] + (v2.pos[2] - v1.pos[2]) * t, + newW + ]); + + vert.color = [ + v1.color[0] + (v2.color[0] - v1.color[0]) * t, + v1.color[1] + (v2.color[1] - v1.color[1]) * t, + v1.color[2] + (v2.color[2] - v1.color[2]) * t, + v1.color[3] + (v2.color[3] - v1.color[3]) * t + ]; + + // Perspective-correct texCoord interpolation + if (safeW) { + const invW1 = 1 / w1, invW2 = 1 / w2; + const newInvW = invW1 + (invW2 - invW1) * t; + const tc1OverW = [v1.texCoords[0] * invW1, v1.texCoords[1] * invW1]; + const tc2OverW = [v2.texCoords[0] * invW2, v2.texCoords[1] * invW2]; + const safeDiv = newInvW > 0.0001 ? newInvW : 1; + vert.texCoords = [ + (tc1OverW[0] + (tc2OverW[0] - tc1OverW[0]) * t) / safeDiv, + (tc1OverW[1] + (tc2OverW[1] - tc1OverW[1]) * t) / safeDiv + ]; + } else { + vert.texCoords = [ + v1.texCoords[0] + (v2.texCoords[0] - v1.texCoords[0]) * t, + v1.texCoords[1] + (v2.texCoords[1] - v1.texCoords[1]) * t + ]; + } + + return vert; +} + +function zeroLineClip(vertices) { + const clipped = []; + + let prevVertex = vertices[vertices.length - 1]; // Start with the last vertex + let prevComponent = prevVertex.pos[Z]; + let prevInside = prevComponent >= 0; + + for (const curVertex of vertices) { + const curComponent = curVertex.pos[Z]; + const curInside = curComponent >= 0; + + if (curInside !== prevInside) { + // Edge crosses the z=0 plane, find intersection + const t = prevComponent / (prevComponent - curComponent); + const intersectionPos = [ + prevVertex.pos[X] + t * (curVertex.pos[X] - prevVertex.pos[X]), + prevVertex.pos[Y] + t * (curVertex.pos[Y] - prevVertex.pos[Y]), + 0, // z = 0 + prevVertex.pos[W] + t * (curVertex.pos[W] - prevVertex.pos[W]), + ]; + + // Interpolate color as well + const intersectionColor = [ + prevVertex.color[0] + t * (curVertex.color[0] - prevVertex.color[0]), + prevVertex.color[1] + t * (curVertex.color[1] - prevVertex.color[1]), + prevVertex.color[2] + t * (curVertex.color[2] - prevVertex.color[2]), + prevVertex.color[3] + t * (curVertex.color[3] - prevVertex.color[3]), + ]; + + clipped.push(new Vertex(intersectionPos, intersectionColor)); + } + + if (curInside) { + clipped.push(curVertex); + } + + prevVertex = curVertex; + prevComponent = curComponent; + prevInside = curInside; + } +} + +// Function to set KidLisp evaluation context for dynamic fade directions +function setKidLispContext(kidlispInstance, api, env) { + currentKidLispContext = { + evaluate: kidlispInstance.evaluate.bind(kidlispInstance), + api, + env, + frameCount: kidlispInstance.frameCount + }; +} + +// Function to clear KidLisp evaluation context +function clearKidLispContext() { + currentKidLispContext = null; +} + +export { + clear, + point, + plot, + flood, + colorsMatch, + pan, + unpan, + savepan, + loadpan, + mask, + unmask, + skip, + copy, + resize, + contrast, + brightness, + invert, + paste, + stamp, + steal, + putback, + line, + pline, + pixelPerfectPolyline, + lineAngle, + circle, + pie, + oval, + poly, + box, + tri, + shape, + grid, + draw, + noise16, + noise16DIGITPAIN, + noise16Aesthetic, + noise16Sotce, + noiseTinted, + printLine, + blendMode, + scroll, + flip, + resetScrollState, + spin, + zoom, + suck, + blur, + sharpen, + cleanupBlurBuffers, + sort, + shear, + setBlockProcessing, + Camera, + Form, + Dolly, + sign, + cacheSignGlyph, + clearSignGlyphCache, + clipInClipSpace, + setShowClippedWireframes, + clearWireframeBuffer, + drawBufferedWireframes, + getRenderStats, + renderStats, + setKidLispContext, + clearKidLispContext, + setEraseTarget, + setGpuSpin, + setGpuFlood, + setGpuContrast, + setGpuLayerComposite, + setGpuConfig, + compositeLayers, + batchedEffects, + initGpuEffects, + getGpuStatus, + onGpuFailover, + gpuLogTick, +}; diff --git a/easel/context/reference/lib/num.mjs b/easel/context/reference/lib/num.mjs new file mode 100644 index 000000000..fb5c697e7 --- /dev/null +++ b/easel/context/reference/lib/num.mjs @@ -0,0 +1,1380 @@ +// 🧮 Numbers +// import * as quat from "../dep/gl-matrix/quat.mjs"; +// import * as mat3 from "../dep/gl-matrix/mat3.mjs"; +// import * as mat4 from "../dep/gl-matrix/mat4.mjs"; +// import * as vec2 from "../dep/gl-matrix/vec2.mjs"; +// import * as vec3 from "../dep/gl-matrix/vec3.mjs"; +// import * as vec4 from "../dep/gl-matrix/vec4.mjs"; + +// export { vec2, vec3, vec4, mat3, mat4, quat }; + +const { + abs, + round, + floor, + ceil, + random, + PI, + min, + max, + sqrt, + pow, + atan2, + sin, + cos, +} = Math; + +// Utilities for modifying {x, y} points. +export const p2 = { + // Turn two values into an {x, y} point. + of: function (x, y) { + return { x, y }; + }, + // Get the length of the point as a vector. + len: function (pA) { + return Math.hypot(pA.x, pA.y); + }, + // Normalize a vector to have a length of 1 + norm: function (p) { + let len = this.len(p); + return len === 0 ? { x: 0, y: 0 } : { x: p.x / len, y: p.y / len }; + }, + // Check for the equality of two points. + eq: function (p1, p2) { + return p1.x === p2.x && p1.y === p2.y; + }, + // Mutably add P->in to P->out. + inc: function (pout, pin) { + pout.x += pin.x; + pout.y += pin.y; + return pout; + }, + // Mutably scale P->out by P->in. + scl: function (pout, pin) { + pout.x *= pin.x || pin; + pout.y *= pin.y || pin; + return pout; + }, + // Immutably add pA + pB. + add: function (pA, pB) { + return { + x: pA.x + pB.x, + y: pA.y + pB.y, + }; + }, + // Immutably sub pA - pB. + sub: function (pA, pB) { + return { + x: pA.x - pB.x, + y: pA.y - pB.y, + }; + }, + // Immutably rotate p by angle in radians. + rot(p, angle) { + return { + x: p.x * cos(angle) - p.y * sin(angle), + y: p.x * sin(angle) + p.y * cos(angle), + }; + }, + // Immutably multiply pA * pB. + mul: function (pA, pB) { + return { + x: pA.x * (pB.x || pB), + y: pA.y * (pB.y || pB), + }; + }, + // Immutably divide pA / pB. + // If pA is a single number then this function expands it to an `{x, y}`. + // Note: Other library functions here could do the same. 2023.1.19 + div: function (pA, pB) { + if (typeof pA === "number") pA = { x: pA, y: pA }; + return { + x: pA.x / pB.x, + y: pA.y / pB.y, + }; + }, + mid: function (pA, pB) { + return { + x: (pA.x + pB.x) / 2, + y: (pA.y + pB.y) / 2, + }; + }, + dist: function (pA, pB) { + // Note: There is also a more generic distance function in `num`. + return sqrt(pow(pB.x - pA.x, 2) + pow(pB.y - pA.y, 2)); + }, + angle: function (pA, pB) { + return atan2(pB.y - pA.y, pB.x - pA.x); + }, + dot: function (pA, pB) { + return pA.x * pB.x + pA.y * pB.y; + }, + floor: function (p) { + return { x: floor(p.x), y: floor(p.y) }; + }, +}; + +// Add all numbers in an array or all parameters. +export function add(...args) { + let numbers; + if (Array.isArray(args[0])) { + numbers = args[0]; + } else { + numbers = args; + } + return numbers.reduce((acc, curr) => acc + curr, 0); +} + +// Find the midpoint between two [x, y] coordinates. +export function midp(a, b) { + return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; +} + +// Determine if the value is a number or not. +export function number(maybeNumber) { + return typeof maybeNumber === "number"; +} + +// Line Intersection +// https://stackoverflow.com/a/24392281 +// accepts 8 arguments or 2 {x0, y0, x1, y1} describing 2 lines +// returns true if the line from (a,b)->(c,d) intersects with (p,q)->(r,s) +export function intersects() { + let a, b, c, d, p, q, r, s; + if ( + arguments.length === 2 && + typeof arguments[0] === "object" && + typeof arguments[1] === "object" + ) { + a = arguments[0].x0; + b = arguments[0].y0; + c = arguments[0].x1; + d = arguments[0].y1; + p = arguments[1].x0; + q = arguments[1].y0; + r = arguments[1].x1; + s = arguments[1].y1; + } else if (arguments.length === 8) { + [a, b, c, d, p, q, r, s] = arguments; + } else { + console.warn("Invalid intersection input."); + return false; + } + + let det, lambda, gamma; + det = (c - a) * (s - q) - (r - p) * (d - b); + if (det === 0) { + return false; + } else { + lambda = ((s - q) * (r - a) + (p - r) * (s - b)) / det; + gamma = ((b - d) * (r - a) + (c - a) * (s - b)) / det; + return 0 < lambda && lambda < 1 && 0 < gamma && gamma < 1; + } +} + +// Ceil an integer (ignoring its sign) +// export function signedCeil(n) { +// return n < 0 ? floor(n) : ceil(n); +// } + +// // Floor an integer (ignoring its sign) +// export function signedFloor(val) { +// let adjustment = 0; +// if (val < 0) adjustment = -1; +// return Math.floor(val) + adjustment; +// }; + +// Ceil a number away from 0 +export function signedCeil(n) { + return n < 0 ? Math.floor(n) : Math.ceil(n); +} + +// Floor a number towards 0 +export function signedFloor(val) { + return val < 0 ? Math.ceil(val) : Math.floor(val); +} + +// Wraps a number around 0 through a max. +export function wrap(n, to) { + // n %= to; // This should work the same. + // if (n < 0) n = to + n; + return (n / to - floor(n / to)) * to; +} + +// Returns true if the number is even, and false otherwise. +export function even(n) { + return n % 2 === 0; +} + +export function odd(n) { + return !even(n); +} + +// Accepts integer from 0–16 +// Yields 17 different values between 0–255. +export function byteInterval17(i16) { + return min(i16 * 16, 255); +} + +// Return a random float from 0->1. +export function rand() { + return random(); +} + +// Generates an integer from 0-n (inclusive) +export function randInt(n) { + return floor(rand() * (n + 1)); +} + +// Returns a random key from an object. +export function anyKey(obj) { + const keys = Object.keys(obj); + return keys[randInt(keys.length - 1)]; +} + +// Generates a random index from an array. +export function randInd(arr) { + return randInt(arr.length - 1); +} + +// Generates an array of random integers from 0-n (inclusive) +// TODO: How could this be made more generic? 22.1.5 +// TODO: How to make this account for range? 2022.01.17.00.33 +export function randIntArr(n, count) { + return Array(count).fill(n).map(randInt); +} + +// Generates an integer from low-high (inclusive) +export function randIntRange(low, high) { + return low + randInt(high - low); +} + +// Converts an array of strings formatted like "1-100" into an array of +// random integer ranges. +// 📓 Used for parsing ranged params (usually colors) inside of pieces. +export function rangedInts(ints) { + if (ints[0] === undefined) return; + return ints.map((str) => { + if (str.match(/^\d+-\d+$/)) { + const range = str.split("-"); + return randIntRange(parseInt(range[0]), parseInt(range[1])); + } else { + if (str === "?") return randInt(255); + return parseInt(str); + } + }); +} + +// Multiplies one or more [] operands by n and returns a Number or Array. +export function multiply(operands, n) { + if (Array.isArray(operands)) { + return operands.map((o) => o * n); + } else { + return operands * n; + } +} + +// Gets the distance between two points. +// (4) x1, y1, x2, y2 +// (2) {x, y}, {x, y} +export function dist() { + let x1, y1, x2, y2; + + if (arguments.length === 4) { + x1 = arguments[0]; + y1 = arguments[1]; + x2 = arguments[2]; + y2 = arguments[3]; + } else if (arguments.length === 2) { + x1 = arguments[0].x; + y1 = arguments[0].y; + x2 = arguments[1].x; + y2 = arguments[1].y; + } + + const dx = x2 - x1; + const dy = y2 - y1; + return sqrt(dx * dx + dy * dy); +} + +// TODO: Would 6 decimal places work? (Do some research) 22.11.08.00.21 +export function dist3d(p1, p2) { + // Convert everything to 4 decimal places. + // Drop some precision here so vec3.dist (Math.hypot) doesn't go crazy + // on my Float32Array values. + if (p1.buffer) p1 = p1.map((p) => p.toPrecision(4)); + if (p2.buffer) p2 = p2.map((p) => p.toPrecision(4)); + + //get dist directly by using p1.x - p2.x, p1.y - p2.y, p1.z - p2.z + const dx = p1[0] - p2[0]; + const dy = p1[1] - p2[1]; + const dz = p1[2] - p2[2]; + return sqrt(dx * dx + dy * dy + dz * dz); + // return Number(vec3.dist(p1, p2).toPrecision(4)); +} + +// Converts degrees to radians. +export function radians(deg = 0) { + if (isNaN(deg)) deg = 0; + return deg * (PI / 180); +} + +// Converts radians to degrees. +export function degrees(rad) { + return rad * (180 / PI); +} + +// Keeps a value between min and max. +export function clamp(value, low, high) { + return min(max(value, low), high); +} + +// Returns a sine wave value between -1 and 1 for the given phase. +export function wave(phase) { + return sin(phase); +} + +export function within(range, a, b) { + return abs(a - b) < range; +} + +// Slides a number between a and b by a normalized amount. +export function lerp(a, b, amount) { + return a + (b - a) * clamp(amount, 0, 1); +} + +// Maps a number within a range to a new range. +// https://stackoverflow.com/a/23202637/8146077 +export function map(num, inMin, inMax, outMin, outMax) { + return ((num - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; +} + +// Perlin noise in 2D. +export function perlin(x, y) { + var grad3 = [ + [1, 1, 0], + [-1, 1, 0], + [1, -1, 0], + [-1, -1, 0], + [1, 0, 1], + [-1, 0, 1], + [1, 0, -1], + [-1, 0, -1], + [0, 1, 1], + [0, -1, 1], + [0, 1, -1], + [0, -1, -1], + ]; + var p = []; + for (var i = 0; i < 256; i++) { + p[i] = Math.floor(Math.random() * 256); + } + var perm = []; + for (var i = 0; i < 512; i++) { + perm[i] = p[i & 255]; + } + + function dot(g, x, y) { + return g[0] * x + g[1] * y; + } + + var F2 = 0.5 * (Math.sqrt(3.0) - 1.0); + var s = (x + y) * F2; + var i = Math.floor(x + s); + var j = Math.floor(y + s); + var G2 = (3.0 - Math.sqrt(3.0)) / 6.0; + var t = (i + j) * G2; + var X0 = i - t; + var Y0 = j - t; + var x0 = x - X0; + var y0 = y - Y0; + + var i1, j1; + if (x0 > y0) { + i1 = 1; + j1 = 0; + } else { + i1 = 0; + j1 = 1; + } + + var x1 = x0 - i1 + G2; + var y1 = y0 - j1 + G2; + var x2 = x0 - 1.0 + 2.0 * G2; + var y2 = y0 - 1.0 + 2.0 * G2; + + var ii = i & 255; + var jj = j & 255; + var gi0 = perm[ii + perm[jj]] % 12; + var gi1 = perm[ii + i1 + perm[jj + j1]] % 12; + var gi2 = perm[ii + 1 + perm[jj + 1]] % 12; + + var t0 = 0.5 - x0 * x0 - y0 * y0; + var n0, n1, n2; + if (t0 < 0) n0 = 0.0; + else { + t0 *= t0; + n0 = t0 * t0 * dot(grad3[gi0], x0, y0); + } + + var t1 = 0.5 - x1 * x1 - y1 * y1; + if (t1 < 0) n1 = 0.0; + else { + t1 *= t1; + n1 = t1 * t1 * dot(grad3[gi1], x1, y1); + } + + var t2 = 0.5 - x2 * x2 - y2 * y2; + if (t2 < 0) n2 = 0.0; + else { + t2 *= t2; + n2 = t2 * t2 * dot(grad3[gi2], x2, y2); + } + + return 70.0 * (n0 + n1 + n2); +} + +// Return the maximum number in an array. +export function arrMax(arr) { + return arr.reduce((top, current) => max(top, current), -Infinity); +} + +// Return a new array with every n index missing. +export function arrCompress(arr, n) { + return arr.filter((_, index) => (index + 1) % n === 0); +} + +// Returns a string of numbers based on local system time. YYYY.MM.DD.HH.MM.SS:MS +export function timestamp() { + const d = new Date(); + const pad = (n, digits = 2) => n.toString().padStart(digits, "0"); + return ` + ${d.getFullYear()}. + ${pad(d.getMonth() + 1)}. + ${pad(d.getDate())}. + ${pad(d.getHours())}. + ${pad(d.getMinutes())}. + ${pad(d.getSeconds())}. + ${pad(d.getMilliseconds(), 3)}`.replace(/\s/g, ""); +} + +// A. Lerps over a single value (from->to) via `progress` (0->1). +// B. Quantizes over an array of individual `values` via `progress` (0->1). +// TODO: Allow `progress` to be 0->N which would map to an index in `values`. +export class Track { + #values; + #result; + #quantize; + + constructor(values, result) { + this.#values = values; + this.#result = result; + this.#quantize = Array.isArray(values); + } + + step(progress) { + if (this.#quantize) { + const index = min( + floor(progress * this.#values.length), + this.#values.length - 1, + ); + this.#result(this.#values[index]); + } else { + this.#result(lerp(this.#values.from, this.#values.to, progress)); + } + } +} + +function cleanHexString(h) { + return h.replace("#", "").replace("0x", "").toUpperCase(); +} + +// Determines if a string is a valid hex value. +export function isHexString(h) { + h = cleanHexString(h); + const a = parseInt(h, 16); + return a.toString(16) === h.toLowerCase(); +} + +// 🌈 Colors +// Parses a color from piece params. +// Including numeric ranges (0-255) and string matching. +export function parseColor(params) { + if (params.length === 0) return params; + const int = parseInt(params[0]); + + if (!isNaN(int) || params.length > 2) { + // Could be "? ? ?" here hence the length check. + // Assume all params can be parsed into integers (excepting alpha) + if (params.length === 2 || params.length === 4) { + const alpha = calculateAlpha(params[params.length - 1]); + params[params.length - 1] = alpha.toString(); + } + + return rangedInts(params); + } else { + let name = params[0].toLowerCase(); // Assume a css color string. + + let alpha = calculateAlpha(params[1]); // Calculate alpha param. + + if (name === "?") name = anyKey(cssColors); // Pick a name if `?` is passed. + + // Check for fade syntax first (new feature) + if (name.startsWith("fade:")) { + return { + type: "fade", + fadeString: name, + alpha: alpha, + originalParams: params + }; + } + + // Check for color index format like "c151" + const indexColor = parseColorIndex(name); + if (indexColor) { + return [...indexColor, alpha]; + } + + if (name in cssColors) { + return [...cssColors[name], alpha]; + } else if (name === "erase") { + return [-1, -1, -1, alpha]; + } else if (name === "rainbow") { + return ["rainbow", alpha]; // Cycle through the "rainbow" colors here. + } else if (name === "zebra") { + return ["zebra", alpha]; // Cycle through black/white zebra colors. + } else { + return [0, 0, 0, alpha]; // Default to black if color name is not found + } + } +} + +// Just used in parseColor above. +// TODO: Float has no range support. +function calculateAlpha(alphaParam) { + if (alphaParam === "?") return randIntRange(5, 255); // Always show a bit. + let alpha = parseFloat(alphaParam); + if (alpha >= 0 && alpha <= 1) { + alpha = round(alpha * 255); // Convert a float alpha from 0->1. + } else { + alpha = rangedInts([alphaParam]) || 255; + } + return alpha; +} + +// Helper function to check if parseColor result is a fade +export function isFadeColor(colorResult) { + return colorResult && + typeof colorResult === "object" && + !Array.isArray(colorResult) && + colorResult.type === "fade"; +} + +/* +export function parseColor(params) { + const int = parseInt(params[0]); + if (!isNaN(int)) { + // assume all params can be parsed into integers + + // TODO: + // Check to see if params length is 2 + // and check the alpha of the 2nd param similar to below. + // Check to see if params length is 4 and do the same. + // Wrap this alpha calculation in a function. + + return rangedInts(params); + } else { + // assume params represents a css color string + const name = params[0].toLowerCase(); + + // Calculate additional alpha param for named colors. + let alpha = parseFloat(params[1]); + if (alpha >= 0 && alpha <= 1) { + alpha = round(alpha * 255); // Convert a float alpha from 0->1. + } else { + alpha = parseInt(params[1]) || 255; + } + + // Check for color index format like "c151" + const indexColor = parseColorIndex(name); + if (indexColor) { + return [...indexColor, alpha]; + } + + if (name in cssColors) { + return [...cssColors[name], alpha]; + } else { + return [0, 0, 0, alpha]; // default to black if color name is not found + } + } +} +*/ + +// All 147 named css colors. +export const cssColors = { + aliceblue: [240, 248, 255], + antiquewhite: [250, 235, 215], + aqua: [0, 255, 255], + aquamarine: [127, 255, 212], + azure: [240, 255, 255], + beige: [245, 245, 220], + bisque: [255, 228, 196], + black: [0, 0, 0], + blanchedalmond: [255, 235, 205], + blue: [0, 0, 255], + blueviolet: [138, 43, 226], + brown: [165, 42, 42], + burlywood: [222, 184, 135], + cadetblue: [95, 158, 160], + chartreuse: [127, 255, 0], + chocolate: [210, 105, 30], + coral: [255, 127, 80], + cornflowerblue: [100, 149, 237], + cornsilk: [255, 248, 220], + crimson: [220, 20, 60], + cyan: [0, 255, 255], + darkblue: [0, 0, 139], + darkcyan: [0, 139, 139], + darkgoldenrod: [184, 134, 11], + darkgray: [169, 169, 169], + darkgrey: [169, 169, 169], + darkgreen: [0, 100, 0], + darkkhaki: [189, 183, 107], + darkmagenta: [139, 0, 139], + darkolivegreen: [85, 107, 47], + darkorange: [255, 140, 0], + darkorchid: [153, 50, 204], + darkred: [139, 0, 0], + darksalmon: [233, 150, 122], + darkseagreen: [143, 188, 143], + darkslateblue: [72, 61, 139], + darkslategray: [47, 79, 79], + darkslategrey: [47, 79, 79], + darkturquoise: [0, 206, 209], + darkviolet: [148, 0, 211], + deeppink: [255, 20, 147], + deepskyblue: [0, 191, 255], + dimgray: [105, 105, 105], + dimgrey: [105, 105, 105], + dodgerblue: [30, 144, 255], + firebrick: [178, 34, 34], + floralwhite: [255, 250, 240], + forestgreen: [34, 139, 34], + fuchsia: [255, 0, 255], + gainsboro: [220, 220, 220], + ghostwhite: [248, 248, 255], + gold: [255, 215, 0], + goldenrod: [218, 165, 32], + gray: [128, 128, 128], + grey: [128, 128, 128], + green: [0, 128, 0], + greenyellow: [173, 255, 47], + honeydew: [240, 255, 240], + hotpink: [255, 105, 180], + indianred: [205, 92, 92], + indigo: [75, 0, 130], + ivory: [255, 255, 240], + khaki: [240, 230, 140], + lavender: [230, 230, 250], + lavenderblush: [255, 240, 245], + lawngreen: [124, 252, 0], + lemonchiffon: [255, 250, 205], + lightblue: [173, 216, 230], + lightcoral: [240, 128, 128], + lightcyan: [224, 255, 255], + lightgoldenrodyellow: [250, 250, 210], + lightgray: [211, 211, 211], + lightgrey: [211, 211, 211], + lightgreen: [144, 238, 144], + lightpink: [255, 182, 193], + lightsalmon: [255, 160, 122], + lightseagreen: [32, 178, 170], + lightskyblue: [135, 206, 250], + lightslategray: [119, 136, 153], + lightslategrey: [119, 136, 153], + lightsteelblue: [176, 196, 222], + lightyellow: [255, 255, 224], + lime: [0, 255, 0], + limegreen: [50, 205, 50], + linen: [250, 240, 230], + magenta: [255, 0, 255], + maroon: [128, 0, 0], + mediumaquamarine: [102, 205, 170], + mediumblue: [0, 0, 205], + mediumorchid: [186, 85, 211], + mediumpurple: [147, 112, 219], + mediumseagreen: [60, 179, 113], + mediumslateblue: [123, 104, 238], + mediumspringgreen: [0, 250, 154], + mediumturquoise: [72, 209, 204], + mediumvioletred: [199, 21, 133], + midnightblue: [25, 25, 112], + mintcream: [245, 255, 250], + mistyrose: [255, 228, 225], + moccasin: [255, 228, 181], + navajowhite: [255, 222, 173], + navy: [0, 0, 128], + oldlace: [253, 245, 230], + olive: [128, 128, 0], + olivedrab: [107, 142, 35], + orange: [255, 165, 0], + orangered: [255, 69, 0], + orchid: [218, 112, 214], + palegoldenrod: [238, 232, 170], + palegreen: [152, 251, 152], + paleturquoise: [175, 238, 238], + palevioletred: [219, 112, 147], + papayawhip: [255, 239, 213], + peachpuff: [255, 218, 185], + peru: [205, 133, 63], + pink: [255, 192, 203], + plum: [221, 160, 221], + powderblue: [176, 224, 230], + purple: [128, 0, 128], + rebeccapurple: [102, 51, 153], + red: [255, 0, 0], + rosybrown: [188, 143, 143], + royalblue: [65, 105, 225], + saddlebrown: [139, 69, 19], + salmon: [250, 128, 114], + sandybrown: [244, 164, 96], + seagreen: [46, 139, 87], + seashell: [255, 245, 238], + sienna: [160, 82, 45], + silver: [192, 192, 192], + skyblue: [135, 206, 235], + slateblue: [106, 90, 205], + slategray: [112, 128, 144], + slategrey: [112, 128, 144], + snow: [255, 250, 250], + springgreen: [0, 255, 127], + steelblue: [70, 130, 180], + tan: [210, 180, 140], + teal: [0, 128, 128], + thistle: [216, 191, 216], + tomato: [255, 99, 71], + turquoise: [64, 224, 208], + violet: [238, 130, 238], + wheat: [245, 222, 179], + white: [255, 255, 255], + whitesmoke: [245, 245, 245], + yellow: [255, 255, 0], + yellowgreen: [154, 205, 50], + // Custom brown colors for AC + darkbrown: [101, 67, 33], + darkerbrown: [62, 39, 35], + darksienna: [139, 90, 43], +}; + +// 🌈 Rainbow color cycling. +let currentRainbowIndex = 0; +let frameRainbowColor = null; // Cache the rainbow color for this frame +let rainbowFrameAdvanced = false; // Track if we've advanced this frame + +const rainbowColors = [ + cssColors.red, + cssColors.orange, + cssColors.yellow, + cssColors.green, + cssColors.blue, + cssColors.indigo, + cssColors.violet, +]; + +export function rainbow(offset = 0) { + // Advance the rainbow index once per frame on the first call + if (!rainbowFrameAdvanced) { + currentRainbowIndex = (currentRainbowIndex + 1) % rainbowColors.length; + frameRainbowColor = rainbowColors[currentRainbowIndex].slice(); + rainbowFrameAdvanced = true; + } + + // Calculate the final index with offset from the current frame's base + const finalIndex = (currentRainbowIndex + offset) % rainbowColors.length; + const result = rainbowColors[finalIndex]; + return result.slice(); +} + +// 🦓 Zebra color cycling (black/white alternating). +let currentZebraIndex = 0; +let frameZebraColor = null; // Cache the zebra color for this frame +let zebraFrameAdvanced = false; // Track if we've advanced this frame + +const zebraColors = [ + cssColors.black, // [0, 0, 0] + cssColors.white, // [255, 255, 255] +]; + +export function zebra(offset = 0) { + // Advance the zebra index once per frame on the first call + if (!zebraFrameAdvanced) { + currentZebraIndex = (currentZebraIndex + 1) % zebraColors.length; + frameZebraColor = zebraColors[currentZebraIndex].slice(); + zebraFrameAdvanced = true; + } + + // Calculate the final index with offset from the current frame's base + const finalIndex = (currentZebraIndex + offset) % zebraColors.length; + return zebraColors[finalIndex].slice(); +} + +// Reset zebra frame cache - should be called once per frame/execution +export function resetZebraCache() { + zebraFrameAdvanced = false; + frameZebraColor = null; +} + +// Get current zebra state for persistence +export function getZebraState() { + return { + currentZebraIndex, + zebraFrameAdvanced, + frameZebraColor: frameZebraColor ? frameZebraColor.slice() : null + }; +} + +// Set zebra state from persistence +export function setZebraState(state) { + if (state) { + currentZebraIndex = state.currentZebraIndex || 0; + zebraFrameAdvanced = state.zebraFrameAdvanced || false; + frameZebraColor = state.frameZebraColor ? state.frameZebraColor.slice() : null; + } +} + +// Reset rainbow frame cache - should be called once per frame/execution +export function resetRainbowCache() { + rainbowFrameAdvanced = false; + frameRainbowColor = null; +} + +// Get current rainbow state for persistence +export function getRainbowState() { + return { + currentRainbowIndex, + rainbowFrameAdvanced, + frameRainbowColor: frameRainbowColor ? frameRainbowColor.slice() : null + }; +} + +// Set rainbow state from persistence +export function setRainbowState(state) { + if (state) { + currentRainbowIndex = state.currentRainbowIndex || 0; + rainbowFrameAdvanced = state.rainbowFrameAdvanced || false; + frameRainbowColor = state.frameRainbowColor ? state.frameRainbowColor.slice() : null; + } +} + +// Find a color inside of `cssColors` by value. +export function findColor(rgb) { + for (let name in cssColors) { + if ( + cssColors[name][0] === rgb[0] && + cssColors[name][1] === rgb[1] && + cssColors[name][2] === rgb[2] + ) { + return name; + } + } +} + +// Create organized color index system +// First 16: Standard web colors +// Then organized by color families (reds, oranges, yellows, greens, blues, purples, etc.) +export const organizedColorIndex = [ + // 0-15: Standard 16 web colors (c0=black, c1=white) + "black", "white", "red", "lime", "blue", "yellow", "cyan", "magenta", + "silver", "gray", "maroon", "olive", "green", "purple", "teal", "navy", + + // 17-32: Additional reds and pinks + "crimson", "darkred", "firebrick", "indianred", "lightcoral", "salmon", + "darksalmon", "lightsalmon", "pink", "lightpink", "hotpink", "deeppink", + "palevioletred", "mediumvioletred", "coral", "tomato", + + // 33-48: Oranges + "orange", "darkorange", "orangered", "chocolate", "saddlebrown", "sienna", + "brown", "rosybrown", "sandybrown", "goldenrod", "darkgoldenrod", "peru", + "burlywood", "tan", "navajowhite", "bisque", + + // 49-64: Yellows and golds + "gold", "palegoldenrod", "khaki", "darkkhaki", "moccasin", "wheat", + "lemonchiffon", "lightgoldenrodyellow", "lightyellow", "beige", "cornsilk", + "blanchedalmond", "papayawhip", "antiquewhite", "linen", "oldlace", + + // 65-80: Greens + "forestgreen", "darkgreen", "darkolivegreen", "darkseagreen", "limegreen", + "seagreen", "mediumseagreen", "springgreen", "mediumspringgreen", "palegreen", + "lightgreen", "lawngreen", "chartreuse", "greenyellow", "yellowgreen", "olivedrab", + + // 81-96: Blues and cyans + "aqua", "darkturquoise", "turquoise", "mediumturquoise", "paleturquoise", + "lightcyan", "cadetblue", "steelblue", "lightsteelblue", "powderblue", + "lightblue", "skyblue", "lightskyblue", "deepskyblue", "dodgerblue", "cornflowerblue", + + // 97-112: More blues + "royalblue", "mediumblue", "darkblue", "midnightblue", "slateblue", + "darkslateblue", "mediumslateblue", "mediumpurple", "blueviolet", "indigo", + "darkorchid", "darkviolet", "mediumorchid", "thistle", "plum", "violet", + + // 113-128: Purples and magentas + "orchid", "fuchsia", "darkmagenta", "mediumvioletred", "lavenderblush", + "mistyrose", "lavender", "ghostwhite", "azure", "aliceblue", "mintcream", + "honeydew", "seashell", "ivory", "floralwhite", "snow", + + // 129-144: Grays and remaining colors + "gainsboro", "lightgray", "lightgrey", "darkgray", "darkgrey", "dimgray", + "dimgrey", "lightslategray", "lightslategrey", "slategray", "slategrey", + "darkslategray", "darkslategrey", "whitesmoke", "rebeccapurple" +]; + +// Add any remaining CSS colors that weren't included above +const remainingColors = Object.keys(cssColors).filter(color => + !organizedColorIndex.includes(color) +); +export const completeColorIndex = [...organizedColorIndex, ...remainingColors]; + +// Get color by CSS index (c0, c1, etc.) +export function getColorByIndex(index) { + // Use 0-based indexing (c0 = first color) + if (index >= 0 && index < completeColorIndex.length) { + const colorName = completeColorIndex[index]; + return { + name: colorName, + rgb: cssColors[colorName] + }; + } + return null; +} + +// Palette colors (p0 = rainbow, etc.) +export const paletteColors = { + 0: "rainbow", // p0 = rainbow + 1: "zebra", // p1 = zebra + // Add more palette colors here as needed +}; + +// Get palette color by index (p1, p2, etc.) +export function getPaletteByIndex(index) { + if (index in paletteColors) { + return paletteColors[index]; + } + return null; +} + +// Static color index mapping - STANDARDIZED and PERMANENT for aesthetic.computer +export const staticColorMap = { + // 0-15: Core web colors (never change these) + 0: [0, 0, 0], // c0 = black + 1: [255, 255, 255], // c1 = white + 2: [255, 0, 0], // c2 = red + 3: [0, 255, 0], // c3 = lime + 4: [0, 0, 255], // c4 = blue + 5: [255, 255, 0], // c5 = yellow + 6: [0, 255, 255], // c6 = cyan + 7: [255, 0, 255], // c7 = magenta + 8: [192, 192, 192], // c8 = silver + 9: [128, 128, 128], // c9 = gray + 10: [128, 0, 0], // c10 = maroon + 11: [128, 128, 0], // c11 = olive + 12: [0, 128, 0], // c12 = green + 13: [128, 0, 128], // c13 = purple + 14: [0, 128, 128], // c14 = teal + 15: [0, 0, 128], // c15 = navy + + // 16-31: Red spectrum + 16: [220, 20, 60], // c16 = crimson + 17: [139, 0, 0], // c17 = darkred + 18: [178, 34, 34], // c18 = firebrick + 19: [205, 92, 92], // c19 = indianred + 20: [240, 128, 128], // c20 = lightcoral + 21: [250, 128, 114], // c21 = salmon + 22: [233, 150, 122], // c22 = darksalmon + 23: [255, 160, 122], // c23 = lightsalmon + 24: [255, 192, 203], // c24 = pink + 25: [255, 182, 193], // c25 = lightpink + 26: [255, 105, 180], // c26 = hotpink + 27: [255, 20, 147], // c27 = deeppink + 28: [219, 112, 147], // c28 = palevioletred + 29: [199, 21, 133], // c29 = mediumvioletred + 30: [255, 127, 80], // c30 = coral + 31: [255, 99, 71], // c31 = tomato + + // 32-47: Orange spectrum + 32: [255, 69, 0], // c32 = orangered + 33: [255, 140, 0], // c33 = darkorange + 34: [255, 165, 0], // c34 = orange + 35: [255, 215, 0], // c35 = gold + 36: [255, 218, 185], // c36 = peachpuff + 37: [255, 228, 196], // c37 = bisque + 38: [255, 239, 213], // c38 = papayawhip + 39: [255, 228, 181], // c39 = moccasin + 40: [255, 222, 173], // c40 = navajowhite + 41: [245, 222, 179], // c41 = wheat + 42: [222, 184, 135], // c42 = burlywood + 43: [210, 180, 140], // c43 = tan + 44: [188, 143, 143], // c44 = rosybrown + 45: [205, 133, 63], // c45 = peru + 46: [244, 164, 96], // c46 = sandybrown + 47: [160, 82, 45], // c47 = saddlebrown + + // 48-63: Yellow/Green spectrum + 48: [255, 248, 220], // c48 = cornsilk + 49: [255, 255, 240], // c49 = ivory + 50: [255, 250, 205], // c50 = lemonchiffon + 51: [250, 250, 210], // c51 = lightgoldenrodyellow + 52: [240, 230, 140], // c52 = khaki + 53: [238, 232, 170], // c53 = palegoldenrod + 54: [189, 183, 107], // c54 = darkkhaki + 55: [154, 205, 50], // c55 = yellowgreen + 56: [124, 252, 0], // c56 = lawngreen + 57: [127, 255, 0], // c57 = chartreuse + 58: [173, 255, 47], // c58 = greenyellow + 59: [50, 205, 50], // c59 = limegreen + 60: [152, 251, 152], // c60 = palegreen + 61: [144, 238, 144], // c61 = lightgreen + 62: [0, 250, 154], // c62 = mediumspringgreen + 63: [0, 255, 127], // c63 = springgreen + + // 64-79: Green spectrum + 64: [46, 125, 50], // c64 = forestgreen + 65: [34, 139, 34], // c65 = forestgreen + 66: [0, 100, 0], // c66 = darkgreen + 67: [85, 107, 47], // c67 = darkolivegreen + 68: [107, 142, 35], // c68 = olivedrab + 69: [102, 205, 170], // c69 = mediumaquamarine + 70: [127, 255, 212], // c70 = aquamarine + 71: [176, 196, 222], // c71 = lightsteelblue + 72: [175, 238, 238], // c72 = paleturquoise + 73: [0, 206, 209], // c73 = darkturquoise + 74: [72, 209, 204], // c74 = mediumturquoise + 75: [64, 224, 208], // c75 = turquoise + 76: [0, 139, 139], // c76 = darkcyan + 77: [95, 158, 160], // c77 = cadetblue + 78: [70, 130, 180], // c78 = steelblue + 79: [176, 224, 230], // c79 = powderblue + + // 80-95: Blue spectrum + 80: [173, 216, 230], // c80 = lightblue + 81: [135, 206, 235], // c81 = skyblue + 82: [135, 206, 250], // c82 = lightskyblue + 83: [0, 191, 255], // c83 = deepskyblue + 84: [30, 144, 255], // c84 = dodgerblue + 85: [100, 149, 237], // c85 = cornflowerblue + 86: [123, 104, 238], // c86 = mediumslateblue + 87: [106, 90, 205], // c87 = slateblue + 88: [72, 61, 139], // c88 = darkslateblue + 89: [25, 25, 112], // c89 = midnightblue + 90: [0, 0, 139], // c90 = darkblue + 91: [0, 0, 205], // c91 = mediumblue + 92: [65, 105, 225], // c92 = royalblue + 93: [138, 43, 226], // c93 = blueviolet + 94: [75, 0, 130], // c94 = indigo + 95: [72, 0, 72], // c95 = darkmagenta + + // 96-111: Purple/Violet spectrum + 96: [153, 50, 204], // c96 = darkorchid + 97: [186, 85, 211], // c97 = mediumorchid + 98: [218, 112, 214], // c98 = orchid + 99: [221, 160, 221], // c99 = plum + 100: [238, 130, 238], // c100 = violet + 101: [255, 0, 255], // c101 = fuchsia (same as magenta) + 102: [208, 32, 144], // c102 = violetred + 103: [199, 21, 133], // c103 = mediumvioletred + 104: [219, 112, 147], // c104 = palevioletred + 105: [255, 105, 180], // c105 = hotpink + 106: [255, 20, 147], // c106 = deeppink + 107: [220, 20, 60], // c107 = crimson + 108: [139, 69, 19], // c108 = saddlebrown + 109: [160, 82, 45], // c109 = saddlebrown + 110: [205, 133, 63], // c110 = peru + 111: [222, 184, 135], // c111 = burlywood + + // 112-127: Browns and earth tones + 112: [245, 245, 220], // c112 = beige + 113: [255, 248, 220], // c113 = cornsilk + 114: [255, 235, 205], // c114 = blanchedalmond + 115: [245, 222, 179], // c115 = wheat + 116: [255, 228, 181], // c116 = moccasin + 117: [255, 218, 185], // c117 = peachpuff + 118: [210, 180, 140], // c118 = tan + 119: [188, 143, 143], // c119 = rosybrown + 120: [244, 164, 96], // c120 = sandybrown + 121: [205, 133, 63], // c121 = peru + 122: [160, 82, 45], // c122 = saddlebrown + 123: [139, 69, 19], // c123 = saddlebrown + 124: [101, 67, 33], // c124 = darkbrown + 125: [62, 39, 35], // c125 = darkerbrown + 126: [139, 90, 43], // c126 = darksienna + 127: [165, 42, 42], // c127 = brown +}; + +// Parse color index string like "c0", "c1", "p0" and return color +export function parseColorIndex(indexString) { + if (typeof indexString === 'string') { + if (indexString.startsWith('c')) { + const index = parseInt(indexString.substring(1), 10); + + // Use standardized static color map + if (!isNaN(index) && staticColorMap[index]) { + const color = staticColorMap[index]; + // Validate color array + if (Array.isArray(color) && color.length >= 3 && + color[0] !== undefined && color[1] !== undefined && color[2] !== undefined) { + return color; + } + } + } else if (indexString.startsWith('p')) { + const index = parseInt(indexString.substring(1), 10); + if (!isNaN(index)) { + const palette = getPaletteByIndex(index); + if (palette === "rainbow") { + return ["rainbow"]; // Special marker for rainbow + } else if (palette === "zebra") { + return ["zebra"]; // Special marker for zebra + } + } + } + } + return null; +} + +// Alpha blends two colors, mutating and returning `dst`. +// Transcribed from C++: https://stackoverflow.com/a/12016968 +export function blend(dst, src, alphaIn = 1) { + if (src[3] === 0) return; // Return early if src is invalid. + + // Assume we are erasing if first channel is negative. + if (src[0] === -1) { + // (All three should be negative for an `erase`.) + const normalizedAlpha = 1 - src[3] / 255; + dst[3] *= normalizedAlpha; + return; + } + + // Otherwise continue to blend. + if (src[3] === undefined) src[3] = 255; // Max alpha if it's not present. + const alpha = src[3] * alphaIn + 1; + const invAlpha = 256 - alpha; + dst[0] = (alpha * src[0] + invAlpha * dst[0]) >> 8; + dst[1] = (alpha * src[1] + invAlpha * dst[1]) >> 8; + dst[2] = (alpha * src[2] + invAlpha * dst[2]) >> 8; + dst[3] = dst[3] + alpha; + return dst; +} + +// Lerp two RGBA arrays, skipping alpha and rounding the output. +// (Assumes 0-255) +export function shiftRGB(a, b, step, mode = "lerp", range = 255) { + const low = range === 255 ? [1, 10] : [0.01, 0.05]; + const high = range === 255 ? [245, 250] : [0.92, 0.95]; + + if (mode === "add" || mode === "subtract") { + if (mode === "subtract") step *= -1; + const shifted = [ + clamp(a[0] + a[0] * step, randIntRange(...low), randIntRange(...high)), + clamp(a[1] + a[1] * step, randIntRange(...low), randIntRange(...high)), + clamp(a[2] + a[2] * step, randIntRange(...low), randIntRange(...high)), + range, + ]; + //if (range === 255) return shifted.map((v) => round(v)); + cc + } else if (mode === "step") { + const shifted = [ + towards(a[0], b[0], step), + towards(a[1], b[1], step), + towards(a[2], b[2], step), + range, + ]; + console.log(a[0], b[0], step); + //if (range === 255) return shifted.map((v) => round(v)); + return shifted; + } else { + const shifted = [ + lerp(a[0], b[0], step), + lerp(a[1], b[1], step), + lerp(a[2], b[2], step), + range, + ]; + // if (range === 255) return shifted.map((v) => round(v)); + return shifted; + } +} + +// Move towards a target definitely and linearly. (Not quite lerping) +function towards(from, to, by) { + return from < to ? min(from + by, to) : max(from - by, to); +} + +// Convert separate rgb values to a single integer. +export function rgbToHex(r, g, b) { + return (1 << 24) + (r << 16) + (g << 8) + b; +} + +// The same, as a string. (Via: https://stackoverflow.com/a/5624139) +// Optionally add a prefix like "#" or "0x" +export function rgbToHexStr(r, g, b, prefix = "") { + return prefix + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1); +} + +// Takes either a string hex or a number hex and outputs and [RGB] array. +// TODO: Take in alpha. +export function hexToRgb(h) { + const int = typeof h === "string" ? parseInt(cleanHexString(h), 16) : h; + const out = [(int >> 16) & 255, (int >> 8) & 255, int & 255]; + return out; +} + +// The below was adapted from: https://codepen.io/Elliotclyde/pen/MWyRezZ + +// TODO: The two functions below could be refactored and combined pretty easily. 22.11.19.00.30 +// Saturate a color by `amount` +export function saturate(rgb, amount = 1) { + const hadAlpha = rgb.length === 4; + const alpha = rgb[3]; + rgb = rgb.slice(0, 3); + + const grey = lightness(rgb) * 255; + + const [low, mid, high] = getLowestMiddleHighest(rgb); + + if (low.val === high.val) return rgb; // Return grey if we are there already. + + const saturationRange = round(min(255 - grey, grey)); + const maxChange = min(255 - high.val, low.val); + const changeAmount = min(saturationRange * amount, maxChange); + const middleValueRatio = (grey - mid.val) / (grey - high.val); + + const out = []; + out[high.index] = round(high.val + changeAmount); + out[low.index] = round(low.val - changeAmount); + out[mid.index] = round(grey + (out[high.index] - grey) * middleValueRatio); + if (hadAlpha) out[3] = alpha; // Add alpha channel back if it exists. + return out; +} +// Desaturate a color by `amount` +export function desaturate(rgb, amount = 1) { + const hadAlpha = rgb.length === 4; + const alpha = rgb[3]; + rgb = rgb.slice(0, 3); + + const [low, mid, high] = getLowestMiddleHighest(rgb); + const grey = lightness(rgb) * 255; + + if (low.val === high.val) return rgb; // Return grey if we are there already. + + const saturationRange = round(min(255 - grey, grey)); + const maxChange = grey - low.val; + const changeAmount = min(saturationRange * amount, maxChange); + const middleValueRatio = (grey - mid.val) / (grey - high.val); + + const out = []; + out[high.index] = round(high.val - changeAmount); + out[low.index] = round(low.val + changeAmount); + out[mid.index] = round(grey + (out[high.index] - grey) * middleValueRatio); + if (hadAlpha) out[3] = alpha; // Add alpha channel back if it exists. + return out; +} + +// These support `saturate` and `desaturate` above. + +function lightness(rgb) { + // Get the highest and lowest out of red green and blue + const highest = max(...rgb); + const lowest = min(...rgb); + return (highest + lowest) / 2 / 255; // Return the average divided by 255 +} + +function getLowestMiddleHighest(rgb) { + let high = { val: 0, index: -1 }; + let low = { val: Infinity, index: -1 }; + + rgb.map((val, index) => { + if (val > high.val) { + high = { val: val, index: index }; + } + if (val < low.val) { + low = { val: val, index: index }; + } + }); + + const mid = { index: 3 - high.index - low.index }; + mid.val = rgb[mid.index]; + return [low, mid, high]; +} + +// Convert rgb to hsl (360, 100, 100). +export function rgbToHsl(r, g, b) { + (r /= 255), (g /= 255), (b /= 255); + const max = Math.max(r, g, b), + min = Math.min(r, g, b); + let h, + s, + l = (max + min) / 2; + + if (max == min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h /= 6; + } + return [h * 360, s * 100, l * 100]; +} + +// Convert hsl (360, 100, 100) to rgb. +export function hslToRgb(h, s, l) { + s /= 100; + l /= 100; + + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + + let r = 0, + g = 0, + b = 0; + + if (0 <= h && h < 60) { + r = c; + g = x; + b = 0; + } else if (60 <= h && h < 120) { + r = x; + g = c; + b = 0; + } else if (120 <= h && h < 180) { + r = 0; + g = c; + b = x; + } else if (180 <= h && h < 240) { + r = 0; + g = x; + b = c; + } else if (240 <= h && h < 300) { + r = x; + g = 0; + b = c; + } else if (300 <= h && h < 360) { + r = c; + g = 0; + b = x; + } + + r = Math.round((r + m) * 255); + g = Math.round((g + m) * 255); + b = Math.round((b + m) * 255); + + return [r, g, b]; +} diff --git a/easel/context/reference/lib/ui.mjs b/easel/context/reference/lib/ui.mjs new file mode 100644 index 000000000..c8bcb24a9 --- /dev/null +++ b/easel/context/reference/lib/ui.mjs @@ -0,0 +1,1186 @@ +import { Box } from "./geo.mjs"; +import { radians, p2 } from "./num.mjs"; +const { round } = Math; + +const spinnerDelayMax = 8; // Skip a few frames of the spinner. +let spinnerDelay = 0; + +let TYPEFACE_UI; // A default `Typeface` instance carried over from `disk`. + +// Strip \color\ codes from text to get visible length +// Format: \r,g,b\text\r,g,b\ -> text +function stripColorCodes(text) { + if (!text || typeof text !== 'string') return text; + // Remove all \...\ patterns (color codes) + return text.replace(/\\[^\\]*\\/g, ''); +} + +function spinnerReset() { + spinnerDelay = 0; +} + +function defaultRolloverScheme($, small = false) { + const mode = $?.dark ? $?.theme?.dark : $?.theme?.light; + const bg = mode?.buttonHover || mode?.buttonBg || ($?.dark ? [24, 32, 42] : [220, 245, 255]); + const outline = mode?.buttonHoverOutline || mode?.buttonOutline || [255, 220, 0]; + const text = mode?.buttonHoverText || mode?.buttonText || ($?.dark ? 255 : 0); + return small ? [bg, outline, text, bg] : [bg, outline, text, 255]; +} + +// Loading icon. +function spinner(ctx, timePassed) { + if (spinnerDelay < spinnerDelayMax) { + spinnerDelay += 1; + return; + } + const gap = 20, + s = 8; + ctx.save(); + const scaledWidth = ctx.canvas.width / window.devicePixelRatio; + ctx.translate(scaledWidth - (s + gap), s + gap); + ctx.rotate(radians(timePassed % 360) * 1); + ctx.beginPath(); + // \ of the X + ctx.moveTo(-s, -s); // top left + ctx.lineTo(s, s); // bottom right + // / of the X + //ctx.moveTo(-s, s); // bottom left + //ctx.lineTo(s, -s); // top right + ctx.strokeStyle = "rgb(255, 255, 0)"; + ctx.lineWidth = 4; + ctx.lineCap = "round"; + ctx.stroke(); + ctx.restore(); +} + +// Paused icon. +function cached(ctx) { + const gap = 4, + s = 20; + + ctx.save(); + ctx.translate(round(gap / 2) + 6, round(gap / 2) + 4); + + // Bottom Left option. + ctx.translate( + 0, + round(ctx.canvas.height / window.devicePixelRatio - s * 2 + 2), + ); + + ctx.beginPath(); + + ctx.moveTo(gap, gap); // left + ctx.lineTo(gap, s); + ctx.moveTo(gap * 3.5, gap); // right + ctx.lineTo(gap * 3.5, s); + + ctx.strokeStyle = "rgb(0, 255, 255)"; + ctx.lineWidth = 4; + ctx.lineCap = "round"; + ctx.stroke(); + + ctx.restore(); +} + +// Global tracking for drag-between-buttons behavior +const activeButtons = new Set(); // Track which buttons are currently active +const hoveredButtons = new Set(); // Shared cursor state across all ui.Button instances. + +function updateHoverCursor(event) { + for (const button of hoveredButtons) + if (!button.over) hoveredButtons.delete(button); + event?.cursor?.(hoveredButtons.size ? "active" : "precise"); +} + +// Debug: Track recent rollout events to detect retap-after-rollout issues +const recentRollouts = new Map(); // buttonId -> timestamp +const ROLLOUT_MEMORY_TIME = 2000; // Remember rollouts for 2 seconds + +function trackRollout(buttonId) { + recentRollouts.set(buttonId, performance.now()); + // Clean up old entries + for (const [id, timestamp] of recentRollouts.entries()) { + if (performance.now() - timestamp > ROLLOUT_MEMORY_TIME) { + recentRollouts.delete(id); + } + } +} + +function wasRecentlyRolledOut(buttonId) { + const rolloutTime = recentRollouts.get(buttonId); + return rolloutTime && (performance.now() - rolloutTime < ROLLOUT_MEMORY_TIME); +} +function addActiveButton(btn, reason = "unknown", netLog = null) { + activeButtons.add(btn); + const logData = { + buttonId: btn.id || "unnamed", + reason, + totalActive: activeButtons.size, + down: btn.down, + downPointer: btn.downPointer + }; + + // console.log("➕ Button added to activeButtons:", logData); + + // Send to remote debugging if available + if (netLog) { + netLog.info("🔘 Button activated:", logData); + } + + // Emergency cleanup if we have too many active buttons + if (activeButtons.size > 5) { + if (netLog) netLog.warn("🚨 Emergency cleanup: too many active buttons:", activeButtons.size); + emergencyButtonCleanup("too many active buttons detected: " + activeButtons.size, netLog); + } +} + +function removeActiveButton(btn, reason = "unknown", netLog = null) { + const wasPresent = activeButtons.has(btn); + activeButtons.delete(btn); + if (wasPresent) { + const logData = { + buttonId: btn.id || "unnamed", + reason, + totalActive: activeButtons.size, + down: btn.down, + downPointer: btn.downPointer + }; + // console.log("➖ Button removed from activeButtons:", logData); + if (netLog) { + netLog.info("🔘 Button deactivated:", logData); + } + } else { + const logData = { + buttonId: btn.id || "unnamed", + reason + }; + console.warn("⚠️ Tried to remove button not in activeButtons:", logData); + if (netLog) { + netLog.warn("⚠️ Button removal attempted but not found:", logData); + } + } +} + +// Debug: Track button state inconsistencies +let debugButtonStateChecks = 0; +function debugActiveButtonsState() { + debugButtonStateChecks++; + if (debugButtonStateChecks % 60 === 0) { // Log every 60 calls (roughly once per second at 60fps) + const activeButtonsArray = Array.from(activeButtons); + const stuckButtons = activeButtonsArray.filter(btn => !btn.down); + + if (stuckButtons.length > 0) { + console.warn("🐛 INCONSISTENT BUTTON STATE DETECTED:", { + totalActive: activeButtonsArray.length, + stuckButtons: stuckButtons.map(btn => ({ + id: btn.id || "unnamed", + down: btn.down, + over: btn.over, + downPointer: btn.downPointer + })), + allButtons: activeButtonsArray.map(btn => ({ + id: btn.id || "unnamed", + down: btn.down, + over: btn.over, + downPointer: btn.downPointer + })) + }); + + // Clean up stuck buttons - MUST call cancel callback to stop sounds + stuckButtons.forEach(btn => { + console.log("🧹 Cleaning up stuck button:", btn.id || "unnamed"); + // Call cancel callback to properly clean up (e.g., stop sounds in notepat) + btn.actions?.cancel?.(btn); + btn.over = false; + btn.downPointer = undefined; + activeButtons.delete(btn); + }); + } + } +} + +// Manual button state reset function (for emergency use) +function resetAllButtons(netLog = null) { + console.log("🔄 MANUAL BUTTON RESET - clearing all active buttons"); + if (netLog) { + netLog.info("🔄 Manual button reset initiated"); + } + + const buttonsToReset = Array.from(activeButtons); + buttonsToReset.forEach(btn => { + console.log("🔄 Resetting button:", btn.id || "unnamed"); + btn.down = false; + btn.over = false; + btn.downPointer = undefined; + removeActiveButton(btn, "manual reset", netLog); + }); +} + +// Emergency cleanup function for when we detect stuck buttons +function emergencyButtonCleanup(reason = "unknown", netLog = null) { + console.warn("🚨 EMERGENCY BUTTON CLEANUP:", reason); + if (netLog) { + netLog.error("🚨 Emergency button cleanup triggered:", { reason }); + } + + const activeButtonsArray = Array.from(activeButtons); + + // Log the state before cleanup + const buttonStates = activeButtonsArray.map(btn => ({ + id: btn.id || "unnamed", + down: btn.down, + over: btn.over, + downPointer: btn.downPointer + })); + + console.log("📊 Active buttons before cleanup:", buttonStates); + if (netLog) { + netLog.info("📊 Emergency cleanup button states:", buttonStates); + } + + // Reset all buttons - MUST call cancel callback to stop sounds + activeButtonsArray.forEach(btn => { + // Call cancel callback to properly clean up (e.g., stop sounds in notepat) + btn.actions?.cancel?.(btn); + btn.down = false; + btn.over = false; + btn.downPointer = undefined; + activeButtons.delete(btn); + }); + + console.log("🧹 Emergency cleanup complete - all buttons reset"); + if (netLog) { + netLog.info("🧹 Emergency cleanup completed:", { buttonsReset: activeButtonsArray.length }); + } +} + +// Expose functions globally for debugging +if (typeof window !== 'undefined') { + window.resetAllButtons = resetAllButtons; + window.emergencyButtonCleanup = emergencyButtonCleanup; +} + +// An interactive button model. +class Button { + btn; + box; + down = false; + disabled = false; + icon; + dom = false; + over = false; // Keep track of rollover state. + multitouch = true; // Toggle to false to make a single touch button2. + downPointer; // Keep track of what original pointer downed the button. + actions; // A held list of callbacks for virtually triggering events. + noEdgeDetection = false; // Set to true to opt out of global edge detection cancellation + noRolloverActivation = false; // Set to true to prevent activation via rollover from other buttons + stickyScrubbing = false; // Set to true to prevent rollover activation when scrubbing from another button + offScreenScrubbing = false; // Set to true to allow scrubbing to continue off-screen but still allow horizontal rollover + + get up() { + return !this.down; + } + + set up(value) { + this.down = !value; + } + + // (x, y, width, height) or Box + constructor() { + if (arguments.length === 1) { + // Assume we are passing in a box {x,y,w,h} object. + this.box = Box.from(arguments[0]); + } else this.box = new Box(...arguments); // Otherwise: x, y, w, h for a box. + this.btn = this; + } + + publishToDom({ send }, label, message) { + // The only use case for this right now is the Clipboard API. 23.06.16.15.40 + // 📓 Where `message` is used as text to be copied. + send({ + type: "button:hitbox:add", + content: { box: this.box, label, message }, + }); + } + + removeFromDom({ send }, label) { + send({ type: "button:hitbox:remove", content: label }); + } + + // For using in a piece's `act` function. Contains callbacks for + // events that take place inside the button. + // Usage: act(e, () => {}); // For 'push' callback only. + // act(e, {push: () => {}, down: () => {}, cancel: () => {}, draw() => {}}); + // You can optionally pass in an array of `pens` {x, y} for multi-touch support. + act(e, callbacks = () => {}, pens = []) { + const btn = this.btn; + if (btn.disabled) { + if (btn.over) { + btn.over = false; + hoveredButtons.delete(btn); + updateHoverCursor(e); + } + return; + } + + // Extract net.log for remote debugging + const netLog = e.net?.log; + + // Debug: Check for inconsistent button states + debugActiveButtonsState(); + + // If only a single function is sent, then assume it's a button push callback. + if (typeof callbacks === "function") callbacks = { push: callbacks }; + btn.actions = callbacks; + + // Handle global edge detection cancellation + if (e.is("ui:cancel-interactions") && !btn.noEdgeDetection) { + // Only cancel if the button is still down (not already cancelled by other means) + // For offScreenScrubbing buttons, don't cancel if cursor is horizontally within bounds + // For stickyScrubbing buttons, always cancel when mouse leaves screen to prevent sounds getting stuck + const shouldCancel = btn.down && (!btn.offScreenScrubbing || + (btn.offScreenScrubbing && (e.x < btn.box.x || e.x >= btn.box.x + btn.box.w))); + + if (shouldCancel) { + console.log("🚫 Global edge detection - button cancelled:", { + buttonId: btn.id || "unnamed", + timestamp: performance.now(), + reason: "ui:cancel-interactions event", + wasDown: btn.down, + offScreenScrubbing: btn.offScreenScrubbing, + cursorX: e.x, + buttonBounds: { x: btn.box.x, w: btn.box.w } + }); + + btn.down = false; + btn.over = false; + hoveredButtons.delete(btn); + updateHoverCursor(e); + btn.downPointer = undefined; + removeActiveButton(btn, "global edge detection", netLog); // Remove from activeButtons Set + callbacks.cancel?.(btn); + } + + // ADDITIONAL SAFETY: Even if shouldCancel is false, if this button is in activeButtons + // but shouldn't be, clean it up to prevent corruption + if (!shouldCancel && activeButtons.has(btn) && !btn.down) { + console.warn("🧹 Edge detection cleanup - removing orphaned button:", { + buttonId: btn.id || "unnamed", + reason: "button in activeButtons but not down during edge detection" + }); + removeActiveButton(btn, "edge detection orphan cleanup", netLog); + btn.over = false; + btn.downPointer = undefined; + } + + return; + } + + const t = this.multitouch ? "any" : "1"; + + // 🏷️ Block piece button interactions when HUD button is active + // This prevents buttons underneath the prompt label from receiving touch events + if (e.hudButtonActive && !btn.noEdgeDetection) { + // This is a piece button (has edge detection), and the HUD button is active + // Don't process any touch/down events for this button + return; + } + + // 1. Down: Enable the button if we touched over it. (Repeatable) + if (e.is(`touch:${t}`) && btn.box.contains(e) && !btn.down) { + const wasRecentRollout = wasRecentlyRolledOut(btn.id || "unnamed"); + // buttonId: btn.id || "unnamed", + // timestamp: performance.now(), + // pointer: e.pointer, + // eventType: "touch", + // wasInActiveButtons: activeButtons.has(btn), + // currentDownPointer: btn.downPointer, + // over: btn.over, + // wasRecentlyRolledOut: wasRecentRollout, + // suspiciousRetap: wasRecentRollout && activeButtons.has(btn) + // }); + + // SAFETY: If this button was somehow still in activeButtons but not down, clean it up first + if (activeButtons.has(btn) && !btn.down) { + console.warn("⚠️ CORRUPTED STATE DETECTED - button in activeButtons but not down:", { + buttonId: btn.id || "unnamed", + reason: "cleaning up before new touch", + wasRecentlyRolledOut: wasRecentRollout + }); + removeActiveButton(btn, "corrupted state cleanup before touch", netLog); + btn.over = false; + btn.downPointer = undefined; + } + + // EXTRA SAFETY: If this is a retap after recent rollout, ensure clean state + if (wasRecentRollout) { + console.log("🔄 Retap after recent rollout - ensuring clean state:", { + buttonId: btn.id || "unnamed" + }); + btn.over = false; + btn.downPointer = undefined; + if (activeButtons.has(btn)) { + removeActiveButton(btn, "retap after rollout cleanup", netLog); + } + } + + const downed = callbacks.down?.(btn); + btn.down = downed || downed === undefined ? true : false; + + // console.log("🔍 downPointer state before assignment:", { + // buttonId: btn.id || "unnamed", + // eventPointer: e.pointer, + // currentDownPointer: btn.downPointer, + // btnDown: btn.down, + // willSetDownPointer: btn.down && btn.downPointer === undefined + // }); + + if (btn.down && btn.downPointer === undefined) { + // console.log("🔧 Setting downPointer on initial touch:", { + // buttonId: btn.id || "unnamed", + // eventPointer: e.pointer, + // beforeDownPointer: btn.downPointer, + // afterDownPointer: e.pointer || 0 + // }); + btn.downPointer = e.pointer || 0; + } else if (btn.down && btn.downPointer !== undefined && btn.downPointer !== e.pointer) { + // In multitouch mode, allow downPointer to be transferred to the new pointer + // This prevents buttons from getting stuck when multiple pointers interact with them + if (this.multitouch) { + console.log("🔄 Transferring downPointer ownership:", { + buttonId: btn.id || "unnamed", + fromPointer: btn.downPointer, + toPointer: e.pointer, + reason: "multitouch pointer transfer" + }); + btn.downPointer = e.pointer || 0; + } else { + console.warn("⚠️ Button already has downPointer - NOT updating:", { + buttonId: btn.id || "unnamed", + eventPointer: e.pointer, + existingDownPointer: btn.downPointer, + reason: "downPointer was not cleaned up properly" + }); + } + } + btn.over = btn.down; + // Add to active buttons set + if (btn.down) { + addActiveButton(btn, "touch down", netLog); + } + } + + // 3. Push: Trigger the button if we push it. + // Only process lift events from the controlling pointer + const isControllingLiftPointer = !this.multitouch || btn.downPointer === e.pointer || btn.downPointer === undefined; + if (e.is(`lift:${t}`) && btn.down && isControllingLiftPointer) { + // buttonId: btn.id || "unnamed", + // timestamp: performance.now(), + // pointer: e.pointer, + // downPointer: btn.downPointer, + // eventType: "lift" + // }); + + function up() { + const up = callbacks.up?.(btn); + if (up === false) { + btn.down = true; + btn.over = true; + console.log("🔄 Button kept down by callback:", { + buttonId: btn.id || "unnamed", + reason: "up() returned false" + }); + } else { + btn.down = false; + btn.over = false; + btn.downPointer = undefined; + // Only remove if still in activeButtons (avoid double removal) + if (activeButtons.has(btn)) { + removeActiveButton(btn, "button up callback", netLog); + } + // console.log("🏁 Button fully released:", { + // buttonId: btn.id || "unnamed", + // activeButtonsCount: activeButtons.size + // }); + } + } + + // In multitouch mode, only respond to lift events from the controlling pointer + const isControllingPointer = !this.multitouch || btn.downPointer === e.pointer || btn.downPointer === undefined; + + // console.log("🔍 Pointer control analysis:", { + // buttonId: btn.id || "unnamed", + // multitouch: this.multitouch, + // eventPointer: e.pointer, + // buttonDownPointer: btn.downPointer, + // isControllingPointer, + // pensCount: pens?.length || 0 + // }); + + // Check if this is a valid button push or should be cancelled + const isValidPush = isControllingPointer && btn.box.contains(e); + // The lift event position (e.x, e.y) contains the position where the pointer was lifted. + // If it's inside the button box, it's a valid push regardless of where other pointers are. + + // console.log("📤 Button lift condition check:", { + // buttonId: btn.id || "unnamed", + // isControllingPointer, + // isValidPush, + // multitouch: this.multitouch, + // pensLength: pens?.length || 0, + // containsE: btn.box.contains(e), + // containsNonePens: btn.box.containsNone(pens || []), + // pointerMatch: btn.downPointer === e.pointer + // }); + + if (isValidPush) { + // console.log("🔴 Button lift - IMMEDIATE (valid push):", { + // buttonId: btn.id || "unnamed", + // timestamp: performance.now(), + // pointer: e.pointer + // }); + + btn.down = false; + btn.over = false; + // Only remove if still in activeButtons (avoid double removal from force cleanup) + if (activeButtons.has(btn)) { + removeActiveButton(btn, "valid push", netLog); + } + callbacks.push?.(btn); + + up(); + } else if (isControllingPointer && !btn.box.contains(e)) { + // Lift event happened outside the button - cancel the button + btn.down = false; + btn.over = false; + // Only remove if still in activeButtons (avoid double removal from force cleanup) + if (activeButtons.has(btn)) { + removeActiveButton(btn, "cancelled - lift outside", netLog); + } + callbacks.cancel?.(btn); + up(); + //console.log("Button up (cancel):", btn, pens); + } else { + // Check for stuck button scenario: button is down but controlling pointer doesn't match + // Force cleanup if pointer mismatch - phantom pointers need to be cleaned up aggressively + const isStuckButton = btn.down && !isControllingPointer; + + if (isStuckButton) { + // Force cleanup the stuck button + btn.down = false; + btn.over = false; + btn.downPointer = undefined; + removeActiveButton(btn, "force cleanup - stuck button", netLog); + callbacks.cancel?.(btn); + } else { + // This button is down but neither valid push nor cancel condition was met + console.log("⚠️ Button down but lift ignored:", { + buttonId: btn.id || "unnamed", + timestamp: performance.now(), + pointer: e.pointer, + downPointer: btn.downPointer, + isControllingPointer, + containsE: btn.box.contains(e), + pensLength: pens?.length || 0, + reason: "lift event ignored - waiting for controlling pointer" + }); + } + } + } + + // Note: Each piece may use the below to implement custom rolling behavior, + // which often differs among use cases such as pianos or general GUIs. + + // 4. Rollover: Run a rollover event if dragged on. + // Hover handling on pointer move: trigger hover/leave callbacks immediately. + if (e.is && e.is("move")) { + try { + const containsNow = btn.box.contains(e); + if (containsNow && !btn.over && !btn.down) { + // Enter hover (passive - no button pressed) + callbacks.hover?.(btn); + btn.over = true; + hoveredButtons.add(btn); + updateHoverCursor(e); + } else if (!containsNow && btn.over && !btn.down) { + // Leave hover (passive - no button pressed) + callbacks.leave?.(btn); + btn.over = false; + hoveredButtons.delete(btn); + updateHoverCursor(e); + } + } catch (err) { + // ignore + } + updateHoverCursor(e); + } + + // if (e.is("draw:any") && !this.down && this.box.contains(e)) { + + // For offScreenScrubbing, check if cursor is horizontally within bounds + const horizontallyWithin = + btn.offScreenScrubbing && e.x >= btn.box.x && e.x < btn.box.x + btn.box.w; + + if ( + e.is(`draw:${t}`) && + !btn.over && + (btn.box.contains(e) || horizontallyWithin) + ) { + // Check if we're dragging from another button + // Don't just check activeButtons.size since buttons can be removed during rollout but still logically down + // Instead, check if ANY button is down (indicating an active drag operation) + const anyButtonDown = Array.from(activeButtons).some(activeBtn => activeBtn.down) || + // Also check for buttons that might be down but not in activeButtons due to rollout + (e.drag && (e.drag.x !== e.x || e.drag.y !== e.y)); // Has actual drag movement + const isDraggingFromOtherButton = anyButtonDown && !btn.down; + + // Check if any active button has sticky scrubbing enabled + const hasStickyButton = Array.from(activeButtons).some( + (activeBtn) => activeBtn.stickyScrubbing, + ); + + // Only prevent rollover activation if: + // 1. This button doesn't allow rollover activation AND we're dragging from another button, OR + // 2. We're dragging from another button AND that button has sticky scrubbing enabled + const shouldPreventRollover = + (btn.noRolloverActivation && isDraggingFromOtherButton) || + (isDraggingFromOtherButton && hasStickyButton && btn.noRolloverActivation); + + if ( + isDraggingFromOtherButton && + !shouldPreventRollover && + (btn.box.contains(e) || horizontallyWithin) + ) { + // Only allow rollover activation if conditions are met + // In multitouch mode, only deactivate buttons that don't have their own active pointer + for (const otherBtn of activeButtons) { + if (otherBtn !== btn && !otherBtn.stickyScrubbing) { + // In multitouch mode, only deactivate if this is the same pointer or if the other button doesn't have a specific pointer + if (!this.multitouch || otherBtn.downPointer === e.pointer || otherBtn.downPointer === undefined) { + otherBtn.down = false; + otherBtn.over = false; + otherBtn.actions?.up?.(otherBtn); + removeActiveButton(otherBtn, "rollover deactivation", netLog); + } + } + } + + // Only activate this button if no sticky button conflicts + if (!hasStickyButton || btn.stickyScrubbing) { + btn.down = true; + btn.downPointer = e.pointer || 0; + addActiveButton(btn, "rollover activation", netLog); + callbacks.down?.(btn); + } + } + + // Always allow rollover callbacks for visual feedback + if ( + !shouldPreventRollover || + horizontallyWithin + ) { + if (callbacks.rollover) { + callbacks.rollover(btn); + } else { + callbacks.over?.(btn); + } + btn.over = true; + } + } + + // Scrub condition: allow if drag is within bounds OR if button is active from rollover + // For sticky scrubbing, allow scrubbing even when dragging outside bounds + // For offScreenScrubbing, allow scrubbing when horizontally within bounds even if vertically off-screen + const containsDrag = btn.box.contains(e.drag); + const inActiveButtons = activeButtons.has(btn); + const horizontallyWithinForOffScreen = + btn.offScreenScrubbing && + e.drag && + e.drag.x >= btn.box.x && + e.drag.x < btn.box.x + btn.box.w; + const allowScrub = + containsDrag || + (inActiveButtons && !btn.stickyScrubbing) || + (btn.stickyScrubbing && btn.down) || + (btn.offScreenScrubbing && btn.down && horizontallyWithinForOffScreen); + + if (e.is(`draw:${t}`) && btn.down && allowScrub) { + // In multitouch mode, only allow scrubbing from the controlling pointer unless special cases apply + const isControllingPointer = !this.multitouch || btn.downPointer === e.pointer || btn.downPointer === undefined; + + // Allow scrubbing if: + // 1. This is the controlling pointer (main case), OR + // 2. Button is active from rollover AND it's the same pointer that's dragging (preserves rollover but prevents cross-pointer contamination), OR + // 3. Sticky scrubbing is enabled, OR + // 4. OffScreen scrubbing with horizontal bounds check + if ( + isControllingPointer || + (inActiveButtons && !btn.stickyScrubbing && btn.downPointer === e.pointer) || // Rollover scrubbing with pointer match + (btn.stickyScrubbing && btn.down) || + (btn.offScreenScrubbing && btn.down && horizontallyWithinForOffScreen) + ) { + callbacks.scrub?.(btn); + } + } + + // 5. Rollout: Run a rollout event if dragged off. + if ( + e.is(`draw:${t}`) && + btn.over && + !btn.box.contains(e) && + btn.box.containsNone(pens) + ) { + // For stickyScrubbing buttons, never trigger rollout - they stay active until pointer is released + // For offScreenScrubbing buttons, only trigger rollout if we're also outside horizontal bounds + const shouldRollout = + !btn.stickyScrubbing && + (!btn.offScreenScrubbing || + (btn.offScreenScrubbing && + (e.x < btn.box.x || e.x >= btn.box.x + btn.box.w))); + + // In multitouch mode, only trigger rollout if this is the pointer that controls this button + const isControllingPointer = !this.multitouch || btn.downPointer === e.pointer || btn.downPointer === undefined; + + if (shouldRollout && isControllingPointer) { + // Track this rollout for detecting retap issues + trackRollout(btn.id || "unnamed"); + + // Call the rollout/out callbacks first + if (callbacks.rollout) { + callbacks.rollout(btn); + } else { + callbacks.out?.(btn); + } + + // Ensure button state is properly cleaned up after rollout + btn.over = false; + + // Always remove from activeButtons on rollout, regardless of down state + // This ensures we don't get stuck buttons in the activeButtons set + if (activeButtons.has(btn)) { + removeActiveButton(btn, "rollout - always remove from active", netLog); + } + + // If the button is still marked as down after the callback, clean up remaining state + if (btn.down) { + btn.down = false; + btn.downPointer = undefined; + } + } + } + + // Final safety check: if button is not down but still in activeButtons, remove it + // This catches any edge cases where callbacks modify button state but don't sync with activeButtons + if (!btn.down && activeButtons.has(btn)) { + removeActiveButton(btn, "safety cleanup - button not down", netLog); + } + } + + // Draws a callback if the button is not disabled. + paint(fn) { + if (!this.disabled) fn(this); + } + + enableIf(flag) { + this.disabled = !flag; + } +} + +// Slider UI control built on Button scrubbing. +class Slider { + box; + button; + min = 0; + max = 1; + value = 0; + step = 0; + dragging = false; + + constructor() { + let opts = {}; + if (arguments.length >= 4) { + if (typeof arguments[4] === "object") opts = arguments[4]; + this.box = new Box(arguments[0], arguments[1], arguments[2], arguments[3]); + } else { + this.box = Box.from(arguments[0]); + if (typeof arguments[1] === "object") opts = arguments[1]; + } + + this.min = Number.isFinite(opts.min) ? opts.min : 0; + this.max = Number.isFinite(opts.max) ? opts.max : 1; + this.step = Number.isFinite(opts.step) ? opts.step : 0; + this.value = Number.isFinite(opts.value) ? opts.value : this.min; + + this.button = new Button(this.box); + this.button.stickyScrubbing = true; + this.button.offScreenScrubbing = true; + this.button.noRolloverActivation = true; + } + + get normalized() { + const span = this.max - this.min || 1; + return (this.value - this.min) / span; + } + + setValue(value) { + let v = Math.max(this.min, Math.min(this.max, value)); + if (this.step > 0) { + const steps = Math.round((v - this.min) / this.step); + v = this.min + steps * this.step; + } + this.value = v; + } + + setNormalized(t) { + const clamped = Math.max(0, Math.min(1, t)); + this.setValue(this.min + clamped * (this.max - this.min)); + } + + _updateFromEvent(e) { + const t = (e.x - this.box.x) / this.box.w; + this.setNormalized(t); + } + + act(e, callbacks = {}) { + const onChange = callbacks.change; + this.button.act(e, { + down: () => { + this.dragging = true; + this._updateFromEvent(e); + callbacks.down?.(this); + onChange?.(this); + }, + scrub: () => { + this._updateFromEvent(e); + onChange?.(this); + }, + up: () => { + this.dragging = false; + callbacks.up?.(this); + }, + cancel: () => { + this.dragging = false; + callbacks.cancel?.(this); + }, + over: () => callbacks.over?.(this), + out: () => callbacks.out?.(this), + }); + } + + paint(draw) { + draw?.(this); + } +} + +class TextButton { + txt; + btn; + + #gap = 4; + #cw = 6; // Character width in pixels. Set from `typeface`. + #g2 = this.#gap * 2; + #h = 12 + this.#g2; // 19; // + #offset = { x: this.#gap, y: this.#gap }; + #typeface = null; + + constructor(text = "Button", pos = { x: 0, y: 0 }, typeface = TYPEFACE_UI, gap = null) { + // Allow custom gap/padding if provided + if (gap !== null) { + this.#gap = gap; + this.#g2 = this.#gap * 2; + this.#offset = { x: this.#gap, y: this.#gap }; + } + + this.#typeface = typeface; + this.#cw = typeface.blockWidth; + this.#h = typeface.blockHeight + this.#gap * 2; + + this.txt = text; + this.btn = new Button(this.#computePosition(text, { ...pos })); + } + + get act() { + return this.btn.act; + } + + set disabled(d) { + return (this.btn.disabled = d); + } + + get disabled() { + return this.btn.disabled; + } + + get down() { + return this.btn.down; + } + + set down(d) { + return (this.btn.down = d); + } + + get noEdgeDetection() { + return this.btn.noEdgeDetection; + } + + set noEdgeDetection(value) { + this.btn.noEdgeDetection = value; + } + + get stickyScrubbing() { + return this.btn.stickyScrubbing; + } + + set stickyScrubbing(value) { + this.btn.stickyScrubbing = value; + } + + #measureTextWidth(text) { + const visibleText = stripColorCodes(text); + if (this.#typeface?.getAdvance) { + let w = 0; + for (const ch of visibleText) w += this.#typeface.getAdvance(ch); + return w; + } + return visibleText.length * this.#cw; + } + + get width() { + return this.#measureTextWidth(this.txt) + this.#gap * 2; + } + + get height() { + return this.#h; + } + + // Compute position for box. + // pos: {x, y} or { top, left } for positioning. + // pos: {bottom, right} for bottom right... + // { center: "xy", screen } for screen centering. + #computePosition(text, pos = { x: 0, y: 0 }) { + pos = { ...pos }; // Make a shallow copy of pos because we will mutate it. + let x, y; + const w = this.#measureTextWidth(text) + this.#g2; + const h = this.#h; + + if (pos.screen) { + pos.screen.x = pos.screen.x || 0; + pos.screen.y = pos.screen.y || 0; + } + + if (pos.center === "xy") { + return { + x: pos.screen.x + (pos.x || 0) + pos.screen.width / 2 - w / 2, + y: pos.screen.y + (pos.y || 0) + pos.screen.height / 2 - h / 2, + w, + h, + }; + } + + if (pos.center === "x") { + x = (pos.screen?.x || 0) + (pos.screen?.width || 0) / 2 - w / 2; + } else { + x = (pos.screen?.x || 0) + (pos.x || 0); + if (pos.right !== undefined) { + x += pos.screen.width - pos.right - w; + } else { + x += pos.left || 0; + } + } + + y = (pos.screen?.y || 0) + (pos.y || 0); + if (pos.bottom !== undefined) { + y += pos.screen.height - pos.bottom - this.#h; + } else { + y += pos.top || 0; + } + + return { x, y, w, h }; + } + + // Update just the label text (keeps the same box when visible length matches). + replaceLabel(txt) { + this.txt = txt; + } + + reposition(pos, txt) { + if (txt) this.txt = txt; + this.btn.box = Box.from(this.#computePosition(this.txt, pos)); + } + + paint( + $, + // scheme = [ + // [0, 100, 0], + // [0, 255, 0, 150], + // [0, 200, 0], + // [0, 50, 0], + // ], + scheme = [0, 255, 255, 0], + hoverScheme = [255, 0, 0, 255], + disabledScheme = [64, 127, 127, 64], + rolloverScheme = undefined, // Defaults to the active light/dark theme. + ) { + if (rolloverScheme === undefined) rolloverScheme = defaultRolloverScheme($); + // Choose scheme based on state: disabled, pressed (down), rollover (hover), default + let s; + if (this.btn.disabled) { + s = disabledScheme; + } else if (this.btn.down && hoverScheme) { + s = hoverScheme; + } else if (this.btn.over && rolloverScheme) { + s = rolloverScheme; + } else { + s = scheme; + } + + // Normalize scheme parts. Many callers pass nested arrays like + // [fillColor, outlineColor, textColor, textAlpha] or sometimes numbers. + const fillColor = s[0] !== undefined ? s[0] : [0, 0, 0]; + const outlineColor = s[1] !== undefined ? s[1] : fillColor; + const textColor = s[2] !== undefined ? s[2] : outlineColor; + const textAlpha = typeof s[3] === "number" ? s[3] : undefined; + + // Paint fill, outline, then text. Use textAlpha only if numeric. + $.ink(fillColor).box(this.btn.box, "fill"); + $.ink(outlineColor).box(this.btn.box, "outline"); + if (textAlpha !== undefined) { + $.ink(textColor).write(this.txt, p2.add(this.btn.box, this.#offset), undefined, textAlpha); + } else { + $.ink(textColor).write(this.txt, p2.add(this.btn.box, this.#offset)); + } + } +} + +// A smaller text button using MatrixChunky8 font (4px char width, 7px height) +class TextButtonSmall { + txt; + btn; + + // MatrixChunky8: 4px char width, 7px char height + #cw = 4; + #ch = 7; + #padL = 2; // Left padding + #padR = 2; // Right padding + #padY = 2; // Vertical padding + #offset; + + constructor(text = "Button", pos = { x: 0, y: 0 }) { + this.txt = text; + this.#offset = { x: this.#padL, y: this.#padY }; + this.btn = new Button(this.#computePosition(text, { ...pos })); + } + + get act() { + return this.btn.act; + } + + set disabled(d) { + return (this.btn.disabled = d); + } + + get disabled() { + return this.btn.disabled; + } + + get down() { + return this.btn.down; + } + + set down(d) { + return (this.btn.down = d); + } + + get stickyScrubbing() { + return this.btn.stickyScrubbing; + } + + set stickyScrubbing(value) { + this.btn.stickyScrubbing = value; + } + + get width() { + const visibleText = stripColorCodes(this.txt); + return visibleText.length * this.#cw + this.#padL + this.#padR; + } + + get height() { + return this.#ch + this.#padY * 2; + } + + #computePosition(text, pos = { x: 0, y: 0 }) { + const visibleText = stripColorCodes(text); + const w = visibleText.length * this.#cw + this.#padL + this.#padR; + const h = this.#ch + this.#padY * 2; + + let x = pos.x || 0; + let y = pos.y || 0; + + // Handle screen-relative positioning + if (pos.screen) { + const sx = pos.screen.x || 0; + const sy = pos.screen.y || 0; + const sw = pos.screen.width || 0; + const sh = pos.screen.height || 0; + + if (pos.center === "xy") { + return { x: sx + sw / 2 - w / 2, y: sy + sh / 2 - h / 2, w, h }; + } + if (pos.center === "x") { + x = sx + sw / 2 - w / 2; + } + if (pos.center !== "x") { + if (pos.right !== undefined) { + x = sx + sw - pos.right - w; + } else { + x = sx + (pos.left || pos.x || 0); + } + } + if (pos.bottom !== undefined) { + y = sy + sh - pos.bottom - h; + } else { + y = sy + (pos.top || pos.y || 0); + } + } + + return { x, y, w, h }; + } + + reposition(pos, txt) { + if (txt) this.txt = txt; + this.btn.box = Box.from(this.#computePosition(this.txt, pos)); + } + + paint( + $, + scheme = [[0, 64, 0], [0, 140, 0], 255, [0, 64, 0]], // [fill, outline, text, fill] + hoverScheme = [[0, 100, 0], [0, 180, 0], 255, [0, 100, 0]], // hover/down + disabledScheme = [[32, 32, 32], [64, 64, 64], 80, [32, 32, 32]], + rolloverScheme = undefined, // Defaults to the active light/dark theme. + ) { + if (rolloverScheme === undefined) rolloverScheme = defaultRolloverScheme($, true); + let s; + if (this.btn.disabled) { + s = disabledScheme; + } else if (this.btn.down) { + s = hoverScheme; + } else if (this.btn.over && rolloverScheme) { + s = rolloverScheme; + } else { + s = scheme; + } + + $.ink(s[0]) + .box(this.btn.box, "fill") + .ink(s[1]) + .box(this.btn.box, "outline") + .ink(s[2]) + .write(this.txt, p2.add(this.btn.box, this.#offset), undefined, undefined, false, "MatrixChunky8"); + } +} + +function setTypeface(tf) { + TYPEFACE_UI = tf; +} + +export { spinner, spinnerReset, cached, Button, Slider, TextButton, TextButtonSmall, setTypeface }; diff --git a/easel/context/score.md b/easel/context/score.md new file mode 100644 index 000000000..ba5d384bc --- /dev/null +++ b/easel/context/score.md @@ -0,0 +1,17 @@ + + +# Piece score + +Build a new piece of aesthetic.computer software. The running artifact is the work. + +- Start from the request and the current piece. Preserve its behavior unless asked to change it. Make the smallest useful visible iteration. +- Lifecycle exports receive AC's API, not a browser canvas context. Ask `ac_api` for an unfamiliar exact symbol. Use `ac_examples` for real call sites, including the bundled API examples outside the monorepo. +- Tool discovery must be narrow: the AC tools are `ac_api`, `ac_examples`, `ac_outline`, `ac_symbol`, `ac_references`, `ac_preview`, and `ac_frame` on the `ac` MCP server. If discovery is needed, match the exact tool name and print only its schema. Never dump `ALL_TOOLS` or match generic terms like "input" or "publish" across every server. Show names with short summaries when listing tools. Preview refresh and auto-publish are managed by Aesel; do not discover unrelated publishing tools for them. +- Navigate JavaScript structurally: `ac_outline` gives AST declarations and class methods; `ac_symbol` reads one declaration; `ac_references` finds identifier occurrences without matching comments or strings. These are syntax tools, not a type checker or scope-aware LSP. Bundled `lib/graph.mjs`, `lib/disk.mjs`, `lib/cam-doll.mjs` and reference pieces are available through these tools. +- Avoid repeatedly paging through runtime files with sed/head/grep. If a tool fails, identify that failure once and use one bounded source lookup. Never describe unavailable tools as successful verification. +- Keep animation in sim/paint and input in act. Use screen dimensions for responsive composition. Follow the bundled SCREEN and HAND guides. +- After an edit, inspect `ac_preview` for errors at the current revision and `ac_frame` for actual pixels. For interactive work, exercise controls and verify the requested outcome. A build passing or nonempty frame is insufficient evidence of playability. +- Show progress and the result in plain language. Do not dump source or tool payloads into chat. When code is needed or requested, use a fenced block with its language (`js`, `lisp`, etc.) so Aesel can highlight it. +- Report what changed, what was checked, and any remaining limitation briefly. Do not claim publishing or visual success until its result is confirmed. diff --git a/easel/context/screen.md b/easel/context/screen.md index 71dc272cd..257088764 100644 --- a/easel/context/screen.md +++ b/easel/context/screen.md @@ -1,5 +1,5 @@ # The Screen — a drawing guide for Aesthetic Computer pieces @@ -23,6 +23,12 @@ the system paints it at `(6, 6)` in the default 6×10 typeface (`lib/disk.mjs`, so: **readouts go along the bottom, or right-aligned.** that's the default. a top bar is a decision, not a reflex. +## drawing API essentials + +`screen` is an object: use `screen.width` and `screen.height`, never `screen[0]`, `screen[1]`, `screen.w` or `screen.h`. +Use named colors or numeric RGB channels with `ink(r, g, b)`; CSS `hsl(...)` strings are not a supported color format. +`circle(x, y, radius, true)` draws a filled circle; omit the final flag for an outline. + ## paint - return nothing to keep animating; return `false` to paint once and freeze (`disk.mjs`: `noPaint = paintOut === false || …`). diff --git a/easel/desktop/.gitignore b/easel/desktop/.gitignore index 90e3e15ab..44158296c 100644 --- a/easel/desktop/.gitignore +++ b/easel/desktop/.gitignore @@ -1,3 +1,6 @@ node_modules/ node_modules dist/ + +# Per-developer signing material stays in the local keychain/worktree. +*.provisionprofile diff --git a/easel/desktop/APP-REVIEW-NOTES.md b/easel/desktop/APP-REVIEW-NOTES.md new file mode 100644 index 000000000..f55122e43 --- /dev/null +++ b/easel/desktop/APP-REVIEW-NOTES.md @@ -0,0 +1,32 @@ +# aesel — macOS review notes + +## Guideline 2.4.5: incoming network entitlement + +`com.apple.security.network.server` is required for account sign-in. aesel +uses Authorization Code with PKCE and temporarily starts an HTTP listener +bound exclusively to `127.0.0.1:44233`. After authentication, the user's browser +connects to `http://localhost:44233/callback` to return the authorization code. +This is an incoming connection to the app, even though it stays on the Mac. +The callback verifies the OAuth state, and the code exchange uses the PKCE +verifier. It does not serve workspace files or accept LAN connections. + +The listener is started only by sign-in and stops accepting connections after +the callback, before the outgoing token exchange. It also closes on error or +after the five-minute sign-in timeout. Ordinary startup does not open it. + +To exercise this functionality: + +1. Launch aesel and click the account label in the header, or enter `/login`. +2. Complete Aesthetic Computer sign-in in the browser. +3. The browser returns to the local callback and displays “Signed in”. Return + to aesel; its header shows the account handle (or the handle-claim flow). +4. If already signed in, use `/logout` before repeating `/login`. + +`com.apple.security.network.client` is separately required for outgoing HTTPS +requests for authentication, hosted inference, previews, and publishing. + +Source: `easel/src/ac-session.mjs`, `ACSession.login()`. + +The entitlement explanation and reproduction steps were saved to App Review +Information on September 17, 2026. No review reply has been sent and the app +has not been resubmitted. The rebuilt candidate is version 0.7.1, build 0.7.3. diff --git a/easel/desktop/APP-STORE.md b/easel/desktop/APP-STORE.md new file mode 100644 index 000000000..ac9199a90 --- /dev/null +++ b/easel/desktop/APP-STORE.md @@ -0,0 +1,68 @@ +# Easel Mac App Store track + +Easel has two Mac distribution tracks: + +* The Developer ID DMG/ZIP is the full-power desktop build. It can use Easel's + native PTY, optional Claude/Codex provider bridges, and Easel's signed binary + update feed. +* The `mas` target is the App Store candidate. It is sandboxed, uses the AC + hosted provider as its default, and receives updates only through the Store. + +Build the candidate on an Apple-silicon Mac after installing the Mac App Store +distribution certificate and provisioning profile: + +```sh +cd easel/desktop +npm install +npm run dist:mas +``` + +Before submitting, test the exported `.app` in a clean macOS user account. In +particular, verify first launch, sign-in, required transcript-sharing +disclosure, creating and publishing a piece, picture preview, QR links, text +selection, and resume after relaunch. Confirm that the sandbox does not break +the PTY or preview webview. If App Review rejects child-process execution under +the sandbox, the Store track must use a hosted-session renderer without the +local Claude/Codex bridges; the direct DMG remains the full Easel build. + +Store metadata still needs to be entered in App Store Connect: subtitle, +description, privacy answers, support URL, screenshots, age rating, and review +notes explaining the hosted inference and required transcript-sharing +disclosure. The Store build must not call `electron-updater`; App Store Connect +owns its update lifecycle. + +The release script signs the app with the Mac App Store application identity, +then signs the outer `.pkg` with the separate installer identity required by +Apple. This works around electron-builder 25's single-identity installer step. + +Current account check: the shared App Store Connect API key has registered +`computer.aesthetic.easel`, created a Mac App Store distribution certificate, +and created an active `Easel Mac App Store` profile (profile UUID +`5725cc89-feef-4bda-80eb-1c4f30f43500`). The profile is installed on the release +Mac. The macOS app record was created through Chrome on September 16, 2026, +as **Aesthetic Easel**, then renamed to **Aesel**, app ID `6812823093`, +SKU `easel-mac`, locale `en-US`. Aesel is the chosen product name. +Apple accepted Aesel and rejected the plain name `Easel` as already in use. + +Listing: https://appstoreconnect.apple.com/apps/6812823093 + +Use `node slab/menuband/bin/asc.mjs status --app 6812823093 --platform MAC_OS` +from the main repository to inspect the submission state. Creating the record +does not upload a build or submit it for review. + +## September 17 review repair + +The visible product name is now lowercase `aesel`. The registered bundle ID +remains `computer.aesthetic.easel`, including the new iOS target, so iOS can +be added to the same App Store record. + +Apple's 2.4.5 review asks why `network.server` is present. It is needed by +`ACSession.login()` for a temporary loopback PKCE callback; removing it would +break account sign-in. See [review notes](APP-REVIEW-NOTES.md). The listener now +stops accepting connections before the outgoing code exchange. Focused tests +cover success and timeout/retry cleanup. + +The rebuilt package is `dist/mas-arm64/aesel-0.7.1-mas-arm64.pkg`, build 0.7.3. +App and installer signatures were verified. Detailed entitlement notes have +been saved in App Store Connect; upload, build selection, and resubmission +remain separate release steps. diff --git a/easel/desktop/README.md b/easel/desktop/README.md index 5bd6ef154..5696b2515 100644 --- a/easel/desktop/README.md +++ b/easel/desktop/README.md @@ -1,7 +1,7 @@ -# Standalone Easel +# Standalone Aesel Install this directory's dependencies, then run `npm start -- --cwd /path/to/project --backend ac`. -The desktop shell runs Easel in a native PTY and uses the existing AC login. +The desktop shell runs Aesel in a native PTY and uses the existing AC login. On launch, Tab switches New media / Threads; arrows select and Enter opens. Checkpointed update restarts resume directly. @@ -19,11 +19,38 @@ The top-right QR follows the Piece URL and includes a four-module quiet zone. Published Pictures also show their verified painting #code QR. Local artifacts without a public URL do not show a QR. -Local app builds are available; signed public distribution is not published yet. +Linux x86-64 downloads: https://releases.aesthetic.computer/easel/desktop/index.html -`/restart` (or Easel → Restart Easel) saves the active thread, transcript, draft, +Ubuntu/Debian: download `Easel-0.7.0-linux-amd64.deb`, then run +`sudo apt install ./Easel-0.7.0-linux-amd64.deb`. Launch **Aesel** from the app menu +or run `easel`. The installer configures Chromium's sandbox when user namespaces +are unavailable. Do not launch with `--no-sandbox`. + +Other distributions can use the AppImage (`chmod +x` first), or extract the +tarball and run `./easel`. If FUSE is missing, AppImage supports +`--appimage-extract-and-run`. Systems restricting user namespaces need an +installed sandbox; on Ubuntu use the .deb. This initial release targets +Intel/AMD 64-bit, tested on Ubuntu 24.04; ARM is not included. + +An AC account and acceptance of required transcript sharing are needed before +use. The normal hosted AC provider needs no Claude or Codex installation. +Bring-your-own Claude or Codex modes remain optional and require their CLI and +account. +Game Boy compilation requires GBDK installed separately. + +Build on Linux with `npm install` and `npm run dist:linux`. Upload the resulting +three artifacts, `latest-linux.yml`, and `scripts/linux-download.html` (as +`index.html`) with `scripts/publish-linux.cjs DIST`, using the existing Spaces +credentials and AWS S3 SDK. Aesel uses the isolated `easel/desktop/` prefix; +never register it through AC Desktop's release API. AppImage supports the +binary update feed; .deb/tar users install a new package manually. +macOS downloads are available from https://releases.aesthetic.computer/easel/desktop/. +The Apple-silicon DMG and ZIP are signed and notarized; Intel builds are not +included in this first release. + +`/restart` (or Aesel → Restart Aesel) saves the active thread, transcript, draft, model and workspace before replacing the app process. A busy turn or upload -finishes first. The local checkpoint is private (`0600`). Launching Easel again +finishes first. The local checkpoint is private (`0600`). Launching Aesel again focuses its existing window; `--restart` requests a saved restart. `/update` is handled by the desktop host. Development builds reload current diff --git a/easel/desktop/build/entitlements.mas.inherit.plist b/easel/desktop/build/entitlements.mas.inherit.plist new file mode 100644 index 000000000..794eada1c --- /dev/null +++ b/easel/desktop/build/entitlements.mas.inherit.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.inherit + + + diff --git a/easel/desktop/build/entitlements.mas.plist b/easel/desktop/build/entitlements.mas.plist new file mode 100644 index 000000000..e25aad37c --- /dev/null +++ b/easel/desktop/build/entitlements.mas.plist @@ -0,0 +1,22 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + + com.apple.security.network.server + + + diff --git a/easel/desktop/build/generate-icon.sh b/easel/desktop/build/generate-icon.sh index eb7033c0f..b6de97692 100755 --- a/easel/desktop/build/generate-icon.sh +++ b/easel/desktop/build/generate-icon.sh @@ -7,12 +7,12 @@ for tool in rsvg-convert sips iconutil; do done icon_tmp="$(mktemp -d "${TMPDIR:-/tmp}/easel-icon.XXXXXX")" trap 'rm -rf "$icon_tmp"' EXIT -mkdir "$icon_tmp/Easel.iconset" +mkdir "$icon_tmp/aesel.iconset" rsvg-convert -w 1024 -h 1024 "$here/icon.svg" -o "$here/icon.png" for size in 16 32 128 256 512; do - sips -z "$size" "$size" "$here/icon.png" --out "$icon_tmp/Easel.iconset/icon_${size}x${size}.png" >/dev/null + sips -z "$size" "$size" "$here/icon.png" --out "$icon_tmp/aesel.iconset/icon_${size}x${size}.png" >/dev/null double=$((size * 2)) - sips -z "$double" "$double" "$here/icon.png" --out "$icon_tmp/Easel.iconset/icon_${size}x${size}@2x.png" >/dev/null + sips -z "$double" "$double" "$here/icon.png" --out "$icon_tmp/aesel.iconset/icon_${size}x${size}@2x.png" >/dev/null done -iconutil -c icns "$icon_tmp/Easel.iconset" -o "$here/icon.icns" +iconutil -c icns "$icon_tmp/aesel.iconset" -o "$here/icon.icns" echo "Generated icon.png (1024 px) and icon.icns from icon.svg." diff --git a/easel/desktop/companion-scene.js b/easel/desktop/companion-scene.js new file mode 100644 index 000000000..5df7904a4 --- /dev/null +++ b/easel/desktop/companion-scene.js @@ -0,0 +1,47 @@ +// Sprite action stage: shared donkey poses, crisp ground shadow and pixel props. +(function(scope){ + const palettes={wood:'#bd8b50',dark:'#261c29',light:'#f5e3bd',pink:'#ff78b2',blue:'#74cfff',green:'#b7df72'}; + function layout(){return {width:112,height:112,qr:null};} + function prop(ctx,name,x,y){ + const r=(color,a,b,w,h)=>{ctx.fillStyle=palettes[color]||color;ctx.fillRect(x+a,y+b,w,h);}; + switch(name){ + case 'brush':r('wood',5,1,2,11);r('light',4,0,4,4);r('pink',4,-2,4,3);break; + case 'pencil':r('#efbf55',4,0,3,12);r('pink',4,0,3,2);r('dark',5,12,1,2);break; + case 'eraser':r('pink',1,5,11,6);r('light',8,5,4,6);break; + case 'palette':r('wood',0,2,14,9);r('dark',10,5,3,3);r('pink',2,3,3,2);r('blue',5,7,3,2);r('green',6,3,3,2);break; + case 'ruler':r('wood',0,5,16,4);for(let i=1;i<16;i+=3)r('dark',i,5,1,2);break; + case 'keyboard':r('dark',0,3,18,9);r('#a39fac',1,3,16,7);for(let a=2;a<16;a+=3)for(let b=4;b<9;b+=3)r('dark',a,b,2,2);break; + case 'paper':r('light',1,0,12,15);for(let i=3;i<13;i+=3)r('#9a8b87',3,i,8,1);break; + case 'book':r('#7c596e',0,1,16,12);r('light',1,1,6,10);r('light',9,1,6,10);break; + case 'note':r('blue',7,0,2,12);r('blue',2,10,7,3);r('blue',7,0,7,3);break; + case 'drum':r('pink',1,3,14,10);r('light',0,2,16,3);r('dark',0,12,16,2);break; + case 'gamepad':r('#afa3b8',0,3,17,9);r('dark',3,4,2,6);r('dark',1,6,6,2);r('pink',12,5,2,2);r('blue',14,8,2,2);break; + case 'cartridge':r('#aeb48b',2,0,11,14);r('dark',4,3,7,6);r('green',5,4,5,4);r('wood',5,12,5,2);break; + case 'magnifier':r('wood',9,8,3,7);r('dark',1,0,10,10);r('blue',3,2,6,6);r('light',3,2,2,3);break; + case 'gear':r('wood',4,0,5,13);r('wood',0,4,13,5);r('wood',2,2,9,9);r('dark',5,5,3,3);break; + } + } + function effect(ctx,name,t){ + const r=(color,x,y,w,h)=>{ctx.fillStyle=color;ctx.fillRect(x,y,w,h);}; + const shift=(t%3)*2; + if(['dust','puff','sweat'].includes(name)){for(let i=0;i<3;i++)r(name==='sweat'?'#74cfff':'#b5a694',77+i*7,88-i*3-shift,3+i,2);return;} + if(['ink','paint'].includes(name)){for(let i=0;i<4;i++)r(name==='ink'?'#343047':['#ff78b2','#74cfff','#efbf55','#b7df72'][i],22+i*5,83+(i%2)*3-shift,3,2);return;} + const patterns={star:['00100','10101','01110','11111','01010'],spark:['00100','00100','11011','00100','00100'],heart:['01010','11111','11111','01110','00100'],question:['01110','10001','00010','00100','00000','00100'],thought:['01110','10001','10001','01110','00100','01000'],note:['00111','00101','00100','11100','11000'],check:['00001','00010','10100','01000'],scan:['11111','10000','10111','10000','11111']}; + const pattern=patterns[name];if(!pattern)return; + pattern.forEach((row,y)=>Array.from(row).forEach((p,x)=>{if(p==='1')r(name==='heart'?'#ff78b2':name==='check'?'#b7df72':'#efda90',30+x*2,10+y*2-shift,2,2);})); + } + function draw(ctx,image,sample){ + const box=layout(),f=typeof sample==='number'?{pose:sample}:sample||{pose:0}; + ctx.clearRect(0,0,box.width,box.height);ctx.imageSmoothingEnabled=false; + // Stepped pixel shadow stays grounded as the donkey hops above it. + ctx.fillStyle='#00000038';ctx.fillRect(33,94,46,3);ctx.fillRect(29,97,54,4);ctx.fillRect(34,101,44,2); + ctx.fillStyle='#00000028';ctx.fillRect(38,97,34,3); + ctx.save();ctx.translate(56+Math.round(f.x||0),62+Math.round(f.y||0));ctx.rotate((f.rotate||0)*Math.PI/180); + ctx.scale((f.flip?1:-1)*(f.scaleX||1),f.scaleY||1); + ctx.drawImage(image,(f.pose%4)*64,Math.floor(f.pose/4)*64,46,64,-23,-32,46,64);ctx.restore(); + if(f.prop)prop(ctx,f.prop,23+Math.round(f.x||0),77+Math.round(f.y||0)); + if(f.effect)effect(ctx,f.effect,f.frameIndex||0); + return box; + } + const api={layout,draw,prop,effect};if(typeof module!=='undefined'&&module.exports)module.exports=api;else scope.CompanionScene=api; +})(typeof window==='undefined'?globalThis:window); diff --git a/easel/desktop/donkey-actions.js b/easel/desktop/donkey-actions.js new file mode 100644 index 000000000..a21c6cfb0 --- /dev/null +++ b/easel/desktop/donkey-actions.js @@ -0,0 +1,57 @@ +// Shared pose-atlas choreography. Every crop excludes the old easel columns. +(function(scope){ + const STATES=['idle','awake','sleeping','working','running']; + const CROP=Object.freeze({cellWidth:64,cellHeight:64,columns:4,width:46,height:64}); + const finite=(n,min,max)=>typeof n==='number'&&Number.isFinite(n)&&n>=min&&n<=max; + function validateActions(value,{expectedCount}={}){ + if(!Array.isArray(value)||value.length>256||(expectedCount!==undefined&&value.length!==expectedCount))throw Error('Invalid action count'); + const ids=new Set(); + for(const action of value){ + if(!action||!/^[a-z][a-z0-9-]{1,63}$/.test(action.id)||ids.has(action.id))throw Error('Invalid or duplicate action ID');ids.add(action.id); + if(typeof action.label!=='string'||!action.label.trim()||action.label.length>100||typeof action.category!=='string'||action.category.length>40||typeof action.loop!=='boolean'||!Array.isArray(action.states)||!action.states.length||action.states.some(s=>!STATES.includes(s)))throw Error(`Invalid action metadata: ${action.id}`); + if(!Array.isArray(action.frames)||action.frames.length<3||action.frames.length>64)throw Error(`Invalid frames: ${action.id}`); + for(const f of action.frames){ + if(!f||!Number.isInteger(f.pose)||f.pose<0||f.pose>15||!Number.isInteger(f.duration)||!finite(f.duration,80,3000))throw Error(`Invalid pose/timing: ${action.id}`); + for(const name of ['x','y'])if(f[name]!==undefined&&!finite(f[name],-128,128))throw Error(`Invalid ${name}`); + if(f.rotate!==undefined&&!finite(f.rotate,-180,180))throw Error('Invalid rotation'); + for(const name of ['scaleX','scaleY'])if(f[name]!==undefined&&!finite(f[name],.25,3))throw Error(`Invalid ${name}`); + if(f.flip!==undefined&&typeof f.flip!=='boolean')throw Error('Invalid flip'); + for(const name of ['prop','effect'])if(f[name]!==undefined&&(typeof f[name]!=='string'||!/^[a-z][a-z0-9-]{0,47}$/.test(f[name])))throw Error(`Invalid ${name}`); + } + }return value; + } + function cropPose(pose){if(!Number.isInteger(pose)||pose<0||pose>15)throw Error('Invalid pose');return {x:(pose%4)*64,y:Math.floor(pose/4)*64,width:CROP.width,height:CROP.height};} + function sampleAction(action,elapsed=0,{reducedMotion=false}={}){ + const total=action.frames.reduce((sum,f)=>sum+f.duration,0),time=Math.max(0,Number.isFinite(elapsed)?elapsed:0); + const done=!action.loop&&time>=total;let offset=action.loop?time%total:Math.min(time,total-1),index=0; + if(reducedMotion)index=0; + else for(let i=0;i>>0;} + function chooseAction(actions,{state='idle',seed=0,previous}={}){ + const eligible=actions.filter(a=>a.states.includes(state));if(!eligible.length)return null; + const candidates=eligible.length>1?eligible.filter(a=>a.id!==previous):eligible; + return candidates[hashSeed(seed)%candidates.length]; + } + async function loadActions({urls=['donkey-actions/idle.json','donkey-actions/work.json','donkey-actions/movement.json'],fetch=scope.fetch?.bind(scope),expectedCount=128}={}){ + if(!fetch)throw Error('No action loader'); + const groups=await Promise.all(urls.map(async url=>{const response=await fetch(url);if(!response.ok)throw Error(`Cannot load donkey actions: ${url}`);return validateActions(await response.json());})); + return validateActions(groups.flat(),{expectedCount}); + } + function createActionPlayer(actions,{seed=0,state='idle',now=0,reducedMotion=false}={}){ + validateActions(actions);let sequence=0,started=now,selected=chooseAction(actions,{state,seed:`${seed}:0`}); + function select(at,nextState){const previous=selected?.id;state=nextState;selected=chooseAction(actions,{state,seed:`${seed}:${++sequence}`,previous});started=at;} + return { + setState(next,at=0){if(!STATES.includes(next))throw Error('Unknown donkey state');if(next!==state)select(at,next);}, + setReducedMotion(value){reducedMotion=!!value;}, + play(id,at=0){const action=actions.find(a=>a.id===id);if(!action)throw Error('Unknown action');selected=action;started=at;}, + sample(at=0){if(!selected)return null;return sampleAction(selected,at-started,{reducedMotion});}, + next(at=0){select(at,state);return this.sample(at);}, + get action(){return selected?.id||null;},get state(){return state;}, + }; + } + const api={STATES,CROP,validateActions,cropPose,sampleAction,chooseAction,loadActions,createActionPlayer}; + if(typeof module!=='undefined'&&module.exports)module.exports=api;else scope.DonkeyActions=api; +})(typeof window==='undefined'?globalThis:window); diff --git a/easel/desktop/donkey-actions/README.md b/easel/desktop/donkey-actions/README.md new file mode 100644 index 000000000..8f1504198 --- /dev/null +++ b/easel/desktop/donkey-actions/README.md @@ -0,0 +1,64 @@ +# Donkey work actions + +`work.json` contains 48 hand-choreographed clips: six each for drawing, painting, writing, coding, music, paper, Game Boy, and thinking. These are new action sequences assembled from the existing 16 raster poses, not 48 newly drawn animations. No atlas pixels were changed or generated. + +The renderer crops columns 0–45 of each 64×64 cell in `assets/aesel.png`; the original baked-in easel is excluded. Poses 0–1 rest, 2–3 attend, 8–11 work, and 12–15 run. Props and effects supply activity-specific details the small shared pose set cannot express alone. The choreography below describes the intended combined pose/transform/prop rendering; the JSON does not independently draw those props. + +Frames use milliseconds, atlas-pixel x/y offsets, rotation in degrees, and dimensionless scale. Missing transforms mean x=0, y=0, rotate=0, flip=false, scaleX=scaleY=1. Each frame replaces the previous frame's values; omitted props/effects clear them. Rotate/scale/flip around the donkey's ground anchor, while translations offset that anchor. Looping clips repeat; finite clips finish in their last pose until the controller chooses another action. States constrain scheduling, not the application's actual progress or success: a decorative check effect must not assert a real build/test result. + +Props (14): `brush`, `pencil`, `eraser`, `palette`, `ruler`, `keyboard`, `paper`, `book`, `note`, `drum`, `gamepad`, `cartridge`, `magnifier`, `gear`. + +Effects (8): `ink` (a short mark), `paint` (small colored dabs), `spark` (brief star), `thought` (small bubble), `note` (rising music glyph), `dust` (eraser crumbs), `scan` (moving inspection line), `check` (a small decorative tick). Effects should clear or reset per frame, respect reduced motion, and remain within the companion scene. + +Every clip has at least six frames and three distinct visual states. IDs are prefixed `work-` to avoid collisions with other sets. No two clips have the same sequence after duration values are removed. Differences include stroke paths, prop exchanges, direction changes, held inspection beats, and preparation/result poses; they are not renamed timing variants. + +| Action | Choreography | +|---|---| +| Trace a contour | Follow an arch, lower the pencil, then inspect its endpoint. | +| Crosshatch | Short descending strokes followed by a reverse diagonal pass. | +| Stipple | Three separated pencil taps at different positions, then lift. | +| Erase a mark | Lean into a brisk horizontal scrub and brush away the crumbs. | +| Measure a line | Place the ruler, sight along it, mark each end, and remove it. | +| Draw a spiral | Widen a small wrist circle into a larger looping stroke. | +| Mix a color | Dip low, stir in both directions, and compare the raised palette. | +| Lay a wash | Broad left-to-right sweep, drop a row, then sweep back. | +| Dab highlights | Load paint once, then make two light bouncing dabs. | +| Paint an edge | Hold the brush upright and climb a narrow vertical edge. | +| Flick paint | Wind the brush back, flick forward, recoil, and admire the spray. | +| Clean the brush | Lower the brush, swish it twice, shake it out, and put it away. | +| Write a sentence | Advance across a line, pause for a word, and return to the margin. | +| Start a paragraph | Read the page, indent, then settle into three writing beats. | +| Cross out a phrase | Inspect, make one firm strike, then write a correction above it. | +| Outline an idea | Place three staggered list entries and trace their structure. | +| Proofread | Scan three lines downward, double back, and mark one correction. | +| Sign the page | A low, sweeping signature ends in a lifted flourish. | +| Type code | Alternate key presses, lean in for a longer chord, and read back. | +| Track a bug | Sweep the magnifier, stop at a clue, then make one targeted fix. | +| Compile | Seat a gear, turn through four phases, and lift at completion. | +| Run checks | Tap to start, watch two results, fix once, and confirm. | +| Rearrange code | Lift a block from one side, turn, place it, and reconnect. | +| Compare versions | Alternate between two sides before reconciling in the middle. | +| Tap a beat | A downbeat, two light taps, and a held rest form a small rhythm. | +| Pick a melody | Rise through three pitches, pause, then answer with a lower note. | +| Conduct | Down, left, right, up: a four-beat conducting pattern. | +| Listen closely | Tilt toward the sound, close the eyes briefly, then nod to it. | +| Arrange a sequence | Place three notes at distinct steps, then read the whole bar. | +| Record a take | Count in with two nods, play a phrase, then stop recording. | +| Read a source | Lean into a book, follow a passage, and look up to consider it. | +| Add a citation | Check the book, note a detail, return to the page, and insert it. | +| Lay out a figure | Measure the frame, place the image, and draw its lower caption. | +| Work an equation | Write, hold a long thinking beat, erase one term, then resolve it. | +| Review annotations | Read a note, check the page, add a margin mark, and tick it off. | +| Gather pages | Reach to each side, stack pages low, press them flat, then lift. | +| Insert a cartridge | Lift a cartridge, align it, press down, and wait for the screen. | +| Test the D-pad | Lean left, right, up, and down while checking directional input. | +| Test A and B | Two separate button presses, then a deliberate two-button chord. | +| Place pixel tiles | Set a tile at each corner of a small grid, then inspect it. | +| Build a ROM | Type, turn the compiler gear, place the cartridge, then present it. | +| Playtest a level | A short run-in, two active control beats, a jump, and a result check. | +| Ponder | Tip the head, settle inward, then slowly look back up. | +| Try a hypothesis | Look up at an idea, sketch a test, inspect it, then nod. | +| Weigh alternatives | Hold attention on each side, hesitate in the middle, then choose. | +| Find an idea | Still concentration becomes a small squash, spring, and bright landing. | +| Plan the next steps | Consider a blank page, point out three steps, and trace the route. | +| Reconsider | Start a mark, pull back, erase it, and face a fresh direction. | diff --git a/easel/desktop/donkey-actions/idle.json b/easel/desktop/donkey-actions/idle.json new file mode 100644 index 000000000..4cc8a32e4 --- /dev/null +++ b/easel/desktop/donkey-actions/idle.json @@ -0,0 +1,1535 @@ +[ + { + "id": "idle-ear-listen", + "label": "Listen with one ear", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 700 + }, + { + "pose": 2, + "duration": 250, + "rotate": -6 + }, + { + "pose": 3, + "duration": 450, + "rotate": -6 + }, + { + "pose": 0, + "duration": 700 + } + ] + }, + { + "id": "idle-ear-sweep", + "label": "Sweep both ears", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 250, + "rotate": -5 + }, + { + "pose": 3, + "duration": 250, + "rotate": 5 + }, + { + "pose": 2, + "duration": 250, + "rotate": -3 + }, + { + "pose": 0, + "duration": 600 + } + ] + }, + { + "id": "idle-look-left", + "label": "Look left", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 450 + }, + { + "pose": 2, + "duration": 300, + "flip": true + }, + { + "pose": 3, + "duration": 900, + "flip": true + }, + { + "pose": 0, + "duration": 400 + } + ] + }, + { + "id": "idle-look-right", + "label": "Look right", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 450, + "flip": true + }, + { + "pose": 2, + "duration": 300 + }, + { + "pose": 3, + "duration": 900 + }, + { + "pose": 0, + "duration": 400 + } + ] + }, + { + "id": "idle-look-up", + "label": "Look up", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 400 + }, + { + "pose": 2, + "duration": 250, + "rotate": -9, + "y": -2 + }, + { + "pose": 3, + "duration": 650, + "rotate": -9, + "y": -2 + }, + { + "pose": 0, + "duration": 400 + } + ] + }, + { + "id": "idle-peek-down", + "label": "Peek at the floor", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 400 + }, + { + "pose": 2, + "duration": 250, + "rotate": 9, + "y": 2 + }, + { + "pose": 1, + "duration": 650, + "rotate": 9, + "y": 2 + }, + { + "pose": 0, + "duration": 400 + } + ] + }, + { + "id": "idle-double-take", + "label": "Double take", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 350 + }, + { + "pose": 2, + "duration": 160, + "flip": true + }, + { + "pose": 0, + "duration": 160 + }, + { + "pose": 3, + "duration": 450, + "flip": true, + "x": -2 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-curious-tilt", + "label": "Curious head tilt", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 350 + }, + { + "pose": 3, + "duration": 500, + "rotate": 8 + }, + { + "pose": 2, + "duration": 350, + "rotate": -4 + }, + { + "pose": 0, + "duration": 600 + } + ] + }, + { + "id": "idle-slow-blink", + "label": "Slow blink", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 1100 + }, + { + "pose": 1, + "duration": 350 + }, + { + "pose": 0, + "duration": 800 + } + ] + }, + { + "id": "idle-wink-bow", + "label": "Wink and bow", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 450 + }, + { + "pose": 1, + "duration": 250, + "rotate": 5, + "scaleY": 0.94 + }, + { + "pose": 3, + "duration": 400 + }, + { + "pose": 0, + "duration": 600 + } + ] + }, + { + "id": "idle-nod-yes", + "label": "Nod yes", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 300 + }, + { + "pose": 3, + "duration": 180, + "rotate": 7, + "y": 1 + }, + { + "pose": 2, + "duration": 180 + }, + { + "pose": 3, + "duration": 180, + "rotate": 7, + "y": 1 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-shake-no", + "label": "Shake no", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 300, + "x": -2 + }, + { + "pose": 3, + "duration": 170, + "x": 2 + }, + { + "pose": 2, + "duration": 170, + "x": -2 + }, + { + "pose": 3, + "duration": 170, + "x": 2 + }, + { + "pose": 0, + "duration": 450 + } + ] + }, + { + "id": "idle-tiny-bow", + "label": "Tiny bow", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 400 + }, + { + "pose": 2, + "duration": 300, + "rotate": 10, + "scaleY": 0.9 + }, + { + "pose": 1, + "duration": 500, + "rotate": 10, + "scaleY": 0.9 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-proud-stand", + "label": "Stand proudly", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 400 + }, + { + "pose": 2, + "duration": 400, + "scaleY": 1.06, + "y": -2 + }, + { + "pose": 3, + "duration": 700, + "scaleY": 1.06, + "y": -2 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-shy-turn", + "label": "Shy turn", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 3, + "duration": 250 + }, + { + "pose": 1, + "duration": 450, + "flip": true, + "x": -2 + }, + { + "pose": 0, + "duration": 650, + "flip": true + }, + { + "pose": 2, + "duration": 350 + } + ] + }, + { + "id": "idle-hoof-shuffle", + "label": "Shuffle hooves", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 12, + "duration": 180, + "x": -2 + }, + { + "pose": 13, + "duration": 180 + }, + { + "pose": 14, + "duration": 180, + "x": 2 + }, + { + "pose": 15, + "duration": 180 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-weight-shift", + "label": "Shift weight", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 600, + "rotate": -2, + "x": -1 + }, + { + "pose": 2, + "duration": 600, + "rotate": 2, + "x": 1 + }, + { + "pose": 0, + "duration": 600 + } + ] + }, + { + "id": "idle-foot-tap", + "label": "Tap a hoof", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 400 + }, + { + "pose": 12, + "duration": 160 + }, + { + "pose": 0, + "duration": 200 + }, + { + "pose": 12, + "duration": 160 + }, + { + "pose": 0, + "duration": 600 + } + ] + }, + { + "id": "idle-stretch-tall", + "label": "Stretch tall", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 350 + }, + { + "pose": 2, + "duration": 400, + "scaleY": 1.1, + "scaleX": 0.96, + "y": -3 + }, + { + "pose": 3, + "duration": 700, + "scaleY": 1.1, + "scaleX": 0.96, + "y": -3 + }, + { + "pose": 0, + "duration": 450 + } + ] + }, + { + "id": "idle-stretch-wide", + "label": "Stretch wide", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 300 + }, + { + "pose": 2, + "duration": 450, + "scaleX": 1.08, + "scaleY": 0.95 + }, + { + "pose": 3, + "duration": 500, + "scaleX": 1.08, + "scaleY": 0.95 + }, + { + "pose": 0, + "duration": 450 + } + ] + }, + { + "id": "idle-shoulder-roll", + "label": "Roll shoulders", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 250, + "rotate": -4, + "y": -1 + }, + { + "pose": 3, + "duration": 250, + "rotate": 0, + "y": -3 + }, + { + "pose": 2, + "duration": 250, + "rotate": 4, + "y": -1 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-settle", + "label": "Settle into place", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 14, + "duration": 180, + "y": -2 + }, + { + "pose": 0, + "duration": 200, + "scaleY": 0.9, + "y": 2 + }, + { + "pose": 1, + "duration": 350, + "scaleY": 0.96, + "y": 1 + }, + { + "pose": 0, + "duration": 700 + } + ] + }, + { + "id": "idle-scratch-ear", + "label": "Scratch an ear", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 300 + }, + { + "pose": 10, + "duration": 180, + "rotate": -7 + }, + { + "pose": 9, + "duration": 180, + "rotate": -4 + }, + { + "pose": 10, + "duration": 180, + "rotate": -7 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-scratch-chin", + "label": "Scratch chin", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 350 + }, + { + "pose": 8, + "duration": 350, + "rotate": 7 + }, + { + "pose": 9, + "duration": 250, + "rotate": 4 + }, + { + "pose": 3, + "duration": 500 + } + ] + }, + { + "id": "idle-sniff-air", + "label": "Sniff the air", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 500 + }, + { + "pose": 2, + "duration": 180, + "x": 2, + "y": -1 + }, + { + "pose": 3, + "duration": 180, + "x": 3, + "y": -2 + }, + { + "pose": 2, + "duration": 180, + "x": 2, + "y": -1 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-sniff-floor", + "label": "Sniff the floor", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 300 + }, + { + "pose": 8, + "duration": 350, + "rotate": 10, + "y": 3 + }, + { + "pose": 9, + "duration": 250, + "rotate": 7, + "x": 2, + "y": 3 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-sneeze", + "label": "Little sneeze", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 450, + "scaleY": 1.04 + }, + { + "pose": 1, + "duration": 200, + "rotate": -5 + }, + { + "pose": 3, + "duration": 120, + "rotate": 12, + "x": 3, + "effect": "puff" + }, + { + "pose": 0, + "duration": 300, + "x": -1 + }, + { + "pose": 0, + "duration": 600 + } + ] + }, + { + "id": "idle-hiccup", + "label": "Hiccup", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 650 + }, + { + "pose": 3, + "duration": 100, + "y": -4, + "scaleY": 1.04 + }, + { + "pose": 1, + "duration": 180, + "scaleY": 0.94 + }, + { + "pose": 0, + "duration": 650 + } + ] + }, + { + "id": "idle-yawn", + "label": "Yawn", + "category": "idle", + "states": [ + "idle", + "sleeping" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 600 + }, + { + "pose": 3, + "duration": 800, + "scaleY": 1.06, + "rotate": -4 + }, + { + "pose": 1, + "duration": 400 + }, + { + "pose": 4, + "duration": 700 + } + ] + }, + { + "id": "idle-doze-nod", + "label": "Doze and nod", + "category": "idle", + "states": [ + "idle", + "sleeping" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 900 + }, + { + "pose": 1, + "duration": 700, + "rotate": 3 + }, + { + "pose": 4, + "duration": 900, + "rotate": 7 + }, + { + "pose": 2, + "duration": 180 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-curl-sleep", + "label": "Curl up to sleep", + "category": "idle", + "states": [ + "sleeping" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 500 + }, + { + "pose": 4, + "duration": 500, + "scaleY": 0.92, + "y": 2 + }, + { + "pose": 5, + "duration": 850, + "scaleY": 0.86, + "y": 4 + }, + { + "pose": 6, + "duration": 1100, + "scaleY": 0.86, + "y": 4 + } + ] + }, + { + "id": "idle-sleep-breathe", + "label": "Sleeping breath", + "category": "idle", + "states": [ + "sleeping" + ], + "loop": true, + "frames": [ + { + "pose": 4, + "duration": 1000, + "scaleY": 0.96 + }, + { + "pose": 5, + "duration": 1000, + "scaleY": 1.02, + "y": -1 + }, + { + "pose": 6, + "duration": 1000, + "scaleY": 0.98 + }, + { + "pose": 7, + "duration": 1000 + } + ] + }, + { + "id": "idle-sleep-twitch", + "label": "Dreaming hoof twitch", + "category": "idle", + "states": [ + "sleeping" + ], + "loop": false, + "frames": [ + { + "pose": 4, + "duration": 1200 + }, + { + "pose": 6, + "duration": 150, + "x": 1 + }, + { + "pose": 5, + "duration": 700 + }, + { + "pose": 7, + "duration": 150, + "x": -1 + }, + { + "pose": 4, + "duration": 1000 + } + ] + }, + { + "id": "idle-dream-trot", + "label": "Dream of trotting", + "category": "idle", + "states": [ + "sleeping" + ], + "loop": false, + "frames": [ + { + "pose": 4, + "duration": 900 + }, + { + "pose": 5, + "duration": 350, + "y": -1 + }, + { + "pose": 6, + "duration": 350, + "x": 1 + }, + { + "pose": 7, + "duration": 350, + "y": -1 + }, + { + "pose": 4, + "duration": 900 + } + ] + }, + { + "id": "idle-wake-stretch", + "label": "Wake and stretch", + "category": "idle", + "states": [ + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 4, + "duration": 500 + }, + { + "pose": 1, + "duration": 350 + }, + { + "pose": 2, + "duration": 450, + "scaleY": 1.08, + "y": -3 + }, + { + "pose": 3, + "duration": 350 + }, + { + "pose": 0, + "duration": 600 + } + ] + }, + { + "id": "idle-startle", + "label": "Small startle", + "category": "idle", + "states": [ + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 500 + }, + { + "pose": 3, + "duration": 120, + "y": -7 + }, + { + "pose": 2, + "duration": 180, + "y": -3 + }, + { + "pose": 0, + "duration": 180, + "scaleY": 0.92 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-greet-bow", + "label": "Greet with a bow", + "category": "idle", + "states": [ + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 400 + }, + { + "pose": 8, + "duration": 350, + "rotate": 8 + }, + { + "pose": 3, + "duration": 500 + }, + { + "pose": 0, + "duration": 650 + } + ] + }, + { + "id": "idle-greet-bounce", + "label": "Greeting bounce", + "category": "idle", + "states": [ + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 250 + }, + { + "pose": 3, + "duration": 200, + "y": -4 + }, + { + "pose": 2, + "duration": 200 + }, + { + "pose": 3, + "duration": 200, + "y": -2 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-listen-close", + "label": "Lean in to listen", + "category": "idle", + "states": [ + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 350 + }, + { + "pose": 2, + "duration": 450, + "x": 4, + "rotate": -3 + }, + { + "pose": 3, + "duration": 800, + "x": 4, + "rotate": -3 + }, + { + "pose": 0, + "duration": 400 + } + ] + }, + { + "id": "idle-ponder", + "label": "Ponder quietly", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 600 + }, + { + "pose": 9, + "duration": 600, + "rotate": 6 + }, + { + "pose": 1, + "duration": 350 + }, + { + "pose": 3, + "duration": 700 + } + ] + }, + { + "id": "idle-remember", + "label": "Remember something", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 1, + "duration": 450 + }, + { + "pose": 2, + "duration": 350, + "rotate": -4 + }, + { + "pose": 3, + "duration": 250, + "y": -2 + }, + { + "pose": 0, + "duration": 600 + } + ] + }, + { + "id": "idle-bashful-bob", + "label": "Bashful bob", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 3, + "duration": 350 + }, + { + "pose": 1, + "duration": 350, + "rotate": 6, + "x": -1 + }, + { + "pose": 2, + "duration": 350, + "rotate": -4 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-content-sway", + "label": "Contented sway", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 650, + "rotate": -3, + "x": -1 + }, + { + "pose": 1, + "duration": 400 + }, + { + "pose": 0, + "duration": 650, + "rotate": 3, + "x": 1 + }, + { + "pose": 2, + "duration": 400 + } + ] + }, + { + "id": "idle-inspect-hoof", + "label": "Inspect a hoof", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 350 + }, + { + "pose": 12, + "duration": 500, + "rotate": 7 + }, + { + "pose": 8, + "duration": 550, + "rotate": 9 + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "idle-turn-and-return", + "label": "Turn and return", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 450 + }, + { + "pose": 13, + "duration": 250, + "flip": true + }, + { + "pose": 2, + "duration": 600, + "flip": true + }, + { + "pose": 14, + "duration": 250 + }, + { + "pose": 0, + "duration": 450 + } + ] + }, + { + "id": "idle-quiet-cheer", + "label": "Quiet cheer", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 300 + }, + { + "pose": 3, + "duration": 350, + "y": -3, + "rotate": -3 + }, + { + "pose": 2, + "duration": 300, + "rotate": 3 + }, + { + "pose": 0, + "duration": 550 + } + ] + }, + { + "id": "idle-balance", + "label": "Balance on a hoof", + "category": "idle", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 350 + }, + { + "pose": 12, + "duration": 500, + "rotate": -6, + "x": -2 + }, + { + "pose": 14, + "duration": 400, + "rotate": 4, + "x": 1 + }, + { + "pose": 0, + "duration": 550 + } + ] + }, + { + "id": "idle-goodnight", + "label": "Goodnight nod", + "category": "idle", + "states": [ + "sleeping" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 500 + }, + { + "pose": 1, + "duration": 500, + "rotate": 5 + }, + { + "pose": 4, + "duration": 800 + }, + { + "pose": 5, + "duration": 1000 + } + ] + } +] diff --git a/easel/desktop/donkey-actions/movement.json b/easel/desktop/donkey-actions/movement.json new file mode 100644 index 000000000..8830c207f --- /dev/null +++ b/easel/desktop/donkey-actions/movement.json @@ -0,0 +1,1331 @@ +[ + { + "id": "movement-sprint-start", + "label": "Sprint start", + "category": "movement", + "states": [ + "running" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 180, + "scaleY": 0.9 + }, + { + "pose": 12, + "duration": 100, + "x": -1, + "rotate": -6 + }, + { + "pose": 13, + "duration": 100, + "x": -4, + "effect": "dust" + }, + { + "pose": 14, + "duration": 100, + "x": -8 + }, + { + "pose": 15, + "duration": 100, + "x": -11 + } + ] + }, + { + "id": "movement-skid-stop", + "label": "Skid to a stop", + "category": "movement", + "states": [ + "running", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 12, + "duration": 100, + "x": 6 + }, + { + "pose": 13, + "duration": 100, + "x": 1 + }, + { + "pose": 3, + "duration": 120, + "x": -4, + "rotate": 12, + "effect": "dust" + }, + { + "pose": 2, + "duration": 200, + "x": -6, + "rotate": -5 + }, + { + "pose": 0, + "duration": 300, + "x": -6 + } + ] + }, + { + "id": "movement-tiptoe", + "label": "Quiet tiptoe", + "category": "movement", + "states": [ + "running", + "idle" + ], + "loop": true, + "frames": [ + { + "pose": 2, + "duration": 300, + "y": -1 + }, + { + "pose": 12, + "duration": 260, + "x": -1, + "y": -2 + }, + { + "pose": 2, + "duration": 300, + "x": -2, + "y": -1 + }, + { + "pose": 14, + "duration": 260, + "x": -3, + "y": -2 + }, + { + "pose": 0, + "duration": 350, + "x": -4 + } + ] + }, + { + "id": "movement-hop", + "label": "One happy hop", + "category": "movement", + "states": [ + "awake", + "idle" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 140, + "scaleY": 0.85 + }, + { + "pose": 2, + "duration": 120, + "y": -4 + }, + { + "pose": 3, + "duration": 160, + "y": -8, + "effect": "star" + }, + { + "pose": 2, + "duration": 120, + "y": -3 + }, + { + "pose": 0, + "duration": 160, + "scaleY": 0.9 + }, + { + "pose": 1, + "duration": 280 + } + ] + }, + { + "id": "movement-double-hop", + "label": "Double hop", + "category": "movement", + "states": [ + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 120, + "scaleY": 0.86 + }, + { + "pose": 2, + "duration": 140, + "y": -5 + }, + { + "pose": 0, + "duration": 100, + "scaleY": 0.9 + }, + { + "pose": 3, + "duration": 150, + "y": -9, + "x": -2, + "effect": "star" + }, + { + "pose": 2, + "duration": 100, + "y": -3, + "x": -3 + }, + { + "pose": 0, + "duration": 180, + "x": -3, + "scaleY": 0.85 + }, + { + "pose": 1, + "duration": 220, + "x": -3 + } + ] + }, + { + "id": "movement-long-jump", + "label": "Running long jump", + "category": "movement", + "states": [ + "running" + ], + "loop": false, + "frames": [ + { + "pose": 12, + "duration": 100, + "x": 5 + }, + { + "pose": 13, + "duration": 100, + "x": 2, + "scaleY": 0.9 + }, + { + "pose": 14, + "duration": 130, + "x": -2, + "y": -5, + "rotate": -12 + }, + { + "pose": 15, + "duration": 150, + "x": -7, + "y": -8, + "rotate": 8 + }, + { + "pose": 2, + "duration": 130, + "x": -10, + "y": -3 + }, + { + "pose": 0, + "duration": 180, + "x": -11, + "scaleY": 0.85, + "effect": "dust" + } + ] + }, + { + "id": "movement-trip-recover", + "label": "Trip and recover", + "category": "movement", + "states": [ + "running", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 12, + "duration": 120 + }, + { + "pose": 13, + "duration": 100, + "x": -2, + "rotate": -12 + }, + { + "pose": 3, + "duration": 180, + "x": -4, + "rotate": -45, + "effect": "sweat" + }, + { + "pose": 2, + "duration": 160, + "x": -5, + "rotate": -20 + }, + { + "pose": 0, + "duration": 220, + "x": -5 + }, + { + "pose": 1, + "duration": 260, + "x": -5 + } + ] + }, + { + "id": "movement-stumble", + "label": "Wobbly stumble", + "category": "movement", + "states": [ + "running" + ], + "loop": false, + "frames": [ + { + "pose": 12, + "duration": 120, + "x": 2 + }, + { + "pose": 3, + "duration": 140, + "rotate": 15 + }, + { + "pose": 14, + "duration": 120, + "x": -2, + "rotate": -18 + }, + { + "pose": 2, + "duration": 180, + "x": -3, + "rotate": 8, + "effect": "sweat" + }, + { + "pose": 0, + "duration": 260, + "x": -3 + } + ] + }, + { + "id": "movement-prance", + "label": "Proud prance", + "category": "movement", + "states": [ + "running", + "awake" + ], + "loop": true, + "frames": [ + { + "pose": 12, + "duration": 150, + "y": -3, + "rotate": -5 + }, + { + "pose": 13, + "duration": 130, + "x": -2 + }, + { + "pose": 14, + "duration": 150, + "x": -4, + "y": -4, + "rotate": 5 + }, + { + "pose": 15, + "duration": 130, + "x": -6 + }, + { + "pose": 2, + "duration": 240, + "x": -6, + "y": -1 + } + ] + }, + { + "id": "movement-dash-return", + "label": "Dash out and return", + "category": "movement", + "states": [ + "running" + ], + "loop": false, + "frames": [ + { + "pose": 12, + "duration": 90, + "x": -3, + "effect": "dust" + }, + { + "pose": 13, + "duration": 90, + "x": -8 + }, + { + "pose": 3, + "duration": 140, + "x": -11, + "flip": true + }, + { + "pose": 14, + "duration": 90, + "x": -7, + "flip": true + }, + { + "pose": 15, + "duration": 90, + "x": -3, + "flip": true + }, + { + "pose": 0, + "duration": 250 + } + ] + }, + { + "id": "movement-peek", + "label": "Peek around the work", + "category": "movement", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 300 + }, + { + "pose": 2, + "duration": 220, + "x": -2, + "rotate": -4 + }, + { + "pose": 3, + "duration": 550, + "x": -5, + "rotate": -9 + }, + { + "pose": 2, + "duration": 220, + "x": -2 + }, + { + "pose": 1, + "duration": 300 + } + ] + }, + { + "id": "movement-look-up", + "label": "Look up at the picture", + "category": "movement", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 220 + }, + { + "pose": 2, + "duration": 220, + "rotate": 4 + }, + { + "pose": 3, + "duration": 650, + "y": -2, + "rotate": 10, + "effect": "star" + }, + { + "pose": 2, + "duration": 220, + "rotate": 4 + }, + { + "pose": 0, + "duration": 300 + } + ] + }, + { + "id": "movement-surprise", + "label": "Surprised jump", + "category": "movement", + "states": [ + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 140 + }, + { + "pose": 3, + "duration": 100, + "scaleX": 0.9, + "scaleY": 1.1 + }, + { + "pose": 3, + "duration": 200, + "y": -8, + "effect": "question" + }, + { + "pose": 2, + "duration": 120, + "y": -3 + }, + { + "pose": 0, + "duration": 180, + "scaleY": 0.88 + }, + { + "pose": 2, + "duration": 400 + } + ] + }, + { + "id": "movement-celebrate", + "label": "Celebrate a finished piece", + "category": "movement", + "states": [ + "awake", + "idle" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 180 + }, + { + "pose": 3, + "duration": 180, + "y": -5, + "rotate": -8, + "effect": "star" + }, + { + "pose": 2, + "duration": 140 + }, + { + "pose": 3, + "duration": 180, + "y": -5, + "rotate": 8, + "effect": "heart" + }, + { + "pose": 1, + "duration": 380 + } + ] + }, + { + "id": "movement-stamp", + "label": "Impatient hoof stamp", + "category": "movement", + "states": [ + "awake", + "working" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 260 + }, + { + "pose": 12, + "duration": 150, + "y": -2 + }, + { + "pose": 0, + "duration": 100, + "scaleY": 0.9, + "effect": "dust" + }, + { + "pose": 2, + "duration": 250 + }, + { + "pose": 14, + "duration": 150, + "y": -2 + }, + { + "pose": 0, + "duration": 100, + "scaleY": 0.9, + "effect": "dust" + }, + { + "pose": 1, + "duration": 340 + } + ] + }, + { + "id": "movement-bow", + "label": "A little bow", + "category": "movement", + "states": [ + "awake", + "idle" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 240 + }, + { + "pose": 0, + "duration": 200, + "rotate": -8 + }, + { + "pose": 1, + "duration": 450, + "rotate": -18, + "scaleY": 0.9 + }, + { + "pose": 0, + "duration": 200, + "rotate": -8 + }, + { + "pose": 2, + "duration": 300, + "effect": "heart" + } + ] + }, + { + "id": "movement-turn-around", + "label": "Quick turning flourish", + "category": "movement", + "states": [ + "awake", + "running" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 140 + }, + { + "pose": 3, + "duration": 100, + "scaleX": 0.6 + }, + { + "pose": 2, + "duration": 140, + "flip": true + }, + { + "pose": 3, + "duration": 100, + "flip": true, + "scaleX": 0.6 + }, + { + "pose": 1, + "duration": 250, + "effect": "star" + } + ] + }, + { + "id": "movement-stretch", + "label": "Full body stretch", + "category": "movement", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 250 + }, + { + "pose": 2, + "duration": 250, + "scaleY": 1.08 + }, + { + "pose": 3, + "duration": 500, + "scaleY": 1.14, + "scaleX": 0.94, + "y": -2 + }, + { + "pose": 2, + "duration": 250, + "scaleY": 1.05 + }, + { + "pose": 1, + "duration": 400 + } + ] + }, + { + "id": "movement-shake-off", + "label": "Shake off the dust", + "category": "movement", + "states": [ + "awake", + "idle" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 180 + }, + { + "pose": 3, + "duration": 100, + "rotate": -9, + "x": -1 + }, + { + "pose": 2, + "duration": 100, + "rotate": 9, + "x": 1, + "effect": "dust" + }, + { + "pose": 3, + "duration": 100, + "rotate": -6, + "x": -1 + }, + { + "pose": 2, + "duration": 100, + "rotate": 5, + "x": 1 + }, + { + "pose": 0, + "duration": 300 + } + ] + }, + { + "id": "movement-nod", + "label": "An approving nod", + "category": "movement", + "states": [ + "awake", + "working" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 260 + }, + { + "pose": 0, + "duration": 180, + "rotate": -7, + "y": 1 + }, + { + "pose": 2, + "duration": 180 + }, + { + "pose": 0, + "duration": 180, + "rotate": -7, + "y": 1 + }, + { + "pose": 3, + "duration": 300, + "effect": "star" + } + ] + }, + { + "id": "movement-puzzled", + "label": "Puzzled head tilt", + "category": "movement", + "states": [ + "awake", + "working" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 250 + }, + { + "pose": 3, + "duration": 380, + "rotate": 10, + "effect": "question" + }, + { + "pose": 2, + "duration": 180 + }, + { + "pose": 3, + "duration": 380, + "rotate": -7 + }, + { + "pose": 1, + "duration": 300 + } + ] + }, + { + "id": "movement-backstep", + "label": "Startled backstep", + "category": "movement", + "states": [ + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 200 + }, + { + "pose": 3, + "duration": 120, + "x": 2, + "y": -2, + "effect": "sweat" + }, + { + "pose": 14, + "duration": 130, + "x": 5 + }, + { + "pose": 2, + "duration": 220, + "x": 7, + "rotate": 8 + }, + { + "pose": 0, + "duration": 380, + "x": 7 + } + ] + }, + { + "id": "movement-ear-perk", + "label": "Ears perk up", + "category": "movement", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 1, + "duration": 400 + }, + { + "pose": 0, + "duration": 180 + }, + { + "pose": 2, + "duration": 150, + "scaleY": 1.04 + }, + { + "pose": 3, + "duration": 380, + "y": -1 + }, + { + "pose": 2, + "duration": 300 + } + ] + }, + { + "id": "movement-greeting", + "label": "Bouncy hello", + "category": "movement", + "states": [ + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 240 + }, + { + "pose": 3, + "duration": 180, + "rotate": 8, + "y": -2 + }, + { + "pose": 2, + "duration": 140, + "rotate": -4 + }, + { + "pose": 3, + "duration": 180, + "rotate": 8, + "y": -2, + "effect": "heart" + }, + { + "pose": 0, + "duration": 300 + } + ] + }, + { + "id": "movement-balance", + "label": "Balance on one hoof", + "category": "movement", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 180 + }, + { + "pose": 12, + "duration": 250, + "y": -1, + "rotate": -8 + }, + { + "pose": 14, + "duration": 280, + "y": -1, + "rotate": 12 + }, + { + "pose": 12, + "duration": 300, + "y": -1, + "rotate": -5, + "effect": "sweat" + }, + { + "pose": 0, + "duration": 250, + "effect": "dust" + } + ] + }, + { + "id": "movement-dizzy", + "label": "Dizzy wobble", + "category": "movement", + "states": [ + "awake", + "working" + ], + "loop": false, + "frames": [ + { + "pose": 3, + "duration": 220, + "rotate": -12, + "effect": "star" + }, + { + "pose": 2, + "duration": 220, + "x": 2, + "rotate": 12 + }, + { + "pose": 3, + "duration": 240, + "x": -2, + "rotate": -8, + "effect": "star" + }, + { + "pose": 2, + "duration": 240, + "x": 1, + "rotate": 5 + }, + { + "pose": 1, + "duration": 450 + } + ] + }, + { + "id": "movement-charge", + "label": "Brave little charge", + "category": "movement", + "states": [ + "running", + "working" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 260, + "x": 2, + "rotate": -8 + }, + { + "pose": 0, + "duration": 140, + "x": 3, + "scaleY": 0.9 + }, + { + "pose": 12, + "duration": 90, + "x": -1, + "rotate": -10, + "effect": "dust" + }, + { + "pose": 13, + "duration": 90, + "x": -5, + "rotate": -10 + }, + { + "pose": 14, + "duration": 90, + "x": -9, + "rotate": -10 + }, + { + "pose": 15, + "duration": 100, + "x": -12 + } + ] + }, + { + "id": "movement-zigzag", + "label": "Zigzag dash", + "category": "movement", + "states": [ + "running" + ], + "loop": false, + "frames": [ + { + "pose": 12, + "duration": 100, + "x": -2, + "y": -2 + }, + { + "pose": 13, + "duration": 100, + "x": -5, + "y": 1 + }, + { + "pose": 14, + "duration": 100, + "x": -8, + "y": -3 + }, + { + "pose": 3, + "duration": 120, + "x": -9, + "flip": true + }, + { + "pose": 15, + "duration": 100, + "x": -5, + "y": 1, + "flip": true + }, + { + "pose": 12, + "duration": 100, + "x": -1, + "y": -2, + "flip": true + }, + { + "pose": 0, + "duration": 260 + } + ] + }, + { + "id": "movement-pounce", + "label": "Crouch and pounce", + "category": "movement", + "states": [ + "awake", + "working" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 350, + "scaleY": 0.8, + "x": 2 + }, + { + "pose": 1, + "duration": 180, + "scaleY": 0.78, + "x": 2 + }, + { + "pose": 3, + "duration": 130, + "x": -2, + "y": -6, + "rotate": -8 + }, + { + "pose": 2, + "duration": 130, + "x": -6, + "y": -3 + }, + { + "pose": 0, + "duration": 180, + "x": -8, + "scaleY": 0.85, + "effect": "dust" + }, + { + "pose": 2, + "duration": 300, + "x": -8 + } + ] + }, + { + "id": "movement-victory-lap", + "label": "Tiny victory lap", + "category": "movement", + "states": [ + "awake", + "running" + ], + "loop": false, + "frames": [ + { + "pose": 12, + "duration": 120, + "x": -4, + "effect": "star" + }, + { + "pose": 13, + "duration": 120, + "x": -8, + "y": -2 + }, + { + "pose": 14, + "duration": 120, + "x": -4, + "y": -5, + "flip": true + }, + { + "pose": 15, + "duration": 120, + "x": 2, + "y": -3, + "flip": true + }, + { + "pose": 2, + "duration": 180, + "x": 4 + }, + { + "pose": 3, + "duration": 250, + "y": -4, + "effect": "heart" + }, + { + "pose": 0, + "duration": 300 + } + ] + }, + { + "id": "movement-landing", + "label": "Soft landing bounce", + "category": "movement", + "states": [ + "running", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 3, + "duration": 180, + "y": -10 + }, + { + "pose": 2, + "duration": 100, + "y": -4 + }, + { + "pose": 0, + "duration": 130, + "scaleY": 0.75, + "effect": "dust" + }, + { + "pose": 2, + "duration": 140, + "y": -3, + "scaleY": 1.05 + }, + { + "pose": 0, + "duration": 160, + "scaleY": 0.94 + }, + { + "pose": 1, + "duration": 300 + } + ] + }, + { + "id": "movement-shy-retreat", + "label": "Shy retreat", + "category": "movement", + "states": [ + "idle", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 300 + }, + { + "pose": 1, + "duration": 400, + "rotate": 5 + }, + { + "pose": 14, + "duration": 200, + "x": 2 + }, + { + "pose": 2, + "duration": 250, + "x": 4, + "rotate": 8 + }, + { + "pose": 1, + "duration": 500, + "x": 6, + "effect": "heart" + } + ] + } +] \ No newline at end of file diff --git a/easel/desktop/donkey-actions/work.json b/easel/desktop/donkey-actions/work.json new file mode 100644 index 000000000..f8c33e9fd --- /dev/null +++ b/easel/desktop/donkey-actions/work.json @@ -0,0 +1,2508 @@ +[ + { + "id": "work-contour", + "label": "Trace a contour", + "category": "drawing", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 2, + "duration": 360, + "prop": "pencil" + }, + { + "pose": 8, + "duration": 160, + "x": -3, + "y": 1, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 160, + "x": -1, + "y": -3, + "rotate": -4, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 160, + "x": 3, + "y": -2, + "rotate": 3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 11, + "duration": 180, + "x": 5, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 1, + "duration": 550, + "prop": "pencil" + } + ] + }, + { + "id": "work-hatch", + "label": "Crosshatch", + "category": "drawing", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 120, + "x": -2, + "y": -2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 120, + "x": 2, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 8, + "duration": 120, + "x": -1, + "y": -2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 120, + "x": 3, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 11, + "duration": 140, + "x": 3, + "y": -2, + "rotate": -6, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 140, + "x": -2, + "y": 2, + "rotate": -6, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 0, + "duration": 400, + "prop": "pencil" + } + ] + }, + { + "id": "work-dots", + "label": "Stipple", + "category": "drawing", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 2, + "duration": 280, + "prop": "pencil" + }, + { + "pose": 8, + "duration": 100, + "x": -3, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 3, + "duration": 140, + "x": -3, + "y": -2, + "prop": "pencil" + }, + { + "pose": 9, + "duration": 100, + "x": 1, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 3, + "duration": 140, + "x": 1, + "y": -2, + "prop": "pencil" + }, + { + "pose": 10, + "duration": 100, + "x": 4, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 2, + "duration": 450, + "prop": "pencil" + } + ] + }, + { + "id": "work-erase", + "label": "Erase a mark", + "category": "drawing", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 1, + "duration": 300, + "prop": "eraser" + }, + { + "pose": 8, + "duration": 100, + "x": -4, + "rotate": 4, + "prop": "eraser", + "effect": "dust" + }, + { + "pose": 9, + "duration": 100, + "x": 4, + "rotate": -4, + "prop": "eraser", + "effect": "dust" + }, + { + "pose": 10, + "duration": 100, + "x": -4, + "rotate": 4, + "prop": "eraser", + "effect": "dust" + }, + { + "pose": 11, + "duration": 140, + "x": 3, + "prop": "eraser", + "effect": "dust" + }, + { + "pose": 2, + "duration": 240, + "y": -2, + "prop": "eraser" + }, + { + "pose": 0, + "duration": 500, + "effect": "check" + } + ] + }, + { + "id": "work-measure", + "label": "Measure a line", + "category": "drawing", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 220, + "prop": "ruler" + }, + { + "pose": 8, + "duration": 440, + "y": 2, + "rotate": 5, + "prop": "ruler", + "effect": "scan" + }, + { + "pose": 9, + "duration": 200, + "x": -4, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 200, + "x": 4, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 1, + "duration": 480, + "prop": "ruler" + }, + { + "pose": 2, + "duration": 260, + "effect": "check" + } + ] + }, + { + "id": "work-spiral", + "label": "Draw a spiral", + "category": "drawing", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 140, + "x": -1, + "y": -1, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 140, + "x": 1, + "y": -2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 140, + "x": 3, + "y": 0, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 11, + "duration": 140, + "x": 2, + "y": 3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 8, + "duration": 160, + "x": -3, + "y": 3, + "rotate": -5, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 180, + "x": -5, + "y": -2, + "rotate": -8, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 2, + "duration": 420, + "prop": "pencil" + } + ] + }, + { + "id": "work-mix", + "label": "Mix a color", + "category": "painting", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 0, + "duration": 250, + "prop": "palette" + }, + { + "pose": 8, + "duration": 160, + "y": 3, + "rotate": 5, + "prop": "brush" + }, + { + "pose": 9, + "duration": 160, + "x": -2, + "y": 3, + "rotate": -6, + "prop": "palette", + "effect": "paint" + }, + { + "pose": 10, + "duration": 160, + "x": 2, + "y": 2, + "rotate": 6, + "prop": "palette", + "effect": "paint" + }, + { + "pose": 11, + "duration": 180, + "y": 1, + "rotate": -3, + "prop": "palette", + "effect": "paint" + }, + { + "pose": 2, + "duration": 650, + "y": -2, + "prop": "palette" + } + ] + }, + { + "id": "work-wash", + "label": "Lay a wash", + "category": "painting", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 220, + "x": -6, + "y": -2, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 9, + "duration": 220, + "x": 0, + "y": -2, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 10, + "duration": 220, + "x": 6, + "y": -2, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 11, + "duration": 220, + "x": 6, + "y": 2, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 9, + "duration": 220, + "x": 0, + "y": 2, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 8, + "duration": 220, + "x": -6, + "y": 2, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 0, + "duration": 350, + "prop": "brush" + } + ] + }, + { + "id": "work-dab", + "label": "Dab highlights", + "category": "painting", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 260, + "y": 3, + "prop": "palette" + }, + { + "pose": 2, + "duration": 180, + "y": -2, + "prop": "brush" + }, + { + "pose": 9, + "duration": 100, + "x": -2, + "y": 1, + "scaleY": 0.96, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 3, + "duration": 160, + "x": -2, + "y": -3, + "prop": "brush" + }, + { + "pose": 10, + "duration": 100, + "x": 3, + "y": 1, + "scaleY": 0.96, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 2, + "duration": 420, + "prop": "brush" + } + ] + }, + { + "id": "work-roller", + "label": "Paint an edge", + "category": "painting", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 220, + "y": 4, + "rotate": -3, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 9, + "duration": 200, + "y": 1, + "rotate": -3, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 10, + "duration": 200, + "y": -2, + "rotate": -3, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 11, + "duration": 260, + "y": -5, + "rotate": -3, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 3, + "duration": 220, + "x": -3, + "prop": "brush" + }, + { + "pose": 1, + "duration": 400, + "prop": "ruler", + "effect": "scan" + } + ] + }, + { + "id": "work-spatter", + "label": "Flick paint", + "category": "painting", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 8, + "duration": 260, + "x": -3, + "rotate": -10, + "prop": "brush" + }, + { + "pose": 9, + "duration": 90, + "x": -1, + "rotate": -5, + "prop": "brush" + }, + { + "pose": 11, + "duration": 90, + "x": 5, + "rotate": 9, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 10, + "duration": 130, + "x": 2, + "rotate": 3, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 3, + "duration": 250, + "x": -2, + "prop": "palette" + }, + { + "pose": 2, + "duration": 650, + "effect": "spark" + } + ] + }, + { + "id": "work-clean-brush", + "label": "Clean the brush", + "category": "painting", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 8, + "duration": 250, + "y": 4, + "prop": "brush" + }, + { + "pose": 9, + "duration": 150, + "x": -2, + "y": 4, + "rotate": -5, + "prop": "brush" + }, + { + "pose": 10, + "duration": 150, + "x": 2, + "y": 4, + "rotate": 5, + "prop": "brush" + }, + { + "pose": 9, + "duration": 150, + "x": -2, + "y": 4, + "rotate": -5, + "prop": "brush" + }, + { + "pose": 3, + "duration": 130, + "y": -2, + "rotate": 7, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 2, + "duration": 180, + "rotate": -5, + "prop": "brush", + "effect": "paint" + }, + { + "pose": 0, + "duration": 500 + } + ] + }, + { + "id": "work-sentence", + "label": "Write a sentence", + "category": "writing", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 180, + "x": -4, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 180, + "x": -1, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 1, + "duration": 460, + "x": 1, + "prop": "paper", + "effect": "thought" + }, + { + "pose": 10, + "duration": 180, + "x": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 11, + "duration": 180, + "x": 5, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 3, + "duration": 220, + "x": -4, + "y": 2, + "prop": "paper" + } + ] + }, + { + "id": "work-paragraph", + "label": "Start a paragraph", + "category": "writing", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 1, + "duration": 600, + "prop": "paper", + "effect": "scan" + }, + { + "pose": 3, + "duration": 240, + "x": -2, + "y": 2, + "prop": "pencil" + }, + { + "pose": 8, + "duration": 170, + "x": 0, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 170, + "x": 2, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 170, + "x": 4, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 0, + "duration": 300, + "prop": "paper" + } + ] + }, + { + "id": "work-strikeout", + "label": "Cross out a phrase", + "category": "writing", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 1, + "duration": 420, + "prop": "paper" + }, + { + "pose": 8, + "duration": 180, + "x": -4, + "rotate": -4, + "prop": "pencil" + }, + { + "pose": 11, + "duration": 100, + "x": 5, + "rotate": 5, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 2, + "duration": 250, + "y": -2, + "prop": "paper" + }, + { + "pose": 9, + "duration": 160, + "x": -1, + "y": -3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 160, + "x": 2, + "y": -3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 0, + "duration": 420, + "effect": "check" + } + ] + }, + { + "id": "work-outline", + "label": "Outline an idea", + "category": "writing", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 200, + "x": -3, + "y": -3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 3, + "duration": 180, + "prop": "paper" + }, + { + "pose": 9, + "duration": 200, + "x": 0, + "y": 0, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 2, + "duration": 180, + "prop": "paper" + }, + { + "pose": 10, + "duration": 200, + "x": 3, + "y": 3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 1, + "duration": 650, + "prop": "paper", + "effect": "scan" + } + ] + }, + { + "id": "work-proofread", + "label": "Proofread", + "category": "writing", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 1, + "duration": 420, + "y": -3, + "prop": "paper", + "effect": "scan" + }, + { + "pose": 0, + "duration": 420, + "y": 0, + "prop": "paper", + "effect": "scan" + }, + { + "pose": 1, + "duration": 420, + "y": 3, + "prop": "paper", + "effect": "scan" + }, + { + "pose": 3, + "duration": 280, + "y": -1, + "rotate": -4, + "prop": "magnifier" + }, + { + "pose": 9, + "duration": 160, + "y": -1, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 2, + "duration": 450, + "effect": "check" + } + ] + }, + { + "id": "work-sign", + "label": "Sign the page", + "category": "writing", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 0, + "duration": 300, + "prop": "paper" + }, + { + "pose": 8, + "duration": 150, + "x": -4, + "y": 3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 150, + "x": -1, + "y": 4, + "rotate": 4, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 150, + "x": 3, + "y": 2, + "rotate": -4, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 11, + "duration": 130, + "x": 5, + "y": -1, + "rotate": -8, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 2, + "duration": 600, + "y": -2, + "prop": "paper", + "effect": "check" + } + ] + }, + { + "id": "work-type-code", + "label": "Type code", + "category": "coding", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 100, + "x": -1, + "prop": "keyboard", + "effect": "ink" + }, + { + "pose": 9, + "duration": 100, + "x": 1, + "prop": "keyboard", + "effect": "ink" + }, + { + "pose": 10, + "duration": 100, + "x": -1, + "prop": "keyboard", + "effect": "ink" + }, + { + "pose": 11, + "duration": 180, + "y": 2, + "scaleY": 0.97, + "prop": "keyboard" + }, + { + "pose": 2, + "duration": 200, + "y": -1, + "prop": "keyboard" + }, + { + "pose": 1, + "duration": 450, + "prop": "keyboard", + "effect": "scan" + } + ] + }, + { + "id": "work-debug", + "label": "Track a bug", + "category": "coding", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 1, + "duration": 320, + "x": -4, + "prop": "magnifier", + "effect": "scan" + }, + { + "pose": 0, + "duration": 320, + "x": 0, + "prop": "magnifier", + "effect": "scan" + }, + { + "pose": 1, + "duration": 320, + "x": 4, + "prop": "magnifier", + "effect": "scan" + }, + { + "pose": 3, + "duration": 500, + "x": 4, + "rotate": 5, + "prop": "magnifier", + "effect": "thought" + }, + { + "pose": 9, + "duration": 120, + "prop": "keyboard" + }, + { + "pose": 2, + "duration": 500, + "prop": "keyboard", + "effect": "check" + } + ] + }, + { + "id": "work-compile", + "label": "Compile", + "category": "coding", + "states": [ + "working", + "running" + ], + "loop": false, + "frames": [ + { + "pose": 8, + "duration": 220, + "y": 3, + "prop": "gear" + }, + { + "pose": 9, + "duration": 180, + "rotate": -5, + "prop": "gear", + "effect": "spark" + }, + { + "pose": 10, + "duration": 180, + "rotate": 0, + "prop": "gear", + "effect": "spark" + }, + { + "pose": 11, + "duration": 180, + "rotate": 5, + "prop": "gear", + "effect": "spark" + }, + { + "pose": 3, + "duration": 240, + "y": -3, + "prop": "gear" + }, + { + "pose": 2, + "duration": 450, + "effect": "check" + } + ] + }, + { + "id": "work-test", + "label": "Run checks", + "category": "coding", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 9, + "duration": 120, + "prop": "keyboard" + }, + { + "pose": 0, + "duration": 420, + "prop": "paper", + "effect": "scan" + }, + { + "pose": 2, + "duration": 180, + "prop": "paper", + "effect": "check" + }, + { + "pose": 1, + "duration": 380, + "prop": "paper", + "effect": "thought" + }, + { + "pose": 10, + "duration": 160, + "prop": "keyboard" + }, + { + "pose": 3, + "duration": 500, + "prop": "paper", + "effect": "check" + } + ] + }, + { + "id": "work-refactor", + "label": "Rearrange code", + "category": "coding", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 8, + "duration": 220, + "x": -4, + "y": 2, + "prop": "gear" + }, + { + "pose": 2, + "duration": 250, + "x": -4, + "y": -3, + "prop": "gear" + }, + { + "pose": 3, + "duration": 250, + "x": 2, + "y": -3, + "flip": true, + "prop": "gear" + }, + { + "pose": 10, + "duration": 220, + "x": 4, + "y": 2, + "prop": "gear" + }, + { + "pose": 9, + "duration": 200, + "prop": "keyboard", + "effect": "ink" + }, + { + "pose": 1, + "duration": 500, + "effect": "check" + } + ] + }, + { + "id": "work-compare-diff", + "label": "Compare versions", + "category": "coding", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 1, + "duration": 450, + "x": -5, + "prop": "paper", + "effect": "scan" + }, + { + "pose": 3, + "duration": 180, + "prop": "paper" + }, + { + "pose": 1, + "duration": 450, + "x": 5, + "flip": true, + "prop": "paper", + "effect": "scan" + }, + { + "pose": 2, + "duration": 300, + "prop": "paper", + "effect": "thought" + }, + { + "pose": 8, + "duration": 160, + "x": -1, + "prop": "keyboard" + }, + { + "pose": 10, + "duration": 160, + "x": 1, + "prop": "keyboard" + }, + { + "pose": 0, + "duration": 450, + "effect": "check" + } + ] + }, + { + "id": "work-tap-beat", + "label": "Tap a beat", + "category": "music", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 120, + "y": 2, + "scaleY": 0.96, + "prop": "drum", + "effect": "note" + }, + { + "pose": 3, + "duration": 240, + "y": -2, + "prop": "drum" + }, + { + "pose": 9, + "duration": 100, + "y": 1, + "prop": "drum", + "effect": "note" + }, + { + "pose": 2, + "duration": 120, + "y": -1, + "prop": "drum" + }, + { + "pose": 10, + "duration": 100, + "y": 1, + "prop": "drum", + "effect": "note" + }, + { + "pose": 0, + "duration": 400, + "prop": "drum" + } + ] + }, + { + "id": "work-melody", + "label": "Pick a melody", + "category": "music", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 180, + "y": 1, + "prop": "note", + "effect": "note" + }, + { + "pose": 9, + "duration": 180, + "y": -1, + "rotate": -2, + "prop": "note", + "effect": "note" + }, + { + "pose": 10, + "duration": 260, + "y": -4, + "rotate": -4, + "prop": "note", + "effect": "note" + }, + { + "pose": 1, + "duration": 360, + "prop": "note" + }, + { + "pose": 11, + "duration": 320, + "y": 2, + "rotate": 3, + "prop": "note", + "effect": "note" + }, + { + "pose": 2, + "duration": 280 + } + ] + }, + { + "id": "work-conduct", + "label": "Conduct", + "category": "music", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 260, + "y": 3, + "prop": "pencil", + "effect": "note" + }, + { + "pose": 9, + "duration": 260, + "x": -4, + "rotate": -7, + "prop": "pencil" + }, + { + "pose": 10, + "duration": 260, + "x": 4, + "rotate": 7, + "prop": "pencil" + }, + { + "pose": 11, + "duration": 260, + "y": -4, + "prop": "pencil" + }, + { + "pose": 3, + "duration": 130, + "y": -2, + "prop": "pencil" + }, + { + "pose": 2, + "duration": 130, + "prop": "pencil" + } + ] + }, + { + "id": "work-listen", + "label": "Listen closely", + "category": "music", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 3, + "duration": 450, + "rotate": -8, + "prop": "note" + }, + { + "pose": 1, + "duration": 700, + "rotate": -8, + "effect": "note" + }, + { + "pose": 0, + "duration": 400, + "rotate": 5, + "prop": "note" + }, + { + "pose": 2, + "duration": 180, + "y": 2, + "effect": "note" + }, + { + "pose": 3, + "duration": 220, + "y": -1, + "prop": "note" + }, + { + "pose": 0, + "duration": 380 + } + ] + }, + { + "id": "work-sequence", + "label": "Arrange a sequence", + "category": "music", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 180, + "x": -5, + "prop": "note", + "effect": "note" + }, + { + "pose": 2, + "duration": 180, + "x": -3, + "prop": "paper" + }, + { + "pose": 9, + "duration": 180, + "x": 0, + "prop": "note", + "effect": "note" + }, + { + "pose": 3, + "duration": 180, + "x": 2, + "prop": "paper" + }, + { + "pose": 10, + "duration": 180, + "x": 5, + "prop": "note", + "effect": "note" + }, + { + "pose": 1, + "duration": 650, + "prop": "paper", + "effect": "scan" + } + ] + }, + { + "id": "work-record", + "label": "Record a take", + "category": "music", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 250, + "y": 2, + "prop": "note" + }, + { + "pose": 3, + "duration": 250, + "y": -1, + "prop": "note" + }, + { + "pose": 8, + "duration": 180, + "rotate": -3, + "prop": "keyboard", + "effect": "note" + }, + { + "pose": 9, + "duration": 180, + "rotate": 3, + "prop": "keyboard", + "effect": "note" + }, + { + "pose": 10, + "duration": 320, + "y": -2, + "prop": "keyboard", + "effect": "note" + }, + { + "pose": 11, + "duration": 120, + "prop": "keyboard" + }, + { + "pose": 0, + "duration": 600, + "effect": "check" + } + ] + }, + { + "id": "work-read-source", + "label": "Read a source", + "category": "paper", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 0, + "duration": 400, + "prop": "book" + }, + { + "pose": 1, + "duration": 650, + "y": 2, + "rotate": 4, + "prop": "book", + "effect": "scan" + }, + { + "pose": 8, + "duration": 220, + "x": 2, + "prop": "book" + }, + { + "pose": 1, + "duration": 500, + "x": 2, + "y": 1, + "prop": "book", + "effect": "scan" + }, + { + "pose": 3, + "duration": 650, + "y": -2, + "prop": "book", + "effect": "thought" + }, + { + "pose": 2, + "duration": 300, + "prop": "book" + } + ] + }, + { + "id": "work-cite", + "label": "Add a citation", + "category": "paper", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 1, + "duration": 500, + "x": -3, + "prop": "book", + "effect": "scan" + }, + { + "pose": 9, + "duration": 170, + "x": -3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 3, + "duration": 250, + "x": 3, + "prop": "paper" + }, + { + "pose": 8, + "duration": 170, + "x": 3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 170, + "x": 4, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 2, + "duration": 450, + "prop": "paper", + "effect": "check" + } + ] + }, + { + "id": "work-figure", + "label": "Lay out a figure", + "category": "paper", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 8, + "duration": 300, + "x": -3, + "y": -2, + "prop": "ruler" + }, + { + "pose": 10, + "duration": 300, + "x": 3, + "y": -2, + "prop": "ruler" + }, + { + "pose": 2, + "duration": 250, + "y": -3, + "prop": "paper" + }, + { + "pose": 9, + "duration": 180, + "y": 2, + "prop": "paper" + }, + { + "pose": 11, + "duration": 200, + "x": 2, + "y": 3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 1, + "duration": 500, + "prop": "paper", + "effect": "scan" + } + ] + }, + { + "id": "work-equation", + "label": "Work an equation", + "category": "paper", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 8, + "duration": 180, + "x": -2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 1, + "duration": 850, + "prop": "paper", + "effect": "thought" + }, + { + "pose": 9, + "duration": 160, + "x": 2, + "prop": "eraser", + "effect": "dust" + }, + { + "pose": 10, + "duration": 160, + "x": -1, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 11, + "duration": 160, + "x": 3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 3, + "duration": 500, + "y": -2, + "effect": "spark" + } + ] + }, + { + "id": "work-review", + "label": "Review annotations", + "category": "paper", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 3, + "duration": 450, + "x": -3, + "prop": "paper" + }, + { + "pose": 1, + "duration": 550, + "x": 2, + "prop": "book", + "effect": "scan" + }, + { + "pose": 8, + "duration": 180, + "x": 4, + "y": -2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 180, + "x": 4, + "y": 1, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 0, + "duration": 320, + "prop": "paper" + }, + { + "pose": 2, + "duration": 450, + "effect": "check" + } + ] + }, + { + "id": "work-bind", + "label": "Gather pages", + "category": "paper", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 8, + "duration": 220, + "x": -5, + "prop": "paper" + }, + { + "pose": 9, + "duration": 220, + "x": 5, + "flip": true, + "prop": "paper" + }, + { + "pose": 10, + "duration": 220, + "y": 3, + "prop": "paper" + }, + { + "pose": 11, + "duration": 280, + "y": 4, + "scaleY": 0.94, + "prop": "book" + }, + { + "pose": 3, + "duration": 300, + "y": -3, + "prop": "book" + }, + { + "pose": 2, + "duration": 550, + "prop": "book", + "effect": "check" + } + ] + }, + { + "id": "work-insert-cartridge", + "label": "Insert a cartridge", + "category": "gameboy", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 300, + "y": -3, + "prop": "cartridge" + }, + { + "pose": 1, + "duration": 350, + "prop": "cartridge", + "effect": "scan" + }, + { + "pose": 8, + "duration": 160, + "y": 2, + "prop": "cartridge" + }, + { + "pose": 9, + "duration": 150, + "y": 4, + "scaleY": 0.96, + "prop": "gamepad" + }, + { + "pose": 0, + "duration": 450, + "prop": "gamepad" + }, + { + "pose": 3, + "duration": 450, + "prop": "gamepad", + "effect": "spark" + } + ] + }, + { + "id": "work-dpad", + "label": "Test the D-pad", + "category": "gameboy", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 8, + "duration": 220, + "x": -4, + "rotate": -5, + "prop": "gamepad" + }, + { + "pose": 9, + "duration": 220, + "x": 4, + "rotate": 5, + "prop": "gamepad" + }, + { + "pose": 10, + "duration": 220, + "y": -3, + "prop": "gamepad" + }, + { + "pose": 11, + "duration": 220, + "y": 3, + "prop": "gamepad" + }, + { + "pose": 1, + "duration": 350, + "prop": "gamepad", + "effect": "scan" + }, + { + "pose": 2, + "duration": 250, + "effect": "check" + } + ] + }, + { + "id": "work-buttons", + "label": "Test A and B", + "category": "gameboy", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 110, + "x": -1, + "prop": "gamepad", + "effect": "spark" + }, + { + "pose": 2, + "duration": 240, + "prop": "gamepad" + }, + { + "pose": 10, + "duration": 110, + "x": 1, + "prop": "gamepad", + "effect": "spark" + }, + { + "pose": 3, + "duration": 240, + "prop": "gamepad" + }, + { + "pose": 11, + "duration": 220, + "y": 2, + "scaleY": 0.97, + "prop": "gamepad", + "effect": "spark" + }, + { + "pose": 0, + "duration": 400, + "prop": "gamepad" + } + ] + }, + { + "id": "work-tile", + "label": "Place pixel tiles", + "category": "gameboy", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 8, + "duration": 180, + "x": -3, + "y": -2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 180, + "x": 3, + "y": -2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 180, + "x": 3, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 11, + "duration": 180, + "x": -3, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 1, + "duration": 500, + "prop": "magnifier", + "effect": "scan" + }, + { + "pose": 2, + "duration": 250, + "prop": "gamepad" + } + ] + }, + { + "id": "work-build-rom", + "label": "Build a ROM", + "category": "gameboy", + "states": [ + "working", + "running" + ], + "loop": false, + "frames": [ + { + "pose": 8, + "duration": 140, + "prop": "keyboard" + }, + { + "pose": 9, + "duration": 140, + "prop": "keyboard" + }, + { + "pose": 10, + "duration": 280, + "rotate": -6, + "prop": "gear", + "effect": "spark" + }, + { + "pose": 11, + "duration": 280, + "rotate": 6, + "prop": "gear", + "effect": "spark" + }, + { + "pose": 0, + "duration": 250, + "y": 2, + "prop": "cartridge" + }, + { + "pose": 3, + "duration": 550, + "y": -3, + "prop": "cartridge", + "effect": "check" + } + ] + }, + { + "id": "work-playtest", + "label": "Playtest a level", + "category": "gameboy", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 12, + "duration": 120, + "x": -4, + "prop": "gamepad" + }, + { + "pose": 13, + "duration": 120, + "x": -2, + "prop": "gamepad" + }, + { + "pose": 8, + "duration": 150, + "prop": "gamepad" + }, + { + "pose": 10, + "duration": 150, + "x": 2, + "prop": "gamepad" + }, + { + "pose": 14, + "duration": 180, + "x": 3, + "y": -5, + "prop": "gamepad", + "effect": "spark" + }, + { + "pose": 15, + "duration": 150, + "x": 4, + "prop": "gamepad" + }, + { + "pose": 1, + "duration": 500, + "prop": "gamepad", + "effect": "scan" + } + ] + }, + { + "id": "work-ponder", + "label": "Ponder", + "category": "thinking", + "states": [ + "working" + ], + "loop": true, + "frames": [ + { + "pose": 0, + "duration": 450 + }, + { + "pose": 3, + "duration": 550, + "rotate": -6, + "effect": "thought" + }, + { + "pose": 1, + "duration": 900, + "y": 1, + "rotate": -3, + "effect": "thought" + }, + { + "pose": 0, + "duration": 400 + }, + { + "pose": 2, + "duration": 500, + "y": -2, + "effect": "thought" + }, + { + "pose": 3, + "duration": 350 + } + ] + }, + { + "id": "work-hypothesis", + "label": "Try a hypothesis", + "category": "thinking", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 2, + "duration": 500, + "y": -3, + "effect": "thought" + }, + { + "pose": 8, + "duration": 160, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 160, + "x": 3, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 1, + "duration": 600, + "prop": "magnifier", + "effect": "scan" + }, + { + "pose": 3, + "duration": 220, + "y": 2, + "effect": "check" + }, + { + "pose": 2, + "duration": 320 + } + ] + }, + { + "id": "work-weigh", + "label": "Weigh alternatives", + "category": "thinking", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 3, + "duration": 500, + "x": -4, + "rotate": -5, + "prop": "paper" + }, + { + "pose": 1, + "duration": 300, + "effect": "thought" + }, + { + "pose": 3, + "duration": 500, + "x": 4, + "rotate": 5, + "flip": true, + "prop": "paper" + }, + { + "pose": 0, + "duration": 550, + "effect": "thought" + }, + { + "pose": 2, + "duration": 250, + "x": -2, + "prop": "paper" + }, + { + "pose": 9, + "duration": 200, + "x": -2, + "prop": "pencil", + "effect": "check" + } + ] + }, + { + "id": "work-eureka", + "label": "Find an idea", + "category": "thinking", + "states": [ + "working", + "awake" + ], + "loop": false, + "frames": [ + { + "pose": 1, + "duration": 800, + "effect": "thought" + }, + { + "pose": 0, + "duration": 180, + "y": 2, + "scaleY": 0.95 + }, + { + "pose": 2, + "duration": 130, + "y": -3, + "scaleY": 1.03, + "effect": "spark" + }, + { + "pose": 3, + "duration": 260, + "y": -6, + "effect": "spark" + }, + { + "pose": 2, + "duration": 140, + "y": 1, + "scaleY": 0.97 + }, + { + "pose": 0, + "duration": 600, + "effect": "check" + } + ] + }, + { + "id": "work-plan", + "label": "Plan the next steps", + "category": "thinking", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 1, + "duration": 650, + "prop": "paper", + "effect": "thought" + }, + { + "pose": 8, + "duration": 160, + "x": -4, + "y": -2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 9, + "duration": 160, + "x": 0, + "y": 0, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 10, + "duration": 160, + "x": 4, + "y": 2, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 11, + "duration": 300, + "x": 1, + "prop": "ruler", + "effect": "scan" + }, + { + "pose": 2, + "duration": 450, + "prop": "paper", + "effect": "check" + } + ] + }, + { + "id": "work-reconsider", + "label": "Reconsider", + "category": "thinking", + "states": [ + "working" + ], + "loop": false, + "frames": [ + { + "pose": 8, + "duration": 160, + "prop": "pencil", + "effect": "ink" + }, + { + "pose": 3, + "duration": 300, + "x": -3, + "rotate": -5, + "effect": "thought" + }, + { + "pose": 1, + "duration": 700, + "prop": "paper", + "effect": "thought" + }, + { + "pose": 9, + "duration": 150, + "x": 2, + "prop": "eraser", + "effect": "dust" + }, + { + "pose": 10, + "duration": 150, + "x": -2, + "prop": "eraser", + "effect": "dust" + }, + { + "pose": 2, + "duration": 450, + "flip": true, + "prop": "paper" + } + ] + } +] diff --git a/easel/desktop/donkey-gallery.html b/easel/desktop/donkey-gallery.html new file mode 100644 index 000000000..e5084db34 --- /dev/null +++ b/easel/desktop/donkey-gallery.html @@ -0,0 +1,3 @@ +aesel actions

aesel actions

diff --git a/easel/desktop/donkey-gallery.js b/easel/desktop/donkey-gallery.js new file mode 100644 index 000000000..f1499c188 --- /dev/null +++ b/easel/desktop/donkey-gallery.js @@ -0,0 +1,28 @@ +(async function(){ + const $=id=>document.getElementById(id),library=window.DonkeyActions,renderer=window.CompanionScene,params=new URLSearchParams(location.search),pageSize=32; + let actions,filtered=[],page=Math.max(0,Number(params.get('page'))||0),selected=null,playing=true,timer=null,started=performance.now(),visible=new Set(),cards=[]; + const image=new Image(),motion=matchMedia('(prefers-reduced-motion: reduce)'); + function reduced(){return motion.matches||$('motion').checked||params.has('capture');} + function stop(){clearTimeout(timer);timer=null;} + function renderCard(card,elapsed){const sample=library.sampleAction(card.action,elapsed,{reducedMotion:reduced()});renderer.draw(card.canvas.getContext('2d'),image,sample);} + function tick(){stop();if(document.hidden||reduced()||!playing)return;const card=cards.find(c=>c.action.id===selected);if(!card||!visible.has(card.canvas))return;const total=card.action.frames.reduce((s,f)=>s+f.duration,0);renderCard(card,(performance.now()-started)%total);timer=setTimeout(tick,Math.ceil(1000/12));} + const observer=new IntersectionObserver(entries=>{for(const entry of entries){if(entry.isIntersecting)visible.add(entry.target);else visible.delete(entry.target);}if(visible.size)tick();else stop();}); + function show(){ + stop();observer.disconnect();visible.clear();cards=[];page=Math.min(page,Math.max(0,Math.ceil(filtered.length/pageSize)-1));$('clips').replaceChildren(); + const shown=filtered.slice(page*pageSize,(page+1)*pageSize);if(!shown.some(a=>a.id===selected))selected=shown[0]?.id||null; + for(const action of shown){const button=document.createElement('button'),canvas=document.createElement('canvas'),label=document.createElement('span');button.className='clip';button.type='button';button.setAttribute('aria-pressed',String(action.id===selected));button.dataset.action=action.id;canvas.width=canvas.height=112;canvas.setAttribute('aria-hidden','true');label.textContent=action.label;button.append(canvas,label);$('clips').append(button);const card={action,canvas,button};cards.push(card);const total=action.frames.reduce((s,f)=>s+f.duration,0);renderCard(card,total*.45);observer.observe(canvas);button.addEventListener('click',()=>{selected=action.id;started=performance.now();for(const c of cards)c.button.setAttribute('aria-pressed',String(c===card));detail();tick();});} + $('previous').disabled=page===0;$('next').disabled=(page+1)*pageSize>=filtered.length;$('page').textContent=`${filtered.length? page+1:0} / ${Math.ceil(filtered.length/pageSize)} · ${filtered.length} actions`;detail();started=performance.now();tick(); + } + function detail(){const a=actions?.find(a=>a.id===selected);$('detail').textContent=a?`${a.id} · ${a.frames.length} frames · ${a.states.join(', ')}`:'';} + function filter(){const query=$('search').value.toLowerCase().trim(),state=$('state').value;filtered=actions.filter(a=>(!state||a.states.includes(state))&&`${a.id} ${a.label} ${a.category}`.toLowerCase().includes(query));page=0;show();} + function exportPage(){ + const shown=filtered.slice(page*pageSize,(page+1)*pageSize),columns=8,cellWidth=160,cellHeight=156,top=54,out=document.createElement('canvas');out.width=columns*cellWidth;out.height=top+Math.max(1,Math.ceil(shown.length/columns))*cellHeight+12;const ctx=out.getContext('2d');ctx.fillStyle='#241d35';ctx.fillRect(0,0,out.width,out.height);ctx.fillStyle='#fff';ctx.font='bold 22px sans-serif';ctx.fillText(`aesel · ${page*pageSize+1}–${page*pageSize+shown.length} / ${filtered.length}`,18,34); + for(let i=0;isum+f.duration,0);renderer.draw(tile.getContext('2d'),image,library.sampleAction(action,total*.45));const x=(i%columns)*cellWidth,y=top+Math.floor(i/columns)*cellHeight;ctx.imageSmoothingEnabled=false;ctx.drawImage(tile,x+24,y);ctx.font='13px sans-serif';ctx.textAlign='center';ctx.fillStyle='#fff';const words=action.label.split(' ');let line='',lines=[];for(const word of words){if(ctx.measureText(line+' '+word).width>150&&line){lines.push(line);line=word;}else line+=(line?' ':'')+word;}if(line)lines.push(line);lines.slice(0,2).forEach((text,n)=>ctx.fillText(text,x+80,y+127+n*16));} + return out.toDataURL('image/png'); + } + try{ + actions=await library.loadActions();await new Promise((resolve,reject)=>{image.onload=resolve;image.onerror=()=>reject(Error('Cannot load the donkey atlas'));image.src='assets/aesel.png';});filtered=actions;show(); + $('search').addEventListener('input',filter);$('state').addEventListener('change',filter);$('previous').addEventListener('click',()=>{page--;show();});$('next').addEventListener('click',()=>{page++;show();});$('motion').addEventListener('change',show);motion.addEventListener('change',show);$('play').addEventListener('click',()=>{playing=!playing;$('play').textContent=playing?'Pause selected':'Play selected';tick();});$('export').addEventListener('click',()=>{const link=document.createElement('a');link.href=exportPage();link.download=`aesel-actions-${page+1}.png`;link.click();});document.addEventListener('visibilitychange',()=>document.hidden?stop():tick()); + window.donkeyGallery={ready:true,count:actions.length,ids:actions.map(a=>a.id),page:()=>page,setPage:index=>{page=index;show();},exportPage,stop}; + }catch(error){$('error').hidden=false;$('error').textContent=error.message;window.donkeyGallery={ready:false,error:error.message};} +})(); diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css index e47ea89db..554103b89 100644 --- a/easel/desktop/donkey.css +++ b/easel/desktop/donkey.css @@ -1,11 +1,31 @@ -/* Keep the terminal and its footer clear of the persistent companion. */ -:root{--aesel-size:128px} -#terminal{padding-right:calc(var(--aesel-size) + 16px)} -#aesel-donkey{position:fixed;right:8px;bottom:8px;width:var(--aesel-size);height:var(--aesel-size);transform:scaleX(-1);image-rendering:pixelated;pointer-events:none;z-index:12} -#desktop-notice{max-width:calc(100% - var(--aesel-size) - 40px)} - +/* Spriteland: tiles behind selectable text, functional sprites in front. */ +:root{--aesel-size:128px;--aesel-scene-width:128px} +#scene-background,#scene-foreground{position:fixed;left:0;top:0;pointer-events:none;image-rendering:pixelated} +#scene-background{z-index:0}#scene-foreground{z-index:1} +#terminal{position:relative;z-index:2;padding-right:calc(var(--aesel-scene-width) + 16px)} +#aesel-donkey{position:fixed;right:8px;bottom:8px;width:var(--aesel-size);height:var(--aesel-size);image-rendering:pixelated;pointer-events:none;z-index:12} +#desktop-notice{max-width:calc(100% - var(--aesel-scene-width) - 40px)} body[data-phase="startup"] #terminal{padding-right:10px} +/* Name hover swaps the preview surface; the QR never floats across it. */ +#qr-card{position:absolute;inset:0;width:100%;height:100%;padding:0;border:0;border-radius:0;background:var(--aesel-background,#463264);box-shadow:none;z-index:13;display:none;align-items:center;justify-content:center;transform:none;opacity:1} +body[data-qr-preview="true"] #qr-card{display:flex} +body[data-qr-preview="true"] #preview-viewport,body[data-qr-preview="true"] #media-preview{visibility:hidden} +#qr-card:hover{filter:none;outline:none} +#qr-card:active{transform:none;box-shadow:none} +#qr{display:block;width:var(--preview-qr-size,82px);height:var(--preview-qr-size,82px);image-rendering:pixelated} +#version{display:block;position:fixed;right:12px;bottom:6px;z-index:12;font:calc(var(--aesel-qr-label-size,16px) * .75) "AC aesel Unifont",monospace;pointer-events:none} +#aesel-donkey{bottom:8px} +body.preview-fullscreen #version{display:none} +@media(prefers-reduced-motion:reduce){#qr-card{transition:none}} +#qr-label{z-index:14} +body.preview-fullscreen #aesel-donkey,body.preview-fullscreen #scene-foreground{display:none} + +#terminal{box-sizing:content-box;height:calc(100% - var(--aesel-qr-label-size,16px) * 2.4 - 32px);padding-top:calc(var(--aesel-qr-label-size,16px) * 2.4 + 22px)} +body[data-phase="startup"] #terminal{padding-top:10px;height:calc(100% - 20px)} + +body[data-native-title="true"] #qr-label{opacity:0} -/* One wooden shelf continues underneath the terminal and Aesel's feet. */ -body:not([data-phase="startup"])::before{content:"";position:fixed;left:0;right:0;bottom:0;height:var(--easel-shelf-height,84px);background:repeating-linear-gradient(177deg,#33231b 0 5px,#3a271c 6px 7px,#33231b 8px 14px,#432d1e 15px 16px);pointer-events:none} -#terminal{position:relative} +#donkey-qr-card{display:none;position:fixed;right:14px;bottom:var(--donkey-qr-bottom,160px);width:var(--donkey-qr-size,164px);height:var(--donkey-qr-size,164px);padding:0;border:0;background:white;box-shadow:2px 2px #0005;z-index:13;cursor:pointer} +#donkey-qr{display:block;width:100%;height:100%;image-rendering:pixelated} +body[data-preview-qr="true"] #donkey-qr-card{display:block} +body[data-qr-preview="true"] #donkey-qr-card,body.preview-fullscreen #donkey-qr-card,#donkey-qr-card[hidden]{display:none} diff --git a/easel/desktop/donkey.js b/easel/desktop/donkey.js index 94d9b18e7..d94d89ff3 100644 --- a/easel/desktop/donkey.js +++ b/easel/desktop/donkey.js @@ -1,4 +1,4 @@ -// Aesel: a status companion. No animation-frame loop or remote resources. +// aesel: a status companion. No animation-frame loop or remote resources. (function(scope){ const ANIMATIONS={idle:{frames:[0,1],durations:[1400,180]},awake:{frames:[2,3],durations:[500,500]},sleeping:{frames:[4,5,6,7],durations:[900,900,900,900]},working:{frames:[8,9,10,11],durations:[180,180,180,180]},running:{frames:[12,13,14,15],durations:[120,120,120,120]}}; function statusPhase(status){ @@ -27,19 +27,38 @@ }return result; } function createDonkey({canvas,document:doc=scope.document,clock=()=>Date.now()}={}){ - if(!canvas)return {update(){},destroy(){}}; - const context=canvas.getContext('2d');if(!context)return {update(){},destroy(){}}; + if(!canvas)return {update(){},setScale(){},destroy(){}}; + const context=canvas.getContext('2d');if(!context)return {update(){},setScale(){},destroy(){}}; const media=scope.matchMedia('(prefers-reduced-motion: reduce)'),image=new scope.Image(); - let animations=ANIMATIONS,loaded=false,destroyed=false,timer=null,status='ready',lastActivity=clock(),awakeUntil=lastActivity+2500,phase=null,phaseStarted=lastActivity,lastFrame=-1; + let animations=ANIMATIONS,loaded=false,destroyed=false,timer=null,status='ready',lastActivity=clock(),awakeUntil=lastActivity+2500,phase=null,phaseStarted=lastActivity,lastFrame=-1,matrix=null,version=0,pixelScale=2; + const scene=scope.CompanionScene; + let player=null,actionStarted=clock(),lastSample=""; + scope.DonkeyActions?.loadActions().then(actions=>{player=scope.DonkeyActions.createActionPlayer(actions,{seed:clock(),state:phase||"idle",now:clock(),reducedMotion:media.matches});actionStarted=clock();lastFrame=-1;tick();}).catch(()=>{}); + function geometry(){ + const box=scene.layout(matrix); + if(canvas.width!==box.width||canvas.height!==box.height){canvas.width=box.width;canvas.height=box.height;lastFrame=-1;} + if(canvas.style){canvas.style.width=box.width*pixelScale+'px';canvas.style.height=box.height*pixelScale+'px';} + const style=doc.documentElement?.style; + style?.setProperty('--aesel-scene-width',box.width*pixelScale+'px'); + if(box.qr){const q=box.qr;style?.setProperty('--aesel-qr-right',(8+(box.width-q.x-q.width)*pixelScale)+'px');style?.setProperty('--aesel-qr-bottom',(8+(box.height-q.y-q.height)*pixelScale)+'px');style?.setProperty('--aesel-qr-size',q.width*pixelScale+'px');} + return box; + } canvas.width=canvas.height=64;context.imageSmoothingEnabled=false; function clear(){if(timer!==null)scope.clearTimeout(timer);timer=null;} function tick(){ clear();if(destroyed||doc.hidden)return; const now=clock(),nextPhase=phaseFor({status,now,lastActivity,awakeUntil}); - if(nextPhase!==phase){phase=nextPhase;phaseStarted=now;canvas.dataset.state=phase;canvas.setAttribute('aria-label',`Aesel the donkey: ${phase}`);} + if(nextPhase!==phase){phase=nextPhase;phaseStarted=now;player?.setState(phase,now);actionStarted=now;canvas.dataset.state=phase;canvas.setAttribute('aria-label',`aesel the donkey: ${phase}`);} const frame=animationFrame(phase,now-phaseStarted,media.matches,animations); - if(loaded&&frame.frame!==lastFrame){context.clearRect(0,0,64,64);context.drawImage(image,(frame.frame%4)*64,Math.floor(frame.frame/4)*64,64,64,0,0,64,64);lastFrame=frame.frame;} let delay=frame.delay; + if(player){ + player.setReducedMotion(media.matches); + let sample=player.sample(now); + if(!media.matches&&(sample?.done||now-actionStarted>Math.max(8000,(sample?.duration||0)*3))){sample=player.next(now);actionStarted=now;} + const key=sample?`${sample.id}:${sample.frameIndex}`:''; + if(loaded&&sample&&(key!==lastSample||lastFrame===-1)){geometry();scene.draw(context,image,sample);lastSample=key;lastFrame=sample.pose;canvas.dataset.action=sample.id;canvas.setAttribute('aria-label',`aesel: ${sample.label}`);} + delay=sample?.nextDelay??null; + }else if(loaded&&frame.frame!==lastFrame){geometry();scene.draw(context,image,frame.frame);lastFrame=frame.frame;} if(statusPhase(status)==='ready'){ const deadline=nownow)delay=delay===null?deadline-now:Math.min(delay,deadline-now); @@ -53,7 +72,8 @@ scope.fetch('assets/aesel.json').then(r=>r.ok?r.json():null).then(value=>{animations=manifestAnimations(value);tick();}).catch(()=>{}); doc.addEventListener('pointermove',wake,{passive:true});doc.addEventListener('pointerdown',wake,{passive:true});doc.addEventListener('keydown',wake);doc.addEventListener('visibilitychange',visibility);media.addEventListener('change',tick);tick(); return { - update(state={}){const next=String(state.status||'ready').toLowerCase();if(statusPhase(next)!==statusPhase(status)){if(statusPhase(next)==='ready'){lastActivity=clock();awakeUntil=lastActivity+2500;}status=next;tick();}else status=next;}, + setScale(value){if(value===pixelScale)return;pixelScale=value;geometry();lastFrame=-1;tick();}, + update(state={}){if(JSON.stringify(state.qr||null)!==JSON.stringify(matrix)||version!==(state.version||0)){matrix=state.qr||null;version=state.version||0;geometry();lastFrame=-1;tick();}const next=String(state.status||'ready').toLowerCase();if(statusPhase(next)!==statusPhase(status)){if(statusPhase(next)==='ready'){lastActivity=clock();awakeUntil=lastActivity+2500;}status=next;tick();}else status=next;}, destroy(){destroyed=true;clear();doc.removeEventListener('pointermove',wake);doc.removeEventListener('pointerdown',wake);doc.removeEventListener('keydown',wake);doc.removeEventListener('visibilitychange',visibility);media.removeEventListener('change',tick);}, }; } diff --git a/easel/desktop/index.html b/easel/desktop/index.html index f105fcae1..2f5359a87 100644 --- a/easel/desktop/index.html +++ b/easel/desktop/index.html @@ -1,7 +1,8 @@ -Easel -
- - +aesel +
+ + + diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs index b0e9e5cbf..899dd47df 100644 --- a/easel/desktop/main.cjs +++ b/easel/desktop/main.cjs @@ -9,9 +9,9 @@ const {createUpdater} = require('./updater.cjs'); const {startFrameCapture} = require('./frame-capture.cjs'); const { tmpdir } = require('node:os'); -app.setName('Easel'); +app.setName('aesel'); app.setPath('userData', join(app.getPath('appData'), 'Easel')); -let window, terminal, timer, quitting = false; +let window, terminal, timer, ledgerWatcher, quitting = false; let terminalSize = {cols:100,rows:32}; let currentTheme = FALLBACK; const themeFollower = followSlabTheme(theme => { currentTheme = theme; if (window && !window.isDestroyed()) { window.setBackgroundColor(theme.background); send('theme',theme); } }); @@ -70,29 +70,40 @@ function requestRestart(action = 'restart') { writeFileSync(intentFile, JSON.stringify({action}), {mode:0o600}); terminal.kill('SIGUSR2'); } -const canUpdateBinary = app.isPackaged && existsSync(join(process.resourcesPath,'app-update.yml')); +// Mac App Store owns updates for its sandboxed build. The Developer ID build +// uses aesel's generic signed feed; never let electron-updater compete with +// Store receipts or present a non-Store update inside the MAS app. +const canUpdateBinary = app.isPackaged && !process.mas && existsSync(join(process.resourcesPath,'app-update.yml')); const desktopUpdater = createUpdater({app, canUpdateBinary, requestRestart, prepareRelaunch: () => writeFileSync(continuationFile,JSON.stringify({cwd:workspace,at:Date.now()}),{mode:0o600}), notify: message => send('desktop-notice', message)}); function send(channel, data) { if (window && !window.isDestroyed()) window.webContents.send(channel, data); } -function start() { +function start({home = false} = {}) { if (terminal) return; const args = [join(root, 'src/tui.mjs'), '--cwd', workspace]; - for (const name of ['--piece', '--resume', '--model', '--backend', '--effort']) if (option(name)) args.push(name, option(name)); - if (continueSession) args.push('--continue-session'); + for (const name of ['--piece', '--resume', '--model', '--backend', '--effort']) if (option(name) && !(home && ['--piece','--resume'].includes(name))) args.push(name, option(name)); + if (continueSession && !home) args.push('--continue-session'); if (supplied.includes('--no-autopublish')) args.push('--no-autopublish'); - const env = { ...process.env, ELECTRON_RUN_AS_NODE: '1', TERM: 'xterm-256color', COLORTERM: 'truecolor', SLAB_HOME: slabHome, EASEL_DESKTOP: '1', EASEL_THEME:'slab', EASEL_MOUSE:'0', EASEL_DESKTOP_SESSION:sessionFile, EASEL_DESKTOP_CONTROL:controlFile, EASEL_DESKTOP_INTENT:intentFile, EASEL_PREVIEW_EVENTS:previewEventsFile }; + const env = { ...process.env, ELECTRON_RUN_AS_NODE: '1', TERM: 'xterm-256color', COLORTERM: 'truecolor', SLAB_HOME: slabHome, EASEL_DESKTOP: '1', EASEL_HOST_PID:String(process.pid), EASEL_HOST_WINDOW_ID:window.getMediaSourceId().split(':')[1], EASEL_THEME:'slab', EASEL_MOUSE:'0', EASEL_DESKTOP_SESSION:sessionFile, EASEL_DESKTOP_CONTROL:controlFile, EASEL_DESKTOP_INTENT:intentFile, EASEL_PREVIEW_EVENTS:previewEventsFile }; delete env.NODE_OPTIONS; delete env.NO_COLOR; env.FORCE_COLOR = '3'; env.EASEL_GROUND = 'paint'; try { - terminal = pty.spawn(process.execPath, args, { name: 'xterm-256color', ...terminalSize, cwd: workspace, env }); + terminal = process.mas + ? require('./mas-terminal.cjs').createMasTerminal(args,{...terminalSize,cwd:workspace,env}) + : pty.spawn(process.execPath, args, { name: 'xterm-256color', ...terminalSize, cwd: workspace, env }); terminal.onData(data => send('output', data)); terminal.onExit(({ exitCode }) => { terminal = null; if (exitCode === 75 && existsSync(controlFile)) { const request = JSON.parse(readFileSync(controlFile, 'utf8')); rmSync(controlFile, {force:true}); + if (request.action === 'home') { + timer?.close(); ledgerWatcher?.close(); + pendingRestart = false; + send('output', '\x1b]777;easel-phase:startup\x07\x1b[2J\x1b[H'); + return start({home:true}); + } if (['restart','update'].includes(request.action)) { quitting = true; timer?.close(); @@ -100,8 +111,8 @@ function start() { } } if (exitCode === 0) { quitting = true; app.quit(); return; } - pendingRestart = false; send('output', `\r\nEasel closed (${exitCode}). Close this window to finish.\r\n`); if (quitting) app.quit(); }); - } catch (error) { send('output', `Could not start Easel: ${error.message}\r\n`); } + pendingRestart = false; send('output', `\r\naesel closed (${exitCode}). Close this window to finish.\r\n`); if (quitting) app.quit(); }); + } catch (error) { send('output', `Could not start aesel: ${error.message}\r\n`); } let previous = '', qrUrl = '', qr = null, previewKey = '', previewData = {}; const dir = join(slabHome, 'state/active-prompts'); mkdirSync(dir, {recursive:true}); @@ -115,7 +126,9 @@ function start() { const state = JSON.parse(readFileSync(join(dir, name), 'utf8')); themeFollower.setStatus(state.state); previewContext=(state.artifact_kind||'piece')==='piece'&&state.piece_channel?{channel:state.piece_channel,revision:state.piece_revision,version:state.piece_version}:null; - const visible = JSON.stringify({ piece: state.piece, status:state.state, url: state.scan_url, version: state.piece_version, publication:state.piece_published_at || "", flow: state.flow, medium: state.artifact_kind || 'piece', preview: state.artifact_preview || null }); + let proxName=null; + try {const ledger=JSON.parse(readFileSync(join(app.getPath('home'),'.config/slab/ledger/local.json'),'utf8'));const entry=ledger.entries?.find(e=>e.id===state.session_id&&e.agentType==='easel');if(entry?.proxNamespace==='easel'&&entry.proxName?.length<=100&&/^[a-z0-9_-]+(?:\/[a-z0-9_-]+)?$/.test(entry.proxName))proxName=entry.proxName;}catch{} + const visible = JSON.stringify({ proxName, handle:state.handle||"",handleColors:state.handle_colors||null, piece: state.piece, status:state.state, url: state.scan_url, version: state.piece_version, publication:state.piece_published_at || "", flow: state.flow, medium: state.artifact_kind || 'piece', preview: state.artifact_preview || null }); if (visible !== previous) { previous = visible; if (state.scan_url !== qrUrl) { @@ -136,20 +149,22 @@ function start() { finally {updating=false;if(updateAgain){updateAgain=false;void update();}} }; timer = watch(dir, () => { update(); }); + try {ledgerWatcher=watch(join(app.getPath('home'),'.config/slab/ledger'),(_,file)=>{if(String(file)==='local.json')update();});}catch{} update(); } app.whenReady().then(() => { if (!primaryInstance) return; Menu.setApplicationMenu(Menu.buildFromTemplate([ - { label: 'Easel', submenu: [ + { label: 'aesel', submenu: [ {role:'about'}, {type:'separator'}, {role:'close',accelerator:'CmdOrCtrl+W'}, {label:'Check for Updates…', click:()=>desktopUpdater.check()}, - {label:'Restart Easel', click:()=>requestRestart('restart')}, + {label:'Restart aesel', click:()=>requestRestart('restart')}, {type:'separator'}, {role:'hide'}, {role:'hideOthers'}, {role:'unhide'}, {type:'separator'}, {role:'quit'}, ] }, { role: 'editMenu' }, { label: 'View', submenu: [ + {label:'aesel Actions…',click:()=>{const gallery=new BrowserWindow({parent:window,width:1120,height:850,title:'aesel actions',backgroundColor:'#241d35',webPreferences:{contextIsolation:true,nodeIntegration:false,sandbox:true}});gallery.loadFile(join(__dirname,'donkey-gallery.html'));}}, {label:'Larger Text',accelerator:'CmdOrCtrl+=',click:()=>send('text-size','larger')}, {label:'Smaller Text',accelerator:'CmdOrCtrl+-',click:()=>send('text-size','smaller')}, {label:'Reset Text Size',accelerator:'CmdOrCtrl+0',click:()=>send('text-size','reset')}, @@ -165,7 +180,7 @@ app.whenReady().then(() => { ] }, { role: 'windowMenu' }, ])); - window = new BrowserWindow({ width: 760, height: 540, title: 'Easel', backgroundColor: '#463264', + window = new BrowserWindow({ width: 760, height: 540, title: 'aesel', backgroundColor: '#463264', webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, webviewTag: true, plugins:true } }); window.webContents.on('will-attach-webview', (event, preferences, params) => { delete preferences.preload; @@ -191,7 +206,7 @@ app.whenReady().then(() => { setTimeout(async () => { const target = resolve(option('--diagnostics')); mkdirSync(target, {recursive:true}); - const renderer = await window.webContents.executeJavaScript(`({font:document.fonts.check('16px "AC Easel Unifont"'), canvases:document.querySelectorAll('.xterm canvas').length, qr:!document.getElementById('qr-card').hidden})`); + const renderer = await window.webContents.executeJavaScript(`({font:document.fonts.check('16px "AC aesel Unifont"'), canvases:document.querySelectorAll('.xterm canvas').length, qr:!document.getElementById('qr-card').hidden})`); writeFileSync(join(target, 'gpu.json'), JSON.stringify({features:app.getGPUFeatureStatus(),info:await app.getGPUInfo('complete'),renderer},null,2)); writeFileSync(join(target, 'window.png'), (await window.webContents.capturePage()).toPNG()); }, 3000); @@ -208,13 +223,38 @@ app.whenReady().then(() => { if (terminal) { event.preventDefault(); if (!quitting) { quitting = true; terminal.kill('SIGTERM'); setTimeout(() => { if (terminal) { quitting = false; send('desktop-notice','The session is still saving or working. Retry closing when it is ready.'); } }, 6000).unref(); } } }); }); +let rockLayoutPath='',lastRockLayout='',rockLayoutWatcher; +function checkNativeTitle(){ + try{ + const ack=JSON.parse(readFileSync(rockLayoutPath+'.ack','utf8')),expected=JSON.parse(lastRockLayout); + send('native-title',ack.title===expected.title&&ack.fontSize===expected.titleFontSize&&['titleX','titleY','titleWidth','titleHeight'].every(k=>Math.abs(ack[k]-expected[k])<1)); + }catch{send('native-title',false);} +} + +ipcMain.on('title-geometry',(event,value)=>{ + if(event.sender!==window?.webContents||!value||!['x','y','size'].every(k=>Number.isFinite(value[k]))||typeof value.visible!=='boolean')return; + const bounds=window.getBounds(),content=window.getContentBounds(); + if(value.x<0||value.y<0||value.x>content.width||value.y>content.height||value.size<16||value.size>96)return; + const layout={x:value.x+content.x-bounds.x,y:value.y+content.y-bounds.y,size:value.size,visible:value.visible}; + if(typeof value.title==='string'&&value.title.length<=120&&['titleX','titleY','titleWidth','titleHeight','titleFontSize'].every(k=>Number.isFinite(value[k])&&value[k]>=0)){ + Object.assign(layout,{title:value.title,titleX:value.titleX+content.x-bounds.x,titleY:value.titleY+content.y-bounds.y,titleWidth:value.titleWidth,titleHeight:value.titleHeight,titleFontSize:value.titleFontSize,titleColors:Array.isArray(value.titleColors)?value.titleColors.slice(0,120).map(c=>Array.isArray(c)&&c.length===3&&c.every(n=>Number.isFinite(n)&&n>=0&&n<=255)?c:[255,255,255]):[]}); + } + const serialized=JSON.stringify(layout);if(serialized===lastRockLayout)return;lastRockLayout=serialized; + try{ + const dir=join(app.getPath('home'),'.local/share/slab/state/easel-layout');mkdirSync(dir,{recursive:true,mode:0o700}); + rockLayoutPath=join(dir,`${process.pid}-${window.getMediaSourceId().split(':')[1]}.json`); + writeFileSync(rockLayoutPath+'.tmp',serialized,{mode:0o600});renameSync(rockLayoutPath+'.tmp',rockLayoutPath); + if(!rockLayoutWatcher)rockLayoutWatcher=watch(dir,(_,file)=>{if(rockLayoutPath.endsWith(String(file).replace(/\.ack$/,''))&&String(file).endsWith('.ack'))checkNativeTitle();}); + checkNativeTitle(); + }catch(error){console.warn('aesel rock layout:',error.message);} +}); ipcMain.on('ready', event => { if (event.sender === window?.webContents) { send('theme',currentTheme); sendDisplay(true); start(); } }); ipcMain.on('closing', event => { if(event.sender===window?.webContents) window.hide(); }); ipcMain.on('input', (event, data) => { if (event.sender === window?.webContents && typeof data === 'string' && data.length < 1_048_576) terminal?.write(data); }); ipcMain.on('size', (event, { cols, rows } = {}) => { - if (event.sender === window?.webContents && Number.isInteger(cols) && Number.isInteger(rows) && cols >= 32 && cols <= 500 && rows >= 10 && rows <= 300) { terminalSize={cols,rows}; terminal?.resize(cols, rows); } + if (event.sender === window?.webContents && Number.isInteger(cols) && Number.isInteger(rows) && cols >= 32 && cols <= 500 && rows >= 10 && rows <= 300) { if(cols!==terminalSize.cols||rows!==terminalSize.rows){terminalSize={cols,rows};terminal?.resize(cols,rows);} } }); -app.on('window-all-closed', () => { timer?.close(); stopFrameCapture(); themeFollower.close(); terminal?.kill(); app.quit(); }); +app.on('window-all-closed', () => { rockLayoutWatcher?.close();if(rockLayoutPath){rmSync(rockLayoutPath,{force:true});rmSync(rockLayoutPath+'.ack',{force:true});} timer?.close(); ledgerWatcher?.close(); stopFrameCapture(); themeFollower.close(); terminal?.kill(); app.quit(); }); const isWindow = event => event.sender === window?.webContents; ipcMain.on('copy-text', (event, text) => { if (isWindow(event) && typeof text === 'string' && text.length <= 1048576) clipboard.writeText(text); }); @@ -249,6 +289,7 @@ function toggleFullscreen(target) { } fullscreenState(); } +ipcMain.on('open-link',(event,value)=>{if(!isWindow(event)||typeof value!=='string'||value.length>4096)return;try{const url=new URL(value);if(['http:','https:'].includes(url.protocol)&&!url.username&&!url.password)shell.openExternal(url.href);}catch{}}); ipcMain.on('open-piece', (event, value) => { if (!isWindow(event) || typeof value !== 'string') return; try { diff --git a/easel/desktop/mas-terminal-worker.cjs b/easel/desktop/mas-terminal-worker.cjs new file mode 100644 index 000000000..3aa7ee6f1 --- /dev/null +++ b/easel/desktop/mas-terminal-worker.cjs @@ -0,0 +1,22 @@ +const {PassThrough}=require('node:stream'); +const {pathToFileURL}=require('node:url'); +const input=new PassThrough(); +input.isTTY=true; +input.isRaw=false; +input.setRawMode=value=>{input.isRaw=!!value;return input;}; +Object.defineProperty(process,'stdin',{value:input}); +for(const output of [process.stdout,process.stderr]){ + output.isTTY=true; + output.columns=Number(process.env.EASEL_COLUMNS)||100; + output.rows=Number(process.env.EASEL_ROWS)||32; + output.getWindowSize=()=>[output.columns,output.rows]; +} +process.parentPort.on('message',({data})=>{ + if(data.type==='input')input.write(data.data); + else if(data.type==='resize'){ + for(const output of [process.stdout,process.stderr]){output.columns=data.cols;output.rows=data.rows;output.emit('resize');} + }else if(data.type==='signal'&&data.signal==='SIGUSR2')process.emit('SIGUSR2'); +}); +const entry=process.argv[2]; +process.argv=[process.execPath,...process.argv.slice(2)]; +import(pathToFileURL(entry).href).catch(error=>{console.error(error);process.exit(1);}); diff --git a/easel/desktop/mas-terminal.cjs b/easel/desktop/mas-terminal.cjs new file mode 100644 index 000000000..04f283306 --- /dev/null +++ b/easel/desktop/mas-terminal.cjs @@ -0,0 +1,16 @@ +// The App Sandbox cannot launch node-pty's POSIX terminal helper. Run the same +// TUI in Electron's signed utility helper and carry terminal input over IPC. +const {utilityProcess}=require('electron'); +const {join}=require('node:path'); +exports.createMasTerminal=function(args,{cwd,env,cols,rows}){ + const childEnv={...env,EASEL_COLUMNS:String(cols),EASEL_ROWS:String(rows)}; + delete childEnv.ELECTRON_RUN_AS_NODE; + const child=utilityProcess.fork(join(__dirname,'mas-terminal-worker.cjs'),args,{cwd,env:childEnv,stdio:'pipe',serviceName:'aesel workspace'}); + return { + onData(fn){for(const stream of [child.stdout,child.stderr]){stream.setEncoding('utf8');let previous='';stream.on('data',data=>{const normalized=(previous+data).replace(/(?fn({exitCode}));}, + write(data){child.postMessage({type:'input',data});}, + resize(cols,rows){child.postMessage({type:'resize',cols,rows});}, + kill(signal){if(signal==='SIGUSR2')child.postMessage({type:'signal',signal});else child.kill();} + }; +}; diff --git a/easel/desktop/package-lock.json b/easel/desktop/package-lock.json new file mode 100644 index 000000000..cca43cda4 --- /dev/null +++ b/easel/desktop/package-lock.json @@ -0,0 +1,5422 @@ +{ + "name": "aesel-desktop", + "version": "0.7.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aesel-desktop", + "version": "0.7.2", + "dependencies": { + "@xterm/addon-fit": "0.10.0", + "@xterm/addon-webgl": "0.18.0", + "@xterm/xterm": "5.5.0", + "electron-updater": "6.6.2", + "node-pty": "1.1.0" + }, + "devDependencies": { + "electron": "39.2.7", + "electron-builder": "25.1.8" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", + "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz", + "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "node-gyp": "^9.0.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/rebuild/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/rebuild/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/rebuild/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/rebuild/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz", + "integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz", + "integrity": "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-25.1.8.tgz", + "integrity": "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.1", + "@electron/rebuild": "3.6.1", + "@electron/universal": "2.0.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chromium-pickle-js": "^0.2.0", + "config-file-ts": "0.2.8-rc1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "25.1.7", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.0", + "resedit": "^1.7.0", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "25.1.8", + "electron-builder-squirrel-windows": "25.1.8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-25.1.7.tgz", + "integrity": "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.10", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.8-rc1", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", + "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.12", + "typescript": "^5.4.3" + } + }, + "node_modules/config-file-ts/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-25.1.8.tgz", + "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "39.2.7", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.7.tgz", + "integrity": "sha512-KU0uFS6LSTh4aOIC3miolcbizOFP7N1M46VTYVfqIgFiuA2ilfNaOHLDS9tCMvwwHRowAsvqBrh9NgMXcTOHCQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-25.1.8.tgz", + "integrity": "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "dmg-builder": "25.1.8", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", + "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "25.1.8", + "archiver": "^5.3.1", + "builder-util": "25.1.7", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", + "integrity": "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-updater": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.6.2.tgz", + "integrity": "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.3.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "^7.6.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz", + "integrity": "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/easel/desktop/package.json b/easel/desktop/package.json index da5306d11..5c3cc84d2 100644 --- a/easel/desktop/package.json +++ b/easel/desktop/package.json @@ -1,12 +1,14 @@ { - "name": "easel-desktop", - "productName": "Easel", - "version": "0.7.0-preview.1", + "name": "aesel-desktop", + "productName": "aesel", + "version": "0.7.2", "private": true, "main": "main.cjs", "scripts": { "start": "electron .", - "dist:mac": "electron-builder --mac --publish never" + "dist:mac": "electron-builder --mac --publish never", + "dist:mas": "scripts/build-mas.sh", + "dist:linux": "electron-builder --linux AppImage deb tar.gz --x64 --publish never" }, "dependencies": { "@xterm/xterm": "5.5.0", @@ -21,7 +23,7 @@ }, "build": { "appId": "computer.aesthetic.easel", - "productName": "Easel", + "productName": "aesel", "directories": { "output": "dist" }, @@ -35,7 +37,8 @@ "fonts/*OFL*", "package.json", "vendor/**/*", - "assets/**/*" + "assets/**/*", + "donkey-actions/*.json" ], "extraResources": [ { @@ -74,13 +77,51 @@ ], "category": "public.app-category.developer-tools", "hardenedRuntime": true, - "icon": "build/icon.icns" + "icon": "build/icon.icns", + "notarize": true + }, + "mas": { + "target": "mas", + "provisioningProfile": "build/Easel-Mac-App-Store.provisionprofile", + "entitlements": "build/entitlements.mas.plist", + "entitlementsInherit": "build/entitlements.mas.inherit.plist", + "hardenedRuntime": false, + "artifactName": "aesel-${version}-mas-${arch}.${ext}" }, "publish": [ { "provider": "generic", - "url": "https://silo.aesthetic.computer/easel/desktop/" + "url": "https://releases.aesthetic.computer/easel/desktop/" + } + ], + "linux": { + "target": [ + "AppImage", + "deb", + "tar.gz" + ], + "category": "Graphics", + "icon": "build/icon.png", + "artifactName": "aesel-${version}-linux-${arch}.${ext}", + "executableName": "aesel", + "desktop": { + "entry": { + "Name": "aesel", + "Comment": "Make art and software with Aesthetic Computer", + "StartupWMClass": "aesel" + } } - ] - } + }, + "deb": { + "afterInstall": "scripts/after-install-linux.sh", + "compression": "gz" + }, + "afterAllArtifactBuild": "scripts/notarize-dmg.cjs" + }, + "description": "Make pieces, pictures, sounds, papers and Game Boy games with Aesthetic Computer.", + "author": { + "name": "Aesthetic Computer", + "email": "mail@aesthetic.computer" + }, + "homepage": "https://aesthetic.computer" } diff --git a/easel/desktop/preload.cjs b/easel/desktop/preload.cjs index 3d83ea4a3..bf6c86e8b 100644 --- a/easel/desktop/preload.cjs +++ b/easel/desktop/preload.cjs @@ -1,12 +1,14 @@ const { contextBridge, ipcRenderer } = require('electron'); -contextBridge.exposeInMainWorld('easel', { +contextBridge.exposeInMainWorld('aesel', { input: data => { if (typeof data === 'string') ipcRenderer.send('input', data); }, size: (cols, rows) => ipcRenderer.send('size', { cols, rows }), + titleGeometry: value => ipcRenderer.send('title-geometry',value), ready: () => ipcRenderer.send('ready'), closing: () => ipcRenderer.send('closing'), fullscreen: target => { if (target === 'app' || target === 'preview') ipcRenderer.send('fullscreen', target); }, + openLink: url => { if(typeof url==='string')ipcRenderer.send('open-link',url); }, openPiece: url => { if (typeof url === 'string') ipcRenderer.send('open-piece',url); }, onPreviewMode: fn => ipcRenderer.on('preview-mode', (_event, mode) => { if (['compact', 'hover', 'pinned'].includes(mode)) fn(mode); }), onNotice: fn => ipcRenderer.on('desktop-notice', (_event, message) => { if (typeof message === 'string') fn(message.slice(0, 2000)); }), @@ -19,6 +21,7 @@ contextBridge.exposeInMainWorld('easel', { onPaste: fn => ipcRenderer.on('paste', (_event, text) => { if (typeof text === 'string') fn(text); }), onSelectAll: fn => ipcRenderer.on('select-all', () => fn()), onOutput: fn => ipcRenderer.on('output', (_event, data) => fn(data)), + onNativeTitle: fn => ipcRenderer.on("native-title", (_event, ready) => fn(ready === true)), onState: fn => ipcRenderer.on('state', (_event, data) => fn(data)), onTextSize: fn => ipcRenderer.on('text-size', (_event, action) => { if (['larger','smaller','reset'].includes(action)) fn(action); }), onDisplay: fn => ipcRenderer.on('display', (_event, value) => { if (Number.isFinite(value?.width) && Number.isFinite(value?.height) && value.width > 0 && value.height > 0) fn(value); }), diff --git a/easel/desktop/qr-label.css b/easel/desktop/qr-label.css index 9992b3b5f..34572daaa 100644 --- a/easel/desktop/qr-label.css +++ b/easel/desktop/qr-label.css @@ -2,24 +2,25 @@ #qr-label { --qr-status-shadow: #808080; position: absolute; - top: calc(100% - 3px); - right: 0; + top: 10px; + left: 14px; + right: auto; width: max-content; max-width: none; margin: 0; - padding: 0 4px 5px; + padding: 0; font-family: "Comic Sans MS Bold", "ComicSansMS-Bold", "Chalkboard SE Bold", "ChalkboardSE-Bold", "Comic Sans MS", "Chalkboard SE", system-ui; - font-size: 16px; + font-size: calc(var(--aesel-qr-label-size,16px) * 2); font-weight: 700; font-style: normal; font-kerning: none; letter-spacing: 0; - line-height: 1.4; + line-height: 1.2; white-space: pre; color: #fff; user-select: none; transform: scale(1); - transform-origin: right top; + transform-origin: left top; transition: transform .28s cubic-bezier(.2, 1.5, .5, 1); z-index: 1; } @@ -38,14 +39,14 @@ #qr-label .qr-letter-ink { display: inline-block; color: #fff; - -webkit-text-stroke: .56px #141414; + -webkit-text-stroke: .07em #141414; paint-order: stroke fill; - text-shadow: 1.5px 1.5px 0 var(--qr-status-shadow); + text-shadow: .07em .07em 0 var(--qr-status-shadow); transform-origin: center; animation: qr-name-wiggle 1.8s ease-in-out var(--qr-delay) infinite; } -#qr-card:hover #qr-label, -#qr-card:focus-within #qr-label { transform: scale(1.5); } +#qr-label:hover, +#qr-label:focus-visible { transform: scale(1); } /* Native scan surfaces enlarge the name without retiming its letters. */ @keyframes qr-name-wiggle { 0%, 100% { transform: translateY(0) rotate(0); } @@ -55,9 +56,13 @@ @media (prefers-reduced-motion: reduce) { #qr-label { transition: none; } #qr-label .qr-letter-ink { animation: none; } - #qr-card:hover #qr-label, - #qr-card:focus-within #qr-label { transform: scale(1); } + #qr-label:hover, + #qr-label:focus-visible { transform: scale(1); } } -#qr-label .qr-version { font-size: 12px; } +#qr-label .qr-version { font-size: .75em; } #qr-label .qr-version .qr-letter-ink { -webkit-text-stroke-width: .42px; } + +body[data-phase="startup"] #qr-label{display:none} + +body[data-prox-rock="true"] #qr-label {left:14px;} diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js index 27ccddd8d..24b6273f2 100644 --- a/easel/desktop/renderer.js +++ b/easel/desktop/renderer.js @@ -1,27 +1,36 @@ async function boot() { const donkey = window.AeselDonkey.createDonkey({canvas:document.getElementById("aesel-donkey")}); window.addEventListener("beforeunload",()=>donkey.destroy(),{once:true}); -await document.fonts.load('16px "AC Easel Unifont"'); +await document.fonts.load('16px "AC aesel Unifont"'); let savedTextSize = 16; try { const size = Number(localStorage.getItem('easel-text-size')); if (size >= 8 && size <= 48) savedTextSize = size; } catch {} let bitmapFont = true; -const terminal = new Terminal({ fontFamily: '"AC Easel Unifont", Menlo, monospace', fontSize: savedTextSize, +const terminal = new Terminal({ fontFamily: '"AC aesel Unifont", Menlo, monospace', fontSize: savedTextSize, fontWeight: '400', fontWeightBold: '400', lineHeight: 1, letterSpacing: 0, cursorBlink: false, scrollback: 0, - theme: { background: '#463264', foreground: '#ffffff', cursor: '#ff64ff' }, allowProposedApi: false }); + allowTransparency:true, + theme: { background: '#00000000', foreground: '#ffffff', cursor: '#ff64ff' }, allowProposedApi: false }); const fit = new FitAddon.FitAddon(); terminal.loadAddon(fit); terminal.open(document.getElementById('terminal')); -window.easel.onTheme(theme => { - terminal.options.theme = theme; +terminal.options.linkHandler={activate:(_event,url)=>window.aesel.openLink(url)}; +const terminalLinks=window.AeselTerminalLinks.attach(terminal,{open:url=>window.aesel.openLink(url),onError:console.error}); +window.aesel.onTheme(theme => { + terminal.options.theme = {...theme,background:"#00000000"}; + window.SpriteLand.theme(theme.background); + resize(); document.getElementById('qr-label').style.setProperty('--qr-status-shadow',theme.cursor); - for (const [name,value] of Object.entries({background:theme.background,foreground:theme.foreground,accent:theme.cursor})) document.documentElement.style.setProperty('--easel-'+name,value); + for (const [name,value] of Object.entries({background:theme.background,foreground:theme.foreground,accent:theme.cursor})) document.documentElement.style.setProperty('--aesel-'+name,value); }); try { const gpu = new WebglAddon.WebglAddon(); gpu.onContextLoss(() => gpu.dispose()); terminal.loadAddon(gpu); } catch (error) { console.info('Terminal using fallback renderer:', error.message); } -let resizeFrame = 0; +let resizeFrame = 0, resizeEndTimer = null; +let sentGrid = ""; +const layoutProperties = new Map(); +function layoutProperty(key,value){if(layoutProperties.get(key)===value)return;layoutProperties.set(key,value);document.documentElement.style.setProperty(key,value);} +function fitTerminal(){const dimensions=fit.proposeDimensions();if(!dimensions)return;const {cols,rows}=dimensions;if(cols!==terminal.cols||rows!==terminal.rows)terminal.resize(cols,rows);const grid=`${cols}:${rows}`;if(grid!==sentGrid){sentGrid=grid;window.aesel.size(cols,rows);}} let publication = ""; let fullscreenState = { app: false, preview: false }; let previewAspect = 1.5, previewWidth = 192, previewHeight = 128; @@ -31,36 +40,65 @@ window.setPreviewDimensions = (width, height) => { previewWidth = width; previewHeight = height; previewAspect = width / height; sizePreviewBox(); scalePreview(); }; +function sizeQr() { + donkey.setScale(terminal.options.fontSize/8); + layoutProperty('--aesel-qr-label-size',`${terminal.options.fontSize}px`); + layoutProperty('--ambient-qr-size',`${(document.getElementById('qr').width || 82) / 2 * terminal.options.fontSize / 8}px`); +} function sizePreviewBox() { + sizeQr(); // Unifont has 16 source pixels per em; companion pixels are twice that scale. - document.documentElement.style.setProperty("--aesel-size", `${terminal.options.fontSize * 8}px`); + layoutProperty("--aesel-size", `${terminal.options.fontSize * 8}px`); const compact = Math.min(terminal.options.fontSize * 11, innerWidth * .7, innerHeight * .65 * previewAspect); - const expanded = Math.min(compact * 3, innerWidth * .7, innerHeight * .65 * previewAspect); + const modules=(document.getElementById("qr").width||82)/2; + const dpr=window.devicePixelRatio||1; + const stageHeight=112*terminal.options.fontSize/8; + const largeQr=modules*Math.max(3,Math.round(terminal.options.fontSize/4)); + const hasQr=!document.getElementById("donkey-qr-card").hidden; + const expanded=Math.min(compact*3,innerWidth*.7,(hasQr?Math.max(compact/previewAspect,innerHeight-stageHeight-largeQr-52):innerHeight*.65)*previewAspect); + layoutProperty("--preview-qr-size",`${Math.max(1,Math.floor((Math.min(compact,compact/previewAspect)-4)*dpr/modules))*modules/dpr}px`); + layoutProperty("--donkey-qr-size",`${largeQr}px`); + layoutProperty("--donkey-qr-bottom",`${stageHeight+20}px`); for (const [key,value] of Object.entries({ 'preview-width':compact+2,'preview-height':compact/previewAspect+2, 'preview-expanded-width':expanded+2,'preview-expanded-height':expanded/previewAspect+2, 'preview-logical-width':previewWidth,'preview-logical-height':previewHeight, 'preview-fullscreen-width':Math.min(innerWidth,innerHeight*previewAspect), 'preview-fullscreen-height':Math.min(innerWidth/previewAspect,innerHeight), - })) document.documentElement.style.setProperty('--'+key,value+'px'); + })) layoutProperty('--'+key,value+'px'); } -window.easel.onDisplay(display => { +window.aesel.onDisplay(display => { if (!pieceDimensions) { pieceDimensions = [Math.round(128 * display.width / display.height), 128]; if (!window.currentPreviewMedium || window.currentPreviewMedium === 'piece') window.setPreviewDimensions(...pieceDimensions); } }); -function sizeShelf(){const screen=document.querySelector('.xterm-screen')?.getBoundingClientRect();if(screen)document.documentElement.style.setProperty('--easel-shelf-height',`${innerHeight-screen.top-screen.height+(screen.height/terminal.rows)*4}px`);} +let shelfHeight=84; +function paintScene(){window.SpriteLand.layout({scale:terminal.options.fontSize/8,shelf:shelfHeight,startup:document.body.dataset.phase==='startup'});} +function sizeShelf(){const screen=document.querySelector('.xterm-screen')?.getBoundingClientRect();if(screen){shelfHeight=innerHeight-screen.top-screen.height+(screen.height/terminal.rows)*4;layoutProperty('--aesel-shelf-height',`${shelfHeight}px`);}paintScene();} +function reportTitleGeometry(){ + const label=document.getElementById('qr-label'),r=label.getBoundingClientRect(); + const size=Math.max(24,Math.min(96,terminal.options.fontSize*3)); + const visible=!label.hidden&&document.body.dataset.phase!=='startup'&&!fullscreenState.preview; + window.aesel.titleGeometry?.({x:visible?r.right+6:0,y:visible?Math.max(0,r.top+(r.height-size)/2):0,size,visible,title:label.textContent,titleX:r.x,titleY:r.y,titleWidth:r.width,titleHeight:r.height,titleFontSize:terminal.options.fontSize*2,titleColors:label.titleColors||[]}); +} +new ResizeObserver(()=>reportTitleGeometry()).observe(document.getElementById('qr-label')); function resize() { - if (resizeFrame || fullscreenState.preview) return; - resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; sizePreviewBox(); if (fullscreenState.preview) return; fit.fit(); sizeShelf(); window.easel.size(terminal.cols, terminal.rows); }); + if (resizeFrame) return; + resizeFrame = requestAnimationFrame(() => { + resizeFrame = 0; + sizePreviewBox(); + if (!fullscreenState.preview) { fitTerminal(); sizeShelf(); } + scalePreview(); + reportTitleGeometry(); + }); } -window.easel.onNotice(message => { +window.aesel.onNotice(message => { const notice = document.getElementById('desktop-notice'); notice.textContent = message; notice.hidden = !message; }); -window.easel.onTextSize(action => { +window.aesel.onTextSize(action => { clearSelectionForInput(); const maximum = Math.min(48, Math.floor((document.getElementById('terminal').clientWidth - 20) / (32 * (bitmapFont ? .5 : .61)))); const size = action === 'reset' ? (bitmapFont ? 16 : 14) : terminal.options.fontSize + (action === 'larger' ? 2 : -2); @@ -68,9 +106,9 @@ window.easel.onTextSize(action => { try { localStorage.setItem('easel-text-size', String(terminal.options.fontSize)); } catch {} resize(); terminal.focus(); }); -window.easel.onFont(bitmap => { +window.aesel.onFont(bitmap => { bitmapFont = bitmap; - terminal.options.fontFamily = bitmap ? '"AC Easel Unifont", Menlo, monospace' : 'Menlo, monospace'; + terminal.options.fontFamily = bitmap ? '"AC aesel Unifont", Menlo, monospace' : 'Menlo, monospace'; terminal.options.fontSize = bitmap ? 16 : 14; terminal.options.fontWeightBold = bitmap ? '400' : 'bold'; resize(); @@ -83,7 +121,7 @@ let draggingSelection = false, pendingOutput = '', pastedInput = false; let clickOrigin = null; let lastHoverCell = '', hoverFrame = 0, pendingHover = null; terminal.parser.registerOscHandler(777, data => { - if(data.startsWith('easel-phase:')) { document.body.dataset.phase=data.slice(12); if(data.slice(12)==='closing') window.easel.closing(); else resize(); return true; } + if(data.startsWith('easel-phase:')) { document.body.dataset.phase=data.slice(12); if(data.slice(12)==='closing') window.aesel.closing(); else resize(); return true; } if (!data.startsWith('easel-pointer:')) return false; const action=data.slice('easel-pointer:'.length); terminalElement.dataset.pointer = (['about','profile'].includes(action)||action.startsWith('settings:')||action.startsWith('choice:')) ? 'link' : ''; @@ -93,7 +131,7 @@ function sendHover(x,y) { const cell=`${x};${y}`; if(cell===lastHoverCell)return; lastHoverCell=cell; - window.easel.input(`\x1b[<35;${cell}M`); + window.aesel.input(`\x1b[<35;${cell}M`); } terminalElement.addEventListener('pointermove', event => { if(event.buttons || draggingSelection || terminal.hasSelection())return; @@ -124,7 +162,7 @@ function clearSelectionForInput() { terminal.clearSelection(); flushOutput(); } -window.easel.onOutput(data => { +window.aesel.onOutput(data => { if (typeof data !== 'string') return; if (draggingSelection || terminal.hasSelection()) { pendingOutput += data; @@ -143,12 +181,12 @@ function finishSelection() { if (!terminal.hasSelection()) flushOutput(); } window.addEventListener('pointerup', event => { - if (event.button === 0 && clickOrigin && !terminal.hasSelection() && + if (event.button === 0 && clickOrigin && !terminal.hasSelection() && !terminalLinks.isLinkAtClientPoint(event.clientX,event.clientY) && Math.hypot(event.clientX-clickOrigin.x,event.clientY-clickOrigin.y) < 4) { const rect = terminalElement.querySelector('.xterm-screen').getBoundingClientRect(); const x = Math.floor((event.clientX-rect.left) / (rect.width/terminal.cols))+1; const y = Math.floor((event.clientY-rect.top) / (rect.height/terminal.rows))+1; - if (x>0 && x<=terminal.cols && y>0 && y<=terminal.rows) window.easel.input(`\x1b[<0;${x};${y}M`); + if (x>0 && x<=terminal.cols && y>0 && y<=terminal.rows) window.aesel.input(`\x1b[<0;${x};${y}M`); } clickOrigin = null; finishSelection(); @@ -157,10 +195,10 @@ window.addEventListener('pointercancel', finishSelection); window.addEventListener('blur', finishSelection); terminalElement.addEventListener('contextmenu', event => { event.preventDefault(); - window.easel.contextMenu(terminal.getSelection()); + window.aesel.contextMenu(terminal.getSelection()); }); -window.easel.onSelectAll(() => terminal.selectAll()); -window.easel.onPaste(text => { +window.aesel.onSelectAll(() => terminal.selectAll()); +window.aesel.onPaste(text => { if (!text) return; clearSelectionForInput(); terminal.focus(); @@ -173,12 +211,12 @@ terminal.attachCustomKeyEventHandler(event => { const key = event.key.toLowerCase(); if (command && key === 'c' && terminal.hasSelection()) { event.preventDefault(); - if (event.type === 'keydown') window.easel.copyText(terminal.getSelection()); + if (event.type === 'keydown') window.aesel.copyText(terminal.getSelection()); return false; } if (command && key === 'v') { event.preventDefault(); - if (event.type === 'keydown') window.easel.requestPaste(); + if (event.type === 'keydown') window.aesel.requestPaste(); return false; } if (event.metaKey && key === 'a') { @@ -197,12 +235,12 @@ terminalElement.addEventListener('wheel', event => { wheelDistance += event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 240 : 1); if (Math.abs(wheelDistance) < 80) return; clearSelectionForInput(); - window.easel.input(wheelDistance < 0 ? '\x1b[5~' : '\x1b[6~'); + window.aesel.input(wheelDistance < 0 ? '\x1b[5~' : '\x1b[6~'); wheelDistance = 0; }, { passive: false }); terminal.onData(data => { if (!pastedInput) clearSelectionForInput(); - window.easel.input(data); + window.aesel.input(data); }); new ResizeObserver(resize).observe(document.getElementById('terminal')); const preview = document.getElementById('piece'); @@ -220,14 +258,19 @@ function scalePreview() { previewViewport.style.transform = `translate(${left}px, ${top}px) scale(${scale})`; } new ResizeObserver(scalePreview).observe(artifact); -window.addEventListener('resize', () => { sizePreviewBox(); scalePreview(); }); -window.easel.onPreviewMode(mode => { +window.addEventListener('resize', () => { + document.body.classList.add('window-resizing'); + clearTimeout(resizeEndTimer); + resizeEndTimer=setTimeout(()=>document.body.classList.remove('window-resizing'),150); + resize(); +}); +window.aesel.onPreviewMode(mode => { document.body.dataset.previewMode = mode; scalePreview(); }); sizePreviewBox(); scalePreview(); let focusBeforePreview = null; -window.easel.onFullscreenState(state => { +window.aesel.onFullscreenState(state => { const entering = state.preview && !fullscreenState.preview; const leaving = !state.preview && fullscreenState.preview; if (entering) focusBeforePreview = document.activeElement; @@ -245,13 +288,23 @@ window.addEventListener('keydown', event => { if (event.key !== 'Escape' || !fullscreenState.preview) return; event.preventDefault(); event.stopImmediatePropagation(); - window.easel.fullscreen('preview'); + window.aesel.fullscreen('preview'); }, true); -document.getElementById('qr-card').addEventListener('click',()=>{ if(shareUrl)window.easel.openPiece(shareUrl); }); +const titleLabel=document.getElementById('qr-label');titleLabel.tabIndex=0; +const showQr=show=>{document.body.dataset.qrPreview=String(show&&!document.getElementById('qr-card').hidden);}; +titleLabel.addEventListener('pointerenter',()=>showQr(true));titleLabel.addEventListener('pointerleave',()=>showQr(false)); +titleLabel.addEventListener('focus',()=>showQr(true));titleLabel.addEventListener('blur',()=>showQr(false)); +window.addEventListener('blur',()=>showQr(false)); +window.aesel.onNativeTitle?.(ready=>{document.body.dataset.nativeTitle=String(ready);}); +for(const id of ['qr-card','donkey-qr-card'])document.getElementById(id).addEventListener('click',()=>{if(shareUrl)window.aesel.openPiece(shareUrl);}); +const hoverPreview=document.getElementById('artifact-shell'); +hoverPreview.addEventListener('pointerenter',()=>{document.body.dataset.previewQr='true';}); +hoverPreview.addEventListener('pointerleave',()=>{document.body.dataset.previewQr='false';}); let shareUrl = ''; let url = '', version = 0, qrFingerprint = ''; -window.easel.onState(state => { +window.aesel.onState(state => { donkey.update(state); + resize(); shareUrl = state.url || ''; document.getElementById('artifact-shell').hidden = !state.url && !state.preview; if (state.medium !== 'piece' && url) { preview.src = 'about:blank'; url = ''; } @@ -260,9 +313,17 @@ window.easel.onState(state => { window.renderMediaPreview(state).catch(error => console.error('Preview failed:', error)); const qr = document.getElementById('qr'); document.getElementById('qr-card').hidden = !state.qr || !shareUrl; + document.getElementById('donkey-qr-card').hidden = !state.qr || !shareUrl; const draftId = state.medium !== 'piece' ? /[?&]id=([a-f0-9]{32})/.exec(state.url || '')?.[1] : null; const scanName = state.preview?.publicCode ? '#' + state.preview.publicCode : draftId ? '#~' + draftId.slice(0,12) : (state.piece || 'easel').replace(/\.(mjs|lisp|lua)$/, ''); - window.updateQrLabel(scanName + (state.version ? ` v${state.version}` : ''), state.status || 'ready'); + const displayName=(state.piece||scanName).split('/').at(-1).replace(/\.(mjs|lisp|lua)$/,''); + const handle=String(state.handle||'').replace(/^@/,''); + window.updateQrLabel(handle?`@${handle}/${displayName}`:displayName, state.status || 'ready'); + const label=document.getElementById('qr-label'); + label.titleColors=Array.from(label.textContent).map((_,i)=>handle&&i<=handle.length?state.handleColors?.[i]||[255,255,255]:[255,255,255]); + label.querySelectorAll('.qr-letter-ink').forEach((ink,i)=>{ink.style.color=`rgb(${label.titleColors[i]||[255,255,255]})`;}); + document.body.dataset.proxRock=String(!!state.proxName); + document.getElementById('qr-label').hidden=!state.url&&!state.preview; if (!document.getElementById('qr-card').hidden && JSON.stringify(state.qr) !== qrFingerprint) { qrFingerprint = JSON.stringify(state.qr); const scale = 2, quiet = 4, span = state.qr.length + quiet * 2; @@ -271,9 +332,11 @@ window.easel.onState(state => { ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, qr.width, qr.height); ctx.fillStyle = '#000'; state.qr.forEach((row, y) => row.forEach((dark, x) => { if (dark) ctx.fillRect((x + quiet) * scale, (y + quiet) * scale, scale, scale); })); + const companionQr=document.getElementById('donkey-qr');companionQr.width=qr.width;companionQr.height=qr.height;companionQr.getContext('2d').drawImage(qr,0,0); qr.title = state.url; + sizeQr(); } - document.title = state.piece ? `${state.piece} · Easel` : 'Easel'; + document.title = state.piece ? `${state.piece} · aesel` : 'aesel'; if(state.medium==='piece' && state.url===url && state.publication && state.publication!==publication) { const target = new URL(preview.src); target.searchParams.set('nolabel','true');target.searchParams.set('nogap','true');target.searchParams.set('autoreload','true'); @@ -289,10 +352,9 @@ window.easel.onState(state => { }); preview.addEventListener('did-finish-load', () => { artifact.classList.remove('refresh'); requestAnimationFrame(() => artifact.classList.add('refresh')); }); sizePreviewBox(); -fit.fit(); +fitTerminal(); sizeShelf(); -window.easel.size(terminal.cols, terminal.rows); -window.easel.ready(); +window.aesel.ready(); terminal.focus(); } boot().catch(error => { document.getElementById('terminal').textContent = `Could not start terminal: ${error.message}`; }); diff --git a/easel/desktop/scripts/after-install-linux.sh b/easel/desktop/scripts/after-install-linux.sh new file mode 100644 index 000000000..658656fbb --- /dev/null +++ b/easel/desktop/scripts/after-install-linux.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e +# Root can create user namespaces on Ubuntu even when desktop users cannot. +# Install the Chromium helper with its standard root-owned sandbox permissions. +chown root:root '/opt/${sanitizedProductName}/chrome-sandbox' +chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' +if command -v update-alternatives >/dev/null 2>&1; then + update-alternatives --install '/usr/bin/${executable}' '${executable}' '/opt/${sanitizedProductName}/${executable}' 100 +else + ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +fi +command -v update-mime-database >/dev/null 2>&1 && update-mime-database /usr/share/mime || true +command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database /usr/share/applications || true diff --git a/easel/desktop/scripts/build-mas.sh b/easel/desktop/scripts/build-mas.sh new file mode 100755 index 000000000..2409ce6f0 --- /dev/null +++ b/easel/desktop/scripts/build-mas.sh @@ -0,0 +1,19 @@ +#!/bin/zsh +set -euo pipefail +cd "${0:A:h}/.." +identity="${EASEL_MAS_IDENTITY:-Jeffrey Scudder (FB5948YR3S)}" +profile="${EASEL_MAS_PROFILE:-build/Easel-Mac-App-Store.provisionprofile}" +[[ -f "$profile" ]] || { print -u2 "missing provisioning profile: $profile"; exit 1; } +# Match both application and installer identities by owner, not one cert hash. +# Skip the preliminary Darwin signature; the MAS pass signs with its profile. +env -u ELECTRON_RUN_AS_NODE ./node_modules/.bin/electron-builder \ + --mac mas --arm64 --publish never \ + -c.buildVersion="${EASEL_MAS_BUILD:-0.7.3}" \ + -c.mac.identity=null \ + -c.mas.identity="$identity" \ + -c.mas.provisioningProfile="$profile" +app="dist/mas-arm64/aesel.app" +pkg="dist/mas-arm64/aesel-$(node -p "require('./package.json').version")-mas-arm64.pkg" +codesign --verify --deep --strict "$app" +pkgutil --check-signature "$pkg" +print "Verified $pkg" diff --git a/easel/desktop/scripts/linux-download.html b/easel/desktop/scripts/linux-download.html new file mode 100644 index 000000000..825b7f13a --- /dev/null +++ b/easel/desktop/scripts/linux-download.html @@ -0,0 +1,20 @@ + +aesel downloads + +

aesel downloads

+

Make pieces, pictures, sounds, papers and Game Boy games with Aesthetic Computer.

+

Download for Ubuntu / Debian

+

Version 0.7.0 · Intel / AMD 64-bit · Early release

+
cd ~/Downloads
+sudo apt install ./aesel-0.7.0-linux-amd64.deb
+

Open aesel from your applications menu, or run easel. Sign in with your Aesthetic Computer account.

+

Other distributions: AppImage · Portable archive

+
chmod +x aesel-0.7.0-linux-x86_64.AppImage
+./aesel-0.7.0-linux-x86_64.AppImage
+

macOS

+

Download for Apple silicon

+

ZIP archive · signed and notarized · macOS 12+

+

If FUSE is unavailable, try --appimage-extract-and-run. On Ubuntu, use the .deb above to install the required sandbox. Do not disable the sandbox.

+

aesel requires sharing conversations and artifact revision references with authorized Aesthetic Computer staff for product improvement, including when using your own provider. Read and accept the disclosure before use. Hosted generation requires account access; bringing your own Claude or Codex requires its CLI and account.

+

Tested on Ubuntu 24.04 x86-64. ARM builds are not included. SHA-256 checksums · Aesthetic Computer

+ diff --git a/easel/desktop/scripts/notarize-dmg.cjs b/easel/desktop/scripts/notarize-dmg.cjs new file mode 100644 index 000000000..3e628a2d8 --- /dev/null +++ b/easel/desktop/scripts/notarize-dmg.cjs @@ -0,0 +1,62 @@ +// @ts-check +const { execFileSync } = require("child_process"); +const path = require("path"); + +// electron-builder's built-in notarize handles the .app bundle but not the +// DMG wrapper: the DMG ships unsigned, so `spctl -t install` reports +// "no usable signature" even after stapling a notarization ticket. This +// afterAllArtifactBuild hook signs each DMG with the same Developer ID, +// submits it to notarytool, and staples the ticket so fresh downloads pass +// Gatekeeper without a warning. +exports.default = async function notarizeDmg(context) { + if (process.platform !== "darwin") return; + if (process.env.SKIP_NOTARIZE) { + console.log("Skipping DMG notarization - SKIP_NOTARIZE set"); + return; + } + + const appleId = process.env.APPLE_ID; + const password = + process.env.APPLE_APP_SPECIFIC_PASSWORD || process.env.APPLE_NOTARIZE_PWD; + const teamId = process.env.APPLE_TEAM_ID; + const signIdentity = + (process.env.CSC_NAME?.startsWith("Developer ID Application:") ? process.env.CSC_NAME : process.env.CSC_NAME ? `Developer ID Application: ${process.env.CSC_NAME}` : null) || + `Developer ID Application: Jeffrey Scudder (${teamId || ""})`; + + if (!appleId || !password || !teamId) { + console.log("Skipping DMG notarization - missing credentials"); + return; + } + + const dmgs = (context.artifactPaths || []).filter((p) => p.endsWith(".dmg")); + if (dmgs.length === 0) return; + + for (const dmg of dmgs) { + const name = path.basename(dmg); + console.log(` • signing DMG ${name}`); + execFileSync( + "codesign", + ["--sign", signIdentity, "--timestamp", "--force", dmg], + { stdio: "inherit" } + ); + console.log(` • notarizing DMG ${name}`); + execFileSync( + "xcrun", + [ + "notarytool", + "submit", + dmg, + "--apple-id", + appleId, + "--password", + password, + "--team-id", + teamId, + "--wait", + ], + { stdio: "inherit" } + ); + console.log(` • stapling DMG ${name}`); + execFileSync("xcrun", ["stapler", "staple", dmg], { stdio: "inherit" }); + } +}; diff --git a/easel/desktop/scripts/publish-linux.cjs b/easel/desktop/scripts/publish-linux.cjs new file mode 100644 index 000000000..50831e399 --- /dev/null +++ b/easel/desktop/scripts/publish-linux.cjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Run with @aws-sdk/client-s3 available and SPACES_KEY / SPACES_SECRET set. +// aesel has its own prefix: never register this with the AC Desktop release API. +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const {S3Client, PutObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, + CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectAclCommand} = require('@aws-sdk/client-s3'); +const dir = path.resolve(process.argv[2] || 'dist'); +const names = fs.readdirSync(dir).filter(n => /^aesel-[\d.]+-linux-(x64|x86_64|amd64)\.(AppImage|deb|tar\.gz)$/.test(n)); +if (names.length !== 3 || !fs.existsSync(path.join(dir,'latest-linux.yml'))) throw Error('Expected three Linux artifacts and update manifest'); +const manifest=fs.readFileSync(path.join(dir,'latest-linux.yml'),'utf8'); +const appImage=names.find(n=>n.endsWith('.AppImage')); +const digest=crypto.createHash('sha512').update(fs.readFileSync(path.join(dir,appImage))).digest('base64'); +if(!manifest.includes('path: '+appImage) || !manifest.includes('sha512: '+digest)) throw Error('AppImage update manifest does not match binary'); +if(!fs.existsSync(path.join(dir,'index.html'))) throw Error('Missing download page'); +const sums = names.map(n => crypto.createHash('sha256').update(fs.readFileSync(path.join(dir,n))).digest('hex')+' '+n).join('\n')+'\n'; +fs.writeFileSync(path.join(dir,'SHA256SUMS'),sums); +if (!process.env.SPACES_KEY || !process.env.SPACES_SECRET) throw Error('Missing Spaces credentials'); +const client = new S3Client({endpoint:process.env.SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com',region:'us-east-1',credentials:{accessKeyId:process.env.SPACES_KEY,secretAccessKey:process.env.SPACES_SECRET},requestChecksumCalculation:'WHEN_REQUIRED',responseChecksumValidation:'WHEN_REQUIRED'}); +const Bucket='releases-aesthetic-computer'; +async function upload(name) { + const file=path.join(dir,name),size=fs.statSync(file).size; + const Key='easel/desktop/'+name; + const metadata={Bucket,Key,ContentType:name.endsWith('.html')?'text/html; charset=utf-8':name.endsWith('.yml')?'text/yaml':'application/octet-stream',CacheControl:name.startsWith('aesel-')?'public, max-age=31536000, immutable':'no-cache'}; + if(size<8*1024*1024) await client.send(new PutObjectCommand({...metadata,Body:fs.readFileSync(file),ACL:'public-read'})); + else { + const {UploadId}=await client.send(new CreateMultipartUploadCommand(metadata)); + const handle=fs.openSync(file,'r'); + try { + const Parts=[]; + for(let offset=0;offset{});throw error;} + finally{fs.closeSync(handle);} + } + console.log('Published '+name); +} +(async()=>{for(const name of [...names,'SHA256SUMS','latest-linux.yml','index.html'])await upload(name);})().catch(error=>{console.error(error.message);process.exitCode=1;}); diff --git a/easel/desktop/scripts/publish-mac.cjs b/easel/desktop/scripts/publish-mac.cjs new file mode 100644 index 000000000..7b23e4ca4 --- /dev/null +++ b/easel/desktop/scripts/publish-mac.cjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Publish notarized Mac artifacts first, then atomically expose the update feed. +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); +const dir = path.resolve(process.argv[2] || 'dist'); +const version = require('../package.json').version; +const zip = `aesel-${version}-arm64-mac.zip`; +const dmg = `aesel-${version}-arm64.dmg`; +const digest = (name, algorithm, encoding) => crypto.createHash(algorithm).update(fs.readFileSync(path.join(dir, name))).digest(encoding); +async function main() { + if (!process.env.SPACES_KEY || !process.env.SPACES_SECRET) throw Error('Missing Spaces credentials'); + for (const name of [zip, dmg, zip + '.blockmap']) fs.accessSync(path.join(dir, name)); + execFileSync('xcrun', ['stapler', 'validate', path.join(dir, dmg)], { stdio: 'inherit' }); + execFileSync('spctl', ['-a', '-t', 'open', '--context', 'context:primary-signature', path.join(dir, dmg)], { stdio: 'inherit' }); + const zipHash = digest(zip, 'sha512', 'base64'); + // Signing/stapling changes the DMG bytes after electron-builder writes its feed. + const manifest = `version: ${version}\nfiles:\n` + [zip, dmg].map(name => ` - url: ${name}\n sha512: ${digest(name, 'sha512', 'base64')}\n size: ${fs.statSync(path.join(dir, name)).size}\n`).join('') + `path: ${zip}\nsha512: ${zipHash}\nreleaseDate: '${new Date().toISOString()}'\n`; + fs.writeFileSync(path.join(dir, 'latest-mac.yml'), manifest); + const existing = await fetch('https://releases.aesthetic.computer/easel/desktop/SHA256SUMS', { cache: 'no-store' }); + if (!existing.ok) throw Error('Cannot preserve existing release checksums'); + const lines = (await existing.text()).trim().split('\n').filter(line => !line.endsWith(' ' + zip) && !line.endsWith(' ' + dmg)); + lines.push(...[zip, dmg].map(name => `${digest(name, 'sha256', 'hex')} ${name}`)); + fs.writeFileSync(path.join(dir, 'SHA256SUMS'), lines.join('\n') + '\n'); + const client = new S3Client({ endpoint: process.env.SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com', region: 'us-east-1', credentials: { accessKeyId: process.env.SPACES_KEY, secretAccessKey: process.env.SPACES_SECRET }, requestChecksumCalculation: 'WHEN_REQUIRED', responseChecksumValidation: 'WHEN_REQUIRED' }); + for (const name of [zip, zip + '.blockmap', dmg, 'SHA256SUMS', 'latest-mac.yml']) { + await client.send(new PutObjectCommand({ Bucket: 'releases-aesthetic-computer', Key: 'easel/desktop/' + name, Body: fs.readFileSync(path.join(dir, name)), ACL: 'public-read', ContentType: name.endsWith('.yml') ? 'text/yaml' : 'application/octet-stream', CacheControl: name.startsWith('aesel-') ? 'public, max-age=31536000, immutable' : 'no-cache' })); + console.log('Published ' + name); + } +} +main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/easel/desktop/spriteland.js b/easel/desktop/spriteland.js new file mode 100644 index 000000000..a42ff28c3 --- /dev/null +++ b/easel/desktop/spriteland.js @@ -0,0 +1,46 @@ +// Static tile layers. Repaint only for layout/theme changes, never per token. +(()=>{ + const background=document.getElementById('scene-background'),foreground=document.getElementById('scene-foreground'); + const make=(w,h)=>{const c=document.createElement('canvas');c.width=w;c.height=h;return c;}; + function woodTile(){ + const c=make(384,96),g=c.getContext('2d'),palette=['#453126','#50382a','#59402e','#4b3528']; + const hash=(x,y)=>((x*374761393+y*668265263)>>>0)%997; + for(let board=0;board<3;board++){ + const top=board*32;g.fillStyle=palette[board];g.fillRect(0,top,384,32); + // Broad, stepped ribbons: all edges align to whole sprite pixels. + for(let row=0;row<7;row++){ + let y=top+3+row*4;g.fillStyle=['#2f241a55','#ac7e4528','#1f181638'][row%3]; + for(let x=0;x<384;x+=4){const wave=Math.round(Math.sin((x+board*61+row*19)/42)*2)*2;g.fillRect(x,y+wave,4,row%3===0?2:1);} + } + for(let knot=0;knot<3;knot++){ + const x=42+knot*128+(hash(board,knot)%25),y=top+14+(hash(knot,board)%7); + for(let r=12;r>0;r-=3){g.fillStyle=r%2?'#35271f':'#795437'; + for(let dy=-Math.floor(r/3);dy<=Math.floor(r/3);dy++){const w=Math.max(2,r-Math.abs(dy)*2);g.fillRect(x-w,y+dy,w*2,1);}} + } + g.fillStyle='#241e18';g.fillRect(0,top+31,384,1); + for(let x=64+board*80;x<384;x+=192){g.fillStyle='#2a201c';g.fillRect(x,top,1,32);g.fillStyle='#b38b4930';g.fillRect(x+1,top+1,1,30);} + } + return c; + } + const wood=woodTile();document.documentElement.style.setProperty('--aesel-wood-tile',`url(${wood.toDataURL()})`);let theme='#463264',last=''; + function tile(){const c=make(32,32),g=c.getContext('2d');g.fillStyle=theme;g.fillRect(0,0,32,32); + g.fillStyle='#ffffff04';for(let y=0;y<32;y+=4)g.fillRect(0,y,32,1); + g.fillStyle='#0000000b';for(let x=0;x<32;x+=4)g.fillRect(x,0,1,32); + g.fillStyle='#ffffff06';g.fillRect(0,0,1,1);g.fillRect(16,16,1,1);return c; + } + let cloth=tile(); + window.SpriteLand={ + theme(color){if(!color||color===theme)return;theme=color;cloth=tile();last='';}, + layout({scale=2,shelf=84,startup=false}={}){ + const width=Math.ceil(innerWidth/scale),height=Math.ceil(innerHeight/scale),rail=Math.ceil(shelf/scale); + const key=[width,height,rail,scale,startup,theme].join(':');if(last===key)return;last=key; + for(const canvas of [background,foreground]){canvas.width=width;canvas.height=height;canvas.style.width=width*scale+'px';canvas.style.height=height*scale+'px';} + const bg=background.getContext('2d');bg.imageSmoothingEnabled=false;bg.fillStyle=bg.createPattern(cloth,'repeat');bg.fillRect(0,0,width,height); + const fg=foreground.getContext('2d');fg.imageSmoothingEnabled=false;fg.clearRect(0,0,width,height);if(startup)return; + const top=height-rail;fg.fillStyle=fg.createPattern(wood,'repeat');fg.fillRect(0,top,width,rail); + for(const [dy,color] of [[0,'#211c18'],[1,'#6c5039'],[2,'#453126'],[rail-3,'#382319'],[rail-2,'#24180f']]){fg.fillStyle=color;fg.fillRect(0,top+dy,width,1);} + // Inlaid pins, kept outside the text-bearing center of the rail. + for(const x of [3,width-5]){fg.fillStyle='#211810';fg.fillRect(x,top+5,2,2);fg.fillStyle='#9b7148';fg.fillRect(x,top+5,1,1);} + } + }; +})(); diff --git a/easel/desktop/style.css b/easel/desktop/style.css index 93e231a77..f91aeec5d 100644 --- a/easel/desktop/style.css +++ b/easel/desktop/style.css @@ -1,31 +1,31 @@ -@font-face{font-family:"AC Easel Unifont";src:url("fonts/ac-easel-unifont.woff") format("woff");font-style:normal;font-weight:400;font-display:swap} -html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#463264;color:white;font-family:"AC Easel Unifont",monospace} +@font-face{font-family:"AC aesel Unifont";src:url("fonts/ac-easel-unifont.woff") format("woff");font-style:normal;font-weight:400;font-display:swap} +html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#463264;color:white;font-family:"AC aesel Unifont",monospace} #terminal{height:calc(100% - 20px);padding:10px} -#artifact-shell{position:absolute;top:14px;left:14px;width:var(--preview-width,178px);height:var(--preview-height,119.33px);transition:width .18s,height .18s;z-index:2} +#artifact-shell{position:absolute;top:14px;right:14px;width:var(--preview-width,178px);height:var(--preview-height,119.33px);transition:width .18s,height .18s;z-index:2} #artifact{position:relative;width:100%;height:100%;box-sizing:border-box;overflow:hidden;background:#1c1728;border:1px solid #ffffff55;border-radius:0;box-shadow:2px 2px #0006;transition:width .18s,height .18s;z-index:2} body[data-preview-mode="hover"] #artifact-shell:hover,body[data-preview-mode="hover"] #artifact-shell:focus-within,body[data-preview-mode="pinned"] #artifact-shell{width:var(--preview-expanded-width,434px);height:var(--preview-expanded-height,290px)} #preview-viewport{position:absolute;left:0;top:0;width:var(--preview-logical-width,192px);height:var(--preview-logical-height,128px);transform-origin:0 0;image-rendering:pixelated;overflow:hidden} #piece{display:flex;width:var(--preview-logical-width,192px);height:var(--preview-logical-height,128px);image-rendering:pixelated} -#version{position:absolute;right:0;top:calc(100% + 4px);color:#fff;text-shadow:1px 1px #000;font:12px "AC Easel Unifont",monospace;pointer-events:none;white-space:nowrap} +#version{position:absolute;right:0;top:calc(100% + 4px);color:#fff;text-shadow:1px 1px #000;font:calc(var(--aesel-qr-label-size,16px) * .75) "AC aesel Unifont",monospace;pointer-events:none;white-space:nowrap} #version:empty{display:none} #artifact.refresh{animation:refresh .35s ease-out} @keyframes refresh{20%{transform:translateX(-2px);border-color:white}50%{transform:translateX(2px)}80%{transform:translateX(-1px)}} @media(prefers-reduced-motion:reduce){#artifact{transition:none}#artifact.refresh{animation:none;border-color:white}} -#qr-card{position:absolute;top:14px;right:14px;z-index:2;text-align:center} +#qr-card{position:absolute;bottom:calc(max(var(--aesel-size,128px),var(--aesel-shelf-height,84px)) + 24px);right:14px;z-index:2;text-align:center} #qr{display:block;margin:auto;image-rendering:pixelated;box-shadow:none} -.fullscreen-control{appearance:none;border:1px solid #ffffff66;border-radius:4px;background:#1c1728e8;color:white;padding:4px 7px;font:12px "AC Easel Unifont",Menlo,monospace;cursor:pointer;line-height:16px;z-index:4} +.fullscreen-control{appearance:none;border:1px solid #ffffff66;border-radius:4px;background:#1c1728e8;color:white;padding:4px 7px;font:calc(var(--aesel-qr-label-size,16px) * .75) "AC aesel Unifont",Menlo,monospace;cursor:pointer;line-height:16px;z-index:4} .fullscreen-control:hover{background:#463264} .fullscreen-control:focus-visible{outline:2px solid white;outline-offset:2px} #preview-fullscreen{position:absolute;top:5px;right:5px;width:24px;height:24px;padding:2px;font-size:16px} -body.preview-fullscreen #terminal,body.preview-fullscreen #qr-card,body.preview-fullscreen #version{display:none} +body.preview-fullscreen #terminal,body.preview-fullscreen #qr-card,body.preview-fullscreen #qr-label,body.preview-fullscreen #version{display:none} body.preview-fullscreen #artifact-shell,body.preview-fullscreen[data-preview-mode] #artifact-shell:hover,body.preview-fullscreen[data-preview-mode] #artifact-shell:focus-within{position:fixed;inset:auto;left:50%;top:50%;transform:translate(-50%,-50%);width:var(--preview-fullscreen-width);height:var(--preview-fullscreen-height);z-index:10;transition:none} body.preview-fullscreen #artifact{border:0;border-radius:0;box-shadow:none;animation:none;transition:none} body.preview-fullscreen #preview-fullscreen{top:12px;right:12px} @media(prefers-reduced-motion:reduce){#artifact-shell{transition:none}} -#desktop-notice{position:fixed;left:12px;bottom:12px;max-width:calc(100% - 40px);padding:6px 8px;background:#1c1728f2;border:1px solid #ffffff66;border-radius:4px;font:14px "AC Easel Unifont",Menlo,monospace;overflow-wrap:anywhere;pointer-events:none;z-index:20} +#desktop-notice{position:fixed;left:12px;bottom:12px;max-width:calc(100% - 40px);padding:6px 8px;background:#1c1728f2;border:1px solid #ffffff66;border-radius:4px;font:14px "AC aesel Unifont",Menlo,monospace;overflow-wrap:anywhere;pointer-events:none;z-index:20} #piece[hidden],#media-preview[hidden]{display:none!important} #media-preview{position:absolute;inset:0;overflow:hidden;color:white;font:10px monospace;text-align:center} @@ -34,16 +34,24 @@ body.preview-fullscreen #preview-fullscreen{top:12px;right:12px} #media-preview button{font:10px monospace} #media-preview pre{margin:4px;text-align:left;white-space:pre-wrap;font:6px monospace} -html,body{background:var(--easel-background,#463264);color:var(--easel-foreground,#fff)} -.fullscreen-control,#desktop-notice{background:var(--easel-background,#1c1728);color:var(--easel-foreground,#fff);border-color:var(--easel-foreground,#ffffff66)} -#version{color:var(--easel-foreground,#fff);text-shadow:none} +html,body{background:var(--aesel-background,#463264);color:var(--aesel-foreground,#fff)} +.fullscreen-control,#desktop-notice{background:var(--aesel-background,#1c1728);color:var(--aesel-foreground,#fff);border-color:var(--aesel-foreground,#ffffff66)} +#version{color:var(--aesel-foreground,#fff);text-shadow:none} -.fullscreen-control:hover{background:var(--easel-background,#463264);border-color:var(--easel-accent,#fff)} +.fullscreen-control:hover{background:var(--aesel-background,#463264);border-color:var(--aesel-accent,#fff)} #terminal[data-pointer="link"] .xterm, #terminal[data-pointer="link"] .xterm-screen{cursor:pointer} -#qr-card{appearance:none;padding:5px;border:1px solid transparent;border-radius:3px;background:var(--easel-background,#463264);color:inherit;font:inherit;cursor:pointer;box-shadow:inset 1px 1px #ffffff40,inset -1px -1px #0005,1px 2px 2px #0004} +#qr-card{appearance:none;padding:5px;border:1px solid transparent;border-radius:3px;background:var(--aesel-background,#463264);color:inherit;font:inherit;cursor:pointer;box-shadow:inset 1px 1px #ffffff40,inset -1px -1px #0005,1px 2px 2px #0004} #qr-card:hover{filter:brightness(1.06)} #qr-card:active{transform:translate(1px,1px);box-shadow:inset 1px 1px #0005,inset -1px -1px #ffffff30} -#qr-card:focus-visible{outline:2px solid var(--easel-accent,#ff64ff);outline-offset:3px} +#qr-card:focus-visible{outline:2px solid var(--aesel-accent,#ff64ff);outline-offset:3px} #qr-label{display:block} + +.window-resizing #artifact-shell,.window-resizing #artifact{transition:none!important;animation:none!important} + +/* Keep the artifact beside the real Slab rock, not beneath its hit region. */ +body:not(.preview-fullscreen)[data-prox-rock="true"] #artifact-shell{right:14px} + +#artifact-shell{padding:4px;background-image:var(--aesel-wood-tile);border:1px solid #9e7548;border-right-color:#392519;border-bottom-color:#392519;box-shadow:3px 3px #0005;image-rendering:pixelated} +body.preview-fullscreen #artifact-shell{padding:0;border:0;box-shadow:none;background:none} diff --git a/easel/desktop/terminal-links.js b/easel/desktop/terminal-links.js new file mode 100644 index 000000000..3389b35a0 --- /dev/null +++ b/easel/desktop/terminal-links.js @@ -0,0 +1,73 @@ +/* Uses xterm's public cell/link APIs; no DOM extraction or terminal escape parsing. */ +(function(root){ + function webURL(value){ + if(typeof value!=='string'||value.length>4096||/[\u0000-\u0020\u007f]/.test(value))return null; + try{const url=new URL(value);return ['http:','https:'].includes(url.protocol)&&url.hostname&&!url.username&&!url.password?url.href:null;}catch{return null;} + } + function trimURL(value){ + let result=value.replace(/[.,;:!?]+$/u,''); + for(const [close,open] of [[')','('],[']','['],['}','{']]){ + while(result.endsWith(close)&&result.split(close).length>result.split(open).length)result=result.slice(0,-1); + } + return result.replace(/[.,;:!?]+$/u,''); + } + function linksForLine(terminal,lineNumber){ + const buffer=terminal.buffer.active, row=lineNumber-1; + if(!Number.isInteger(row)||row<0||!buffer.getLine(row))return []; + let first=row,last=row; + // Actual xterm wraps only. Never join independently painted TUI rows. + while(first>0&&row-first<16&&buffer.getLine(first)?.isWrapped)first--; + while(last-first<16&&buffer.getLine(last+1)?.isWrapped)last++; + let text='',cells=[]; + for(let y=first;y<=last;y++){ + const line=buffer.getLine(y); + for(let x=0;x"'`\u0000-\u001f\u007f]+/gu)){ + const label=trimURL(match[0]),url=webURL(label); + if(!url)continue; + const start=cells[match.index],end=cells[match.index+label.length-1]; + if(!start||!end||lineNumberend.y)continue; + links.push({text:label,url,range:{start:{x:start.x,y:start.y},end:{x:end.endX,y:end.y}}}); + } + return links; + } + function attach(terminal,{open,onError=()=>{}}={}){ + if(typeof open!=='function')throw new Error('Terminal links require a validated host open callback'); + const registration=terminal.registerLinkProvider({provideLinks(y,callback){ + callback(linksForLine(terminal,y).map(link=>({text:link.text,range:link.range, + decorations:{pointerCursor:true,underline:true}, + activate(event){ + if(event.button!==0||terminal.hasSelection())return; + // Revalidate the displayed range against the current buffer after repaint. + const current=linksForLine(terminal,link.range.start.y).find(candidate=> + candidate.url===link.url&&JSON.stringify(candidate.range)===JSON.stringify(link.range)); + if(!current)return; + event.preventDefault();event.stopPropagation(); + Promise.resolve().then(()=>open(current.url)).catch(onError); + }, + }))); + }}); + return { + dispose(){registration.dispose();}, + isLinkAtCell(x,y){return linksForLine(terminal,y).some(({range:r})=> + y>=r.start.y&&y<=r.end.y&&(y!==r.start.y||x>=r.start.x)&&(y!==r.end.y||x<=r.end.x));}, + isLinkAtClientPoint(clientX,clientY){ + const screen=terminal.element?.querySelector('.xterm-screen'),rect=screen?.getBoundingClientRect(); + if(!rect||clientX=rect.right||clientY>=rect.bottom)return false; + const x=Math.floor((clientX-rect.left)*terminal.cols/rect.width)+1; + const y=Math.floor((clientY-rect.top)*terminal.rows/rect.height)+terminal.buffer.active.viewportY+1; + return this.isLinkAtCell(x,y); + }, + }; + } + const api={webURL,trimURL,linksForLine,attach}; + if(typeof module==='object'&&module.exports)module.exports=api; + else root.AeselTerminalLinks=api; +})(typeof globalThis==='object'?globalThis:this); diff --git a/easel/desktop/test/preview-smoke.cjs b/easel/desktop/test/preview-smoke.cjs index cec820a76..3385f0afa 100644 --- a/easel/desktop/test/preview-smoke.cjs +++ b/easel/desktop/test/preview-smoke.cjs @@ -1,4 +1,4 @@ -// Isolated offscreen Electron window. No PTY, login, or live Easel process. +// Isolated offscreen Electron window. No PTY, login, or live aesel process. const { app, BrowserWindow, ipcMain } = require('electron'); const { join, resolve } = require('node:path'); const fs = require('node:fs'); diff --git a/easel/desktop/test/terminal-links.test.cjs b/easel/desktop/test/terminal-links.test.cjs new file mode 100644 index 000000000..336206ce1 --- /dev/null +++ b/easel/desktop/test/terminal-links.test.cjs @@ -0,0 +1,33 @@ +const {test}=require('node:test');const assert=require('node:assert/strict'); +const {webURL,linksForLine,attach}=require('../terminal-links.js'); +function terminal(lines,cols=80){return {cols,rows:24,buffer:{active:{viewportY:0,getLine(y){const s=lines[y];if(!s)return;const chars=typeof s==='string'?s:s.text;return {isWrapped:!!s.wrapped,length:chars.length,getCell(x){return {getWidth:()=>1,getChars:()=>chars[x]}}};}}},hasSelection:()=>false};} +test('only HTTP(S), no credentials/control characters or oversized links',()=>{ + assert.equal(webURL('https://aesthetic.computer/@jeffrey/a'),'https://aesthetic.computer/@jeffrey/a'); + for(const s of ['javascript:alert(1)','file:///tmp/file','https://user:pass@example.com','https://example.com/\n','https://example.com/'+ 'x'.repeat(4096)])assert.equal(webURL(s),null); +}); +test('punctuation and balanced URL parentheses have exact cell ranges',()=>{ + const links=linksForLine(terminal(['See (https://example.com/a(b)).']),1); + assert.equal(links[0].text,'https://example.com/a(b)');assert.equal(links[0].range.start.x,6);assert.equal(links[0].range.end.x,29); +}); +test('wrapped links join actual wraps but never independent rows',()=>{ + const t=terminal(['https://example.',{text:'com/hello',wrapped:true}],16); + assert.equal(linksForLine(t,2)[0].url,'https://example.com/hello'); + assert.equal(linksForLine(terminal(['https://example.','com/hello'],16),2).length,0); +}); +test('link activation requires left click, no selection and unchanged buffer; no hover navigation',async()=>{ + const lines=['https://example.com'];const t=terminal(lines);let provider,opened=[]; + t.registerLinkProvider=p=>(provider=p,{dispose(){}}); + const helper=attach(t,{open:url=>opened.push(url)});let links;provider.provideLinks(1,l=>links=l); + assert.deepEqual(links[0].decorations,{pointerCursor:true,underline:true});assert.equal(opened.length,0); + const e={button:0,preventDefault(){},stopPropagation(){}}; + links[0].activate({...e,button:2});t.hasSelection=()=>true;links[0].activate(e);t.hasSelection=()=>false; + assert.equal(opened.length,0);links[0].activate(e);await new Promise(setImmediate);assert.equal(opened.length,1); + lines[0]='different text';links[0].activate(e);await new Promise(setImmediate);assert.equal(opened.length,1); + assert.equal(helper.isLinkAtCell(1,1),false); +}); + +test('wide Unicode cells before URLs do not shift the click target',()=>{ + const t=terminal([]),data=[['🎨',2],['',0],[' ',1],...Array.from('https://example.com').map(c=>[c,1])]; + t.buffer.active.getLine=y=>y===0?{length:data.length,isWrapped:false,getCell:x=>({getChars:()=>data[x][0],getWidth:()=>data[x][1]})}:undefined; + const [link]=linksForLine(t,1);assert.equal(link.range.start.x,4);assert.equal(link.range.end.x,data.length); +}); diff --git a/easel/desktop/updater.cjs b/easel/desktop/updater.cjs index 7a0943fef..769b796de 100644 --- a/easel/desktop/updater.cjs +++ b/easel/desktop/updater.cjs @@ -7,8 +7,8 @@ function createUpdater({app, notify, requestRestart, prepareRelaunch = () => {}, updater.autoDownload = true; updater.autoInstallOnAppQuit = false; // Session checkpoint must succeed first. updater.on('error', error => notify(`Update failed: ${error.message}`)); - updater.on('update-not-available', () => notify('Easel is up to date.')); - updater.on('update-available', info => notify(`Downloading Easel ${info.version}…`)); + updater.on('update-not-available', () => notify('aesel is up to date.')); + updater.on('update-available', info => notify(`Downloading aesel ${info.version}…`)); updater.on('update-downloaded', () => { downloaded = true; if (!applying) requestRestart('update'); }); return updater; } diff --git a/easel/docs/format.md b/easel/docs/format.md index b7ab538bf..88cddf35e 100644 --- a/easel/docs/format.md +++ b/easel/docs/format.md @@ -48,7 +48,7 @@ line after a crash; malformed complete lines remain errors. ## Required company sharing -Before using Easel, each signed-in account must acknowledge disclosure version 2: future user messages, assistant replies, and artifact revision references are shared with authorized AC staff for product improvement, with a 30-day retention limit. Declining exits before creating a session or transmitting conversation content. Existing private messages are not backfilled. Account changes require a separate acknowledgment. The disclosure is available through `/sharing`; there is no optional private-use mode. +Before using Aesel, each signed-in account must acknowledge disclosure version 2: future user messages, assistant replies, and artifact revision references are shared with authorized AC staff for product improvement, with a 30-day retention limit. Declining exits before creating a session or transmitting conversation content. Existing private messages are not backfilled. Account changes require a separate acknowledgment. The disclosure is available through `/sharing`; there is no optional private-use mode. A `.easel` journal remains on disk. Uploads use authenticated batches of at most 100 records / 256 KiB. Recognizable credentials are redacted on both client and server. Failed uploads remain queued; a new generation cannot start until its user message has been journaled and the pending upload succeeds. `/transcript export FILE.easel` exports the local transcript; `/transcript delete` deletes the uploaded copy, while future messages continue under required sharing. @@ -71,7 +71,7 @@ Retries do not renew retention or mutate an accepted stable record ID. A partial batch failure is retryable with identical IDs. The unique owner/session/sequence index prevents two different events from claiming the same position. -These are company-private application records for improving Easel. They are not +These are company-private application records for improving Aesel. They are not sent to PostHog or automatically forwarded to model providers or training jobs. This prototype supplies storage and access controls, not a training pipeline, company review UI, or a promise of end-to-end encryption. diff --git a/easel/docs/interface-contract.md b/easel/docs/interface-contract.md index 098e9a7f4..b4f9d83f5 100644 --- a/easel/docs/interface-contract.md +++ b/easel/docs/interface-contract.md @@ -1,6 +1,6 @@ -# Easel interface contract +# Aesel interface contract -`easel` opens the standalone desktop app. `ac` opens Easel inside the current +`easel` opens the standalone desktop app. `ac` opens Aesel inside the current terminal, with optional Slab integration. They are two hosts for the same making interface and the same project data. Neither interface may introduce a separate conversation model, command dialect, artifact history, or meaning for an action. @@ -15,7 +15,7 @@ conversation model, command dialect, artifact history, or meaning for an action. | `ac` / `ac DIRECTORY` | Run the shared TUI in this terminal, defaulting to the current directory. | | `easel --help` | Explain desktop launch options without starting the app. | -The macOS desktop launcher uses `open -a ~/Applications/Easel.app`, without +The macOS desktop launcher uses `open -a ~/Applications/Aesel.app`, without `-n`. It does not create another app instance. If the app is already running, macOS activates it; new workspace/backend/model arguments are startup options, not a promise to replace an active session. Quit the app before starting it with @@ -37,7 +37,7 @@ restarts bypass this chooser. Keep the same visible layout, labels, actions, keyboard shortcuts, and state: - The conversation scrolls internally above a fixed composer/footer. -- EASEL opens `/about`; the @handle opens that user's profile. +- AESEL opens `/about`; the @handle opens that user's profile. - Medium, current artifact/version, and conversation engine remain visible. - Picture, Sound, Piece, Paper, and Game Boy use the same `/medium` and artifact selection actions. `/new` starts fresh work in the current medium; `/new thread` @@ -58,7 +58,7 @@ Keep the same visible layout, labels, actions, keyboard shortcuts, and state: Host primitives may differ: Electron supplies a window, PTY, clipboard, browser, and embedded preview; a native terminal supplies its own text surface and Slab can supply the preview/window controls. This permits different implementation -code, not a visibly different Easel product. Slab must remain optional: the +code, not a visibly different Aesel product. Slab must remain optional: the standalone app works without its daemon, and `ac` still works without overlays. ## Shared actions and storage diff --git a/easel/docs/local-contract.md b/easel/docs/local-contract.md index fd5de31e4..63f235ca0 100644 --- a/easel/docs/local-contract.md +++ b/easel/docs/local-contract.md @@ -1,6 +1,6 @@ # Local contract -Easel requires no Easel server. +Aesel requires no Aesel server. - No runtime account, telemetry, analytics, cloud sync, or hosted control plane. - Configuration, session records, memory, and credentials remain on machines @@ -42,13 +42,13 @@ workspace paths, no conversation. The channel token is random per session and is never reused. Pushing is the interface's own action, on a file the user can see, and stops -when the session ends. It is the one thing Easel sends without being +when the session ends. It is the one thing Aesel sends without being asked each time, so it is worth stating plainly: while the interface is open, the piece on screen is repeatedly leaving the machine. ## Inference boundary -The terminal interface is always Easel. Engines are internal bridges, +The terminal interface is always Aesel. Engines are internal bridges, not alternate client interfaces or command shortcuts. Two bridges exist, both remote: Claude Code in headless stream-json mode (the @@ -56,7 +56,7 @@ default, on `claude-opus-5`) and Codex app-server. `--backend` and `/backend` choose between them and `--model` and `/model` name the model. The interface labels remote inference before a prompt is sent. Provider terms govern that traffic, and each bridge signs in with its own vendor's existing -credentials on this machine; Easel stores no key of its own. +credentials on this machine; Aesel stores no key of its own. Neither bridge inherits the user's own agent configuration. Codex is started with `on-request` approvals and a `workspace-write` sandbox regardless of what @@ -89,7 +89,7 @@ Codex runs commands under an operating-system sandbox: writes are confined to the workspace and `networkAccess` is false, so an approved command still cannot reach the network without a second, explicit escalation. -Claude Code has no equivalent sandbox. On that bridge Easel confines +Claude Code has no equivalent sandbox. On that bridge Aesel confines the file tools to the workspace, removes WebFetch and WebSearch, and routes every prompt to this terminal — and Claude does prompt before a command that touches the network — but the prompt is the whole boundary. A shell command the @@ -97,7 +97,7 @@ user approves runs with the user's own privileges and can reach the network. Read-only commands are auto-approved by Claude's own classifier, as reads inside the sandbox are on the Codex bridge. -Easel therefore does not claim that agent tools are network-isolated +Aesel therefore does not claim that agent tools are network-isolated on the Claude bridge. Where that matters, `--backend codex` is the bridge with a kernel behind its approvals. diff --git a/easel/docs/native-shell.md b/easel/docs/native-shell.md index 53687c15a..a310c1523 100644 --- a/easel/docs/native-shell.md +++ b/easel/docs/native-shell.md @@ -1,10 +1,10 @@ -# A native Easel +# A native Aesel Written 2026-09-13. Nothing here is implemented. Line counts, flags and file paths were read out of the tree that morning; anything I could not run is labelled as such, and there is a list of those at the end. -The question is whether Easel should become an application — a Mac app, a +The question is whether Aesel should become an application — a Mac app, a Windows app, possibly an iOS app — instead of a program you run inside somebody else's terminal. The prompt for this memo framed it as packaging the TUI together with the Slab overlays that today only work beside it. That framing is @@ -126,7 +126,7 @@ at all: on this machine both binaries are standalone Mach-O executables so the npm-shim problem does not arise. If a Windows user has a `.cmd` shim install instead, Node has refused to spawn `.cmd` without `shell: true` since 18.20.2. Second, and worse: `claude`'s own Bash tool needs a shell, and on -Windows that has historically meant Git Bash. A Windows Easel could plausibly +Windows that has historically meant Git Bash. A Windows Aesel could plausibly start the bridge and then watch every command the model runs fail. I have no Windows machine and there is no Windows evidence anywhere in this repo. @@ -146,7 +146,7 @@ view, and `render.test.mjs` covering half of them. The stronger argument against the rewrite is not cost. It is that `docs/local-contract.md` says, as a boundary rather than a description: *"The -terminal interface is always Easel. Engines are internal bridges, not alternate +terminal interface is always Aesel. Engines are internal bridges, not alternate client interfaces."* A native view layer would be a second client interface. It might be a good one, but it has to be argued for as a product, not slipped in as a port. Nothing in the brief for this memo argues for it. @@ -207,7 +207,7 @@ cross-reference to `easel/` anywhere. My read: **build inside ac-electron, not beside it.** A second Electron app with a second signing config, a second updater and a second release pipeline is the distraction. The daemon it already is — menu bar, tray, updater, deep links, -`~/.ac-token` — is most of the chrome an Easel window needs around it. +`~/.ac-token` — is most of the chrome an Aesel window needs around it. ## 2. What the overlays become @@ -241,13 +241,13 @@ What is lost is more interesting than what is saved. sigil exists so you can tell nine sessions apart at a glance across a wall of tiled panes. One window with one session has nothing to disambiguate, and the rock degrades into decoration. So this is a design constraint, not a detail: a -native Easel should have panes or tabs, and the rock should be what marks them. +native Aesel should have panes or tabs, and the rock should be what marks them. If it is a single-session window, drop the rock and keep the scan code. **Cross-application reach is gone.** Today the rocks decorate Terminal.app and iTerm2 — someone else's windows. A native app cannot decorate a terminal it does not own. If @jeffrey keeps working in Terminal.app under Slab, the native app -does not replace that; it becomes a second place Easel lives. Two shells to keep +does not replace that; it becomes a second place Aesel lives. Two shells to keep in agreement is a real recurring cost and I do not think it goes away. **The scan code gets better, not worse.** `PromptScanCode` is 99 lines of @@ -265,7 +265,7 @@ that the AC-runtime-as-webview constraint works as an inner pane and not just as a whole window. It ports directly. **Keep writing the Slab marker either way.** `SlabSession` writes one JSON file -per session into `~/.local/share/slab/state`. If a native Easel keeps doing that, +per session into `~/.local/share/slab/state`. If a native Aesel keeps doing that, it appears in the existing menu bar and prox ledger for free, and the fleet does not need to learn a new thing. The marker costs nothing and buys continuity. @@ -295,7 +295,7 @@ grammar with its load-bearing encoded space, and the fact that a piece is one file. What is duplicated is the agent loop (about 200 lines of `ac-server.mjs`), the SSE parser, the publish call, sign-in, and the entire interface. -**(d) Ship no native app.** Worth naming, because Easel's actual barriers today +**(d) Ship no native app.** Worth naming, because Aesel's actual barriers today are `curl | sh`, "you need Node 18", and "you need a Claude or Codex subscription", and a window fixes only the first of those. The third is what `ac-server.mjs` was built to fix, and it was built without any native work at @@ -314,7 +314,7 @@ No subprocess means the `ac` bridge only, and that is a sharper limit than `z-ai/glm-4.6`, `qwen/qwen3-coder`, `deepseek/deepseek-chat-v3.1` — with a comment saying why: *"Adding a frontier model here multiplies the cost of the free tier by about thirty."* It requires a token, resolves it to an `@handle`, -and meters against the same daily budget as `/api/ask`. So an iOS Easel is +and meters against the same daily budget as `/api/ask`. So an iOS Aesel is categorically a weaker agent than the desktop one **by server policy, not by porting effort**. No Opus, no Fable, no Codex, and a day's allowance shared with everything else AC buys for that handle. @@ -329,7 +329,7 @@ Sign-in needs rewriting. `ac-session.mjs` runs a loopback HTTP server on port `ASWebAuthenticationSession` against a custom scheme. Auth0 supports it; it is about ninety lines and a callback URL registration, and it is standard work. -So, plainly: **is an iOS Easel a real editor or a viewer with a prompt?** +So, plainly: **is an iOS Aesel a real editor or a viewer with a prompt?** It is a real single-file editor with a deliberately weak model, and the interesting thing about it is not the editing. It is that the piece is live and @@ -340,8 +340,8 @@ and it is arguably a *better* demonstration of what Aesthetic Computer is than the desktop app, because the whole live-piece apparatus stops needing a second screen to explain itself. -It is not Easel. It is the hosted half of Easel with a touch interface, and it -should be named and scoped as that rather than shipped as "Easel for iPhone" and +It is not Aesel. It is the hosted half of Aesel with a touch interface, and it +should be named and scoped as that rather than shipped as "Aesel for iPhone" and then apologised for. One more consequence: it cannot be a terminal. Nobody drives slash commands and @@ -372,7 +372,7 @@ with silo, and `electron-updater` points at fastlane lanes at `apple/fastlane/`. **The WKWebView bridge exists.** `apple/aesthetic.computer/ContentView.swift` is -466 lines and already has the pattern an iOS Easel needs: two message handlers +466 lines and already has the pattern an iOS Aesel needs: two message handlers (`iOSApp`, `iOSAppLog`), a `console.log` monkey-patch at document start, and native→JS calls into named globals (`window.iOSReceivePushToken`, `iOSAppSwitchPiece`) with a retry loop for the case where the page has not @@ -383,10 +383,10 @@ only** — `SDKROOT = iphoneos`, no `SUPPORTED_PLATFORMS` override, no Catalyst. There is no macOS target in it and never has been. And `fastlane ios build` is currently *broken*: `PROGRESS.md` records it failing on 2026-09-11 because Xcode 26.6 kept the iPhoneOS SDK but has no simulator runtimes, and version 1.1 has -been sitting in `PREPARE_FOR_SUBMISSION` since. An iOS Easel would inherit a +been sitting in `PREPARE_FOR_SUBMISSION` since. An iOS Aesel would inherit a pipeline that does not presently produce an IPA. -An iOS Easel would be a **new target or a new project**, not a target of +An iOS Aesel would be a **new target or a new project**, not a target of `aesthetic.computer.xcodeproj`. The precedent in this repo is clear: oskiewar, trackdrum and tvos-tapes are each their own project, two of them generated by XcodeGen from a `project.yml`. Follow that. @@ -432,7 +432,7 @@ Apple Events (*"App Review routinely rejects it for a menubar instrument"*), no MultipeerConnectivity, and *"no broad Downloads, Desktop, Documents, or home-directory access."* -Applied to Easel, the equivalent gate is the `claude` and `codex` bridges. The +Applied to Aesel, the equivalent gate is the `claude` and `codex` bridges. The workspace itself is solvable — MenuBand holds `com.apple.security.files.user-selected.read-write`, and a folder the user picks through an open panel plus a security-scoped bookmark is a normal sandboxed @@ -454,7 +454,7 @@ execute-outside-the-container rule are documented Apple behaviour, not something I ran. The practical conclusion is cleaner than the policy question, and it is the -useful part: **a sandboxed Mac App Store Easel would be the `ac` bridge only — +useful part: **a sandboxed Mac App Store Aesel would be the `ac` bridge only — which is the same product as the iOS app.** So MAS and iOS are one decision, not two, and Developer ID direct distribution is the only home the CLI bridges can have. That is a tidy line, and it means nobody has to litigate App Review to diff --git a/easel/docs/next-release.md b/easel/docs/next-release.md index 0dc68819f..88fded41b 100644 --- a/easel/docs/next-release.md +++ b/easel/docs/next-release.md @@ -1,8 +1,24 @@ -# Easel 0.7 — Pictures, Sound, Pieces, Papers +# Aesel 0.7.2 + +Includes the desktop companion and preview layout, syntax and link rendering, +queued provider approvals, bundled structural API references, saved phone threads +and model picker, and Slab window identity and title integration. + +The hosted AC harness includes optional Jev repair triage. The trusted-host +computer-use adapter and Captutor selector can ask Jev to choose among fresh, +observed targets. They do not click autonomously, replace the vision model, or +install an automatic decision layer in every Frame/Puppet MCP call. The phone +bundle does not contain provider credentials or enable the desktop Jev adapter. + +The native OCR helper uses Vision's fast recognizer to avoid accurate-mode cold +compilation delays. Its confidence scores remain native Vision scores. Jev +selection still checks observation age, allowed candidates, and previous outcomes. + +# Aesel 0.7 — Pictures, Sound, Pieces, Papers Proposed September 15, 2026. Strategy, not implemented functionality. -Easel should make pictures, sounds, pieces of aesthetic.computer software, and papers through one conversation and +Aesel should make pictures, sounds, pieces of aesthetic.computer software, and papers through one conversation and one version history. Choose the medium first; choose the tools and providers within it. An image model, an AC brush, and a hand-drawn stroke can all contribute to the same picture. @@ -81,7 +97,7 @@ contract from [Illy](../../plugins/illy/scripts/illy-mcp.mjs). Its installed capability registry currently advertises OpenAI `gpt-image-2` generation/editing and fal Flux routes. Resolve supported models at runtime and show the explicit selection. Credential availability on the studio machine does not imply access -for an Easel user. Never silently switch providers after a failed paid request. +for an Aesel user. Never silently switch providers after a failed paid request. **Sound:** follow [Pop's compositional direction](../../pop/SCORE.md). Begin with short phrases and loops built from instruments, notes, rhythm, and effects, diff --git a/easel/docs/openrouter-backend.md b/easel/docs/openrouter-backend.md index 1eb6d9ae7..6c561568d 100644 --- a/easel/docs/openrouter-backend.md +++ b/easel/docs/openrouter-backend.md @@ -3,7 +3,7 @@ Written 2026-09-11. Prices quoted are from `https://openrouter.ai/api/v1/models`, pulled that morning. Nothing here is implemented. -The goal is to stop requiring every person who opens Easel to already +The goal is to stop requiring every person who opens Aesel to already hold a Claude or Codex subscription. Today both bridges sign in with a vendor CLI's own credentials, which is elegant — no key, no server, no account — and also a wall: the interface is unusable to anyone who has not already paid @@ -62,7 +62,7 @@ Worth noting, because it changes how bad this option feels: OpenRouter has an OAuth PKCE flow at `https://openrouter.ai/auth` that exchanges a code at `POST /api/v1/auth/keys` for a user-scoped key billed to that user's credits, and it documents a headless mode with no `callback_url` where the user copies -the code across. Easel already runs an Authorization-Code + PKCE flow +the code across. Aesel already runs an Authorization-Code + PKCE flow with a loopback callback in `src/ac-session.mjs`; a second one against a different issuer is the same ninety lines. So "bring your own key" can be `/login`-shaped rather than paste-shaped. @@ -120,7 +120,7 @@ The trust boundary in the recommended option is worth stating flatly: Aesthetic Computer never sees the prompts, but it does mint a bearer token that spends its money, and a user who extracts that token from their own machine — trivially, it is a file they own — can spend the cap however they like, including on -something that is not Easel. The cap is therefore the entire control. +something that is not Aesel. The cap is therefore the entire control. It should be small, it should reset, and it should be revocable per handle. ## Whether this needs a second kind of engine @@ -251,7 +251,7 @@ product, and writes the answer back to Redis. The invalidation half is in `ticket.js`, which on `customer.subscription.updated` reads `customer.metadata.sub` and deletes the cache entry. That pair — a Redis-cached entitlement keyed on the Auth0 sub, invalidated by a webhook — is precisely what -a paid Easel tier needs, and it can be ported by changing the product +a paid Aesel tier needs, and it can be ported by changing the product id. `system/netlify/functions/news-toll.mjs` is the cleanest single-file reference @@ -318,7 +318,7 @@ of magnitude. These are @jeffrey's own sessions in the full Aesthetic Computer tree, on a million-token context, and they are an upper bound rather than a typical -Easel session — the interface opens on one small piece file in a +Aesel session — the interface opens on one small piece file in a workspace the user chose. So, bottom-up: thirty turns, context growing from about 15k tokens to about 60k, averaging 35k. That is roughly 1.05M cache-read tokens, 300k cache-write, 20k output. Priced across the shelf at today's @@ -390,7 +390,7 @@ may be issued or re-issued, plus a short `expires_at` so an abandoned key stops mattering — and it is a partial one. Worth testing before promising a tier. Runaway spend inside a single session is the most likely everyday failure, and -it is not malice. Easel auto-approves tool calls by default: +it is not malice. Aesel auto-approves tool calls by default: `handleRequest` in `src/tui.mjs` answers `accept` unless `/ask on` is set, because the first real session spent two of its two hours and nineteen minutes parked on prompts with nobody watching. That default is right for a piece @@ -452,7 +452,7 @@ from unauthenticated route probes, not from a working call. cannot answer. The free tier's viability is a quality question, not a price one. - **The realistic monthly-active handle count.** 2,814 handles exist. How many - would open Easel in a month is a guess, and the $300 figure moves + would open Aesel in a month is a guess, and the $300 figure moves linearly with it. ## What this changes in the contract diff --git a/easel/docs/picture-workflow.md b/easel/docs/picture-workflow.md index 9f19a7165..ab9f1148b 100644 --- a/easel/docs/picture-workflow.md +++ b/easel/docs/picture-workflow.md @@ -2,7 +2,7 @@ A Picture gets a public short painting code when its WIP is first saved. Accepted changes autosave under that code. Pending proposals stay in the local -preview until accepted. AC, No Paint, and Easel use the same saved pixel and step +preview until accepted. AC, No Paint, and Aesel use the same saved pixel and step format. `/done` (also `/publish`) uploads the accepted PNG and AC playback ZIP, then seals diff --git a/easel/docs/playability.md b/easel/docs/playability.md index a781d8c8b..464827026 100644 --- a/easel/docs/playability.md +++ b/easel/docs/playability.md @@ -1,4 +1,4 @@ -# Playtesting inside the Easel preview +# Playtesting inside the Aesel preview Status: implementation plan, grounded in the current checkout. No game has been driven or declared playable by this exploration. @@ -23,7 +23,7 @@ Paths beginning `lib/` or `disks/` above are under `system/public/aesthetic.comp ## First executable slice -Add an Easel-scoped adapter for the existing Puppet action vocabulary, callable through Easel's existing MCP server alongside `ac_frame`. Bind it to the current preview guest using the frame bridge's channel/revision checks. Desktop can dispatch through the guest's existing browser input facility; native Slab needs an equivalent bridge into AC's input path. Report unsupported capabilities explicitly until both adapters exist. +Add an Aesel-scoped adapter for the existing Puppet action vocabulary, callable through Aesel's existing MCP server alongside `ac_frame`. Bind it to the current preview guest using the frame bridge's channel/revision checks. Desktop can dispatch through the guest's existing browser input facility; native Slab needs an equivalent bridge into AC's input path. Report unsupported capabilities explicitly until both adapters exist. Proposed tool: `ac_play`, a scoped adapter over existing input primitives, not a second control stack. One request batches a short action segment and its resulting observation. Proposed shape, not an existing tool: diff --git a/easel/docs/spriteland.md b/easel/docs/spriteland.md new file mode 100644 index 000000000..85d40ab2b --- /dev/null +++ b/easel/docs/spriteland.md @@ -0,0 +1,28 @@ +# Spriteland + +The desktop mediates the terminal grid through three visual layers: + +1. `spriteland.js` caches background fabric and foreground wood tiles. Layout and theme changes repaint them; token updates do not. +2. Xterm renders selectable text with a transparent background. The terminal owns chat and control actions; its four bottom rows sit over the wooden rail. Native terminals retain their character-based fallback. +3. `companion-scene.js` composites the generated donkey poses, a functional QR easel, and a pixel-lettered version plate. `donkey.js` schedules pose changes, pauses when hidden, and supports reduced motion. The accessible QR button overlays the exact composited QR square. + +The QR uses square black/white modules and a four-module white quiet zone. Wood, version text and all sixteen donkey poses stay outside that zone. Cmd+/− changes the common pixel scale; the name is twice the text size. The TV retains its own logical resolution. + +The real Slab rock is a separate native overlay. Desktop markers identify `host_app`, `host_pid` and `host_window_id`; Slab matches the exact Aesel process/window and suppresses duplicate native name/QR/preview surfaces. The ledger registers `proxNamespace`, `proxName` and `proxIdentity`. The desktop reads the matching session's authoritative name; `prox:easel:` resolves it, with `prox:easel::` available for fleet ambiguity. + +Validation: + +- `node --test easel/test/donkey.test.mjs` +- Run `easel/test/preview-geometry.electron.cjs` with the desktop Electron binary for window resizing, hover/fullscreen geometry and QR zoom. +- Run `easel/test/companion-scene.electron.cjs` with `EASEL_DESKTOP=1 COLORTERM=truecolor` and Electron. It verifies every QR/quiet-zone pixel across all sixteen poses and saves normal/small/tiny full-window screenshots under `/tmp/easel-scene-*.png`. +- On macOS, `swift easel/test/decode-qr.swift EXPECTED_URL /tmp/easel-scene-normal.png /tmp/easel-scene-small.png /tmp/easel-scene-tiny.png` checks actual screenshot decoding with Vision. Set SDKROOT to a supported local SDK if necessary. + +The September 15 integration check decoded the same watch URL at 16px, 14px and 12px text sizes. This is software-decoder evidence, not a guarantee for every physical phone camera, screen size or lighting condition. + +The companion now draws only atlas columns 0–45 (the donkey), mirrored toward +content. The QR is a separate right-side button revealed by window hover or +keyboard focus, with its own quiet zone. Version text stays near the companion. +The title shows `@handle/proxName`. Renderer-measured title geometry travels over +validated IPC to a private `state/easel-layout/-.json` sidecar; Slab +places the compact native rock immediately after the title using the same bounds +for drawing, pointer hits and occlusion. No fixed blank slot precedes the title. diff --git a/easel/install.sh b/easel/install.sh index 71f8889c5..e9de6db7f 100755 --- a/easel/install.sh +++ b/easel/install.sh @@ -11,9 +11,10 @@ ZSH_PROFILE="${ZDOTDIR:-${HOME}}/.zprofile" mkdir -p "$BIN_DIR" "$CONFIG_DIR" chmod +x "$PROJECT_DIR/bin/easel" ln -sfn "$PROJECT_DIR/bin/easel" "$BIN_DIR/easel" +ln -sfn "$PROJECT_DIR/bin/easel" "$BIN_DIR/aesel" ln -sfn "$PROJECT_DIR/bin/easel" "$BIN_DIR/ac" -# The tool was called `aesthetic` before it was called Easel, and that name +# The tool was called `aesthetic` before it was called Aesel, and that name # belongs to the platform helper it displaced. Take it back only if it is still # our own symlink — never touch a real file someone else put there. if [[ -L "$BIN_DIR/aesthetic" ]] && [[ "$(readlink "$BIN_DIR/aesthetic")" == *"/bin/aesthetic" || "$(readlink "$BIN_DIR/aesthetic")" == *"/bin/easel" ]]; then @@ -50,6 +51,6 @@ case ":${PATH}:" in *) printf 'Add %s to PATH to use the commands.\n' "$BIN_DIR" >&2 ;; esac -printf 'Installed Easel:\n' +printf 'Installed Aesel:\n' printf ' %s\n' "$BIN_DIR/ac" printf ' %s\n' "$BIN_DIR/easel" diff --git a/easel/media/gameboy/README.md b/easel/media/gameboy/README.md index 9946c2c0c..f9cac63a0 100644 --- a/easel/media/gameboy/README.md +++ b/easel/media/gameboy/README.md @@ -17,18 +17,18 @@ standard bootable cartridge header. Validation checks its logo, declared ROM size, header checksum and global checksum. The source and compiler binary hashes are saved with the ROM hash. Source edits keep the last successful preview and mark `metadata.sourceAhead`; export requires the source to match the build. -The shared Easel artifact history owns versioning and rollback. +The shared Aesel artifact history owns versioning and rollback. Install [GBDK-2020](https://github.com/gbdk-2020/gbdk-2020/releases), following its [getting-started guide](https://gbdk.org/docs/api/docs_getting_started.html). Set `GBDK_HOME` to the extracted `gbdk` directory. An explicitly configured but missing toolchain is an error, never silently substituted. Otherwise discovery -checks the monorepo's `kidlisp-gameboy/gbdk`, Easel's optional local GBDK 4.5.0 +checks the monorepo's `kidlisp-gameboy/gbdk`, Aesel's optional local GBDK 4.5.0 pack, then PATH. The optional pack location is `~/.local/share/easel/toolchains/gbdk-4.5.0/gbdk`. Builds invoke the same `lcc -o game.gb main.c` workflow as AC, adding only a fixed -EASEL cartridge title. They run in an isolated temporary artifact directory, +AESEL cartridge title. They run in an isolated temporary artifact directory, with fixed arguments, no shell, no forwarded credential environment, a 20-second process-group timeout, and bounded diagnostics. Failed compiles preserve the previous ROM. Source paths and outputs reject symlinks; project file writes are diff --git a/easel/media/gameboy/starter.c b/easel/media/gameboy/starter.c index 1ed2deddc..8dc7141ad 100644 --- a/easel/media/gameboy/starter.c +++ b/easel/media/gameboy/starter.c @@ -1,4 +1,4 @@ -// Easel joypad sketch, built with Aesthetic Computer's GBDK workflow. +// Aesel joypad sketch, built with Aesthetic Computer's GBDK workflow. // D-pad moves the original mark. A returns it to the center. #include #include @@ -11,7 +11,7 @@ const uint8_t mark[] = { void main(void) { uint8_t x = 84, y = 88; - printf("EASEL\n\nGAME BOY SKETCH\n\nD-PAD: MOVE\nA: CENTER"); + printf("AESEL\n\nGAME BOY SKETCH\n\nD-PAD: MOVE\nA: CENTER"); set_sprite_data(0, 1, mark); set_sprite_tile(0, 0); OBP0_REG = 0xe4; diff --git a/easel/media/picture/README.md b/easel/media/picture/README.md index c690fd27b..d30b1cc1f 100644 --- a/easel/media/picture/README.md +++ b/easel/media/picture/README.md @@ -3,7 +3,7 @@ `src/media/picture.mjs` creates a portable 512 × 512 picture. It stores accepted layers and a pending proposal in `picture.json`, renders the current proposal in `preview.png`, and exports accepted layers to `composite.png`. Accept adds a -layer; discard does not touch accepted pixels. The shared Easel artifact store +layer; discard does not touch accepted pixels. The shared Aesel artifact store owns revision numbering and rollback of these files. `ac-line.mjs` vendors the exact `nopaintProposal` contract from @@ -12,7 +12,7 @@ owns revision numbering and rollback of these files. AC's Line brush. The portable CPU `ink().line()` implementation rasterizes round segments without AC runtime dependencies; it does not promise pixel identity with every AC renderer. A test compares the vendored proposal with its source. -The toolkit is distributed by Aesthetic Computer under the Easel license. +The toolkit is distributed by Aesthetic Computer under the Aesel license. Use `propose` with a seed and optional RGBA color, thickness, and points, inspect its preview, then `accept` or `discard`. `import_png` accepts an existing file diff --git a/easel/media/sound/README.md b/easel/media/sound/README.md index cc0a5a79a..3056824e7 100644 --- a/easel/media/sound/README.md +++ b/easel/media/sound/README.md @@ -1,10 +1,10 @@ # Sound toolkit -The first Easel Sound lane composes short instrumental phrases and loops using +The first Aesel Sound lane composes short instrumental phrases and loops using Pop's own sinepower voice, wobble/bitcrush effects, and Bjorklund rhythms. No samples, account, network, external executable, or personal voice assets are required. Source modules in `pop/` are verbatim copies; `provenance.json` records -the exact original paths and SHA-256 hashes. The Easel repository license applies. +the exact original paths and SHA-256 hashes. The Aesel repository license applies. `score.json` is the editable recipe: diff --git a/easel/package.json b/easel/package.json index 776168624..fa648a64f 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { - "name": "easel", - "version": "0.6.1", + "name": "aesel", + "version": "0.7.2", "private": true, "type": "module", "scripts": { diff --git a/easel/phone/host.html b/easel/phone/host.html index ecc18ea8f..5bc685cc4 100644 --- a/easel/phone/host.html +++ b/easel/phone/host.html @@ -22,6 +22,11 @@ Swift → JS is `window.aesel.*`. JS → Swift is one message handler named `aesel`, carrying the session's events verbatim. --> +