diff --git a/apple/aesel/Aesel.xcodeproj/project.pbxproj b/apple/aesel/Aesel.xcodeproj/project.pbxproj index 5eba832531..f8420788b5 100644 --- a/apple/aesel/Aesel.xcodeproj/project.pbxproj +++ b/apple/aesel/Aesel.xcodeproj/project.pbxproj @@ -8,19 +8,40 @@ /* Begin PBXBuildFile section */ 06DAB9B425E19C4E02F25A24 /* AeselPieceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72A337044339282DB88573A0 /* AeselPieceView.swift */; }; + 084DDDE9E374CC8B27DEA518 /* slab-theme.json in Resources */ = {isa = PBXBuildFile; fileRef = AACC6D25723505FB156B46F0 /* slab-theme.json */; }; + 0B56E0A1E07AD7B531824F41 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B505C3279E0F8DB5292B86 /* Session.swift */; }; + 15625BEE72859E0B3AA9421E /* ComicRelief-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 99FE2FC14157DB3F88708A6B /* ComicRelief-Bold.ttf */; }; 1F53641C03321161A7665F3B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505E152C400C5CE69463B39F /* ContentView.swift */; }; + 2026E504A366EFEB32F2F822 /* AeselModelPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26176753E43E445E2CF551D /* AeselModelPicker.swift */; }; + 23BD42525B15F37C435178AB /* AeselProviderPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3405C669DD22957E6E9FC52B /* AeselProviderPicker.swift */; }; 2DB3100BC43BD21115A1BA37 /* AeselApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7CE6904A010A2BEC894D13C /* AeselApp.swift */; }; 3661DCFA19E2594688511C86 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B505C3279E0F8DB5292B86 /* Session.swift */; }; 44709587196D5718C5B25542 /* COMIC-RELIEF-OFL-1.1.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4A54F121629B5E78C6F46F6A /* COMIC-RELIEF-OFL-1.1.txt */; }; + 466E32FFFB239E88D25A3C66 /* Braincells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */; }; + 496F27216049A66E73C95E09 /* SessionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */; }; + 4A5FFF82FBF2EF81CD8D158A /* AeselApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7CE6904A010A2BEC894D13C /* AeselApp.swift */; }; + 56DF7E946D2DDC7451AA9F0E /* AeselPieceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72A337044339282DB88573A0 /* AeselPieceView.swift */; }; + 5FF391305FB9EEF4AB630D42 /* AeselScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */; }; 6E01FC52B2208E175FFC6985 /* SessionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */; }; 7AE3B1696F71BFDE82E8B72E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 228E709932A29DC953A73B71 /* Assets.xcassets */; }; + 8A7F1BBCD12CDD5FE9707061 /* AeselHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2D2E4941E446673BAF4F97 /* AeselHomeView.swift */; }; + 975A2C86AF7C7B60369819B6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505E152C400C5CE69463B39F /* ContentView.swift */; }; + 9D73D7FD54B29205733E3BC9 /* ApplePlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4BC5239A9B70F128C1DC84 /* ApplePlatform.swift */; }; A2CDE2052DDB891D3A4659D6 /* AeselNotebook.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB9C447ACCA3590492C05AC5 /* AeselNotebook.swift */; }; + A4B8792BDBB5F95822088FB0 /* AeselNotebook.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB9C447ACCA3590492C05AC5 /* AeselNotebook.swift */; }; + A766FD4831AD3177B8902DA5 /* ApplePlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4BC5239A9B70F128C1DC84 /* ApplePlatform.swift */; }; + A989A8D07DAA87AB483EC748 /* AeselButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A512F7F818F52E3E58D3B7CB /* AeselButtonStyle.swift */; }; AC7B6241A0E7F2791E06F1CA /* ComicRelief-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 99FE2FC14157DB3F88708A6B /* ComicRelief-Bold.ttf */; }; B7D0EF78D26BBA38AB801393 /* slab-theme.json in Resources */ = {isa = PBXBuildFile; fileRef = AACC6D25723505FB156B46F0 /* slab-theme.json */; }; B8236CE8844912F069905928 /* AeselScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */; }; + BAC151C354229839F584BDDF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 228E709932A29DC953A73B71 /* Assets.xcassets */; }; C27DB5FE92281B28965A6535 /* Braincells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */; }; C48716DE150CDF3FE383298E /* Session in Resources */ = {isa = PBXBuildFile; fileRef = 47AE0766266534A8EE665542 /* Session */; }; C5323C1BB9D8D59F14A1FF74 /* AeselModelPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26176753E43E445E2CF551D /* AeselModelPicker.swift */; }; + C5E4BB3C192D944F7CB281FC /* Session in Resources */ = {isa = PBXBuildFile; fileRef = 47AE0766266534A8EE665542 /* Session */; }; + E269C78DB87E568CC6F63CC8 /* AeselProviderPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3405C669DD22957E6E9FC52B /* AeselProviderPicker.swift */; }; + E9877D833B3CE6FE4FB14192 /* AeselButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A512F7F818F52E3E58D3B7CB /* AeselButtonStyle.swift */; }; + EE331E96C91793CBF5E52157 /* COMIC-RELIEF-OFL-1.1.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4A54F121629B5E78C6F46F6A /* COMIC-RELIEF-OFL-1.1.txt */; }; FBC8984584385405A83CB639 /* AeselHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2D2E4941E446673BAF4F97 /* AeselHomeView.swift */; }; /* End PBXBuildFile section */ @@ -28,13 +49,17 @@ 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 = ""; }; + 3405C669DD22957E6E9FC52B /* AeselProviderPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselProviderPicker.swift; sourceTree = ""; }; 47AE0766266534A8EE665542 /* Session */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Session; path = Resources/Session; sourceTree = SOURCE_ROOT; }; 4A54F121629B5E78C6F46F6A /* COMIC-RELIEF-OFL-1.1.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "COMIC-RELIEF-OFL-1.1.txt"; sourceTree = ""; }; 505E152C400C5CE69463B39F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Braincells.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 = ""; }; + 7F4BC5239A9B70F128C1DC84 /* ApplePlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplePlatform.swift; sourceTree = ""; }; + 99EE09710898E704A2EDB590 /* AeselMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AeselMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; 99FE2FC14157DB3F88708A6B /* ComicRelief-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ComicRelief-Bold.ttf"; sourceTree = ""; }; + A512F7F818F52E3E58D3B7CB /* AeselButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselButtonStyle.swift; sourceTree = ""; }; AACC6D25723505FB156B46F0 /* slab-theme.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "slab-theme.json"; 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 = ""; }; @@ -48,11 +73,14 @@ isa = PBXGroup; children = ( B7CE6904A010A2BEC894D13C /* AeselApp.swift */, + A512F7F818F52E3E58D3B7CB /* AeselButtonStyle.swift */, CC2D2E4941E446673BAF4F97 /* AeselHomeView.swift */, F26176753E43E445E2CF551D /* AeselModelPicker.swift */, BB9C447ACCA3590492C05AC5 /* AeselNotebook.swift */, 72A337044339282DB88573A0 /* AeselPieceView.swift */, + 3405C669DD22957E6E9FC52B /* AeselProviderPicker.swift */, 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */, + 7F4BC5239A9B70F128C1DC84 /* ApplePlatform.swift */, 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */, 505E152C400C5CE69463B39F /* ContentView.swift */, 13B505C3279E0F8DB5292B86 /* Session.swift */, @@ -75,6 +103,7 @@ isa = PBXGroup; children = ( 22509229E839210D9200B4AA /* Aesel.app */, + 99EE09710898E704A2EDB590 /* AeselMac.app */, ); name = Products; sourceTree = ""; @@ -111,6 +140,24 @@ productReference = 22509229E839210D9200B4AA /* Aesel.app */; productType = "com.apple.product-type.application"; }; + B06E0CA251E11584E7EFAA6A /* AeselMac */ = { + isa = PBXNativeTarget; + buildConfigurationList = B305F863E9DCF792B982BFA5 /* Build configuration list for PBXNativeTarget "AeselMac" */; + buildPhases = ( + FE126C5A82E5DACF43957B94 /* Sources */, + D69DF980C6E5B37A19FF3612 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AeselMac; + packageProductDependencies = ( + ); + productName = AeselMac; + productReference = 99EE09710898E704A2EDB590 /* AeselMac.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -124,6 +171,10 @@ DevelopmentTeam = FB5948YR3S; ProvisioningStyle = Automatic; }; + B06E0CA251E11584E7EFAA6A = { + DevelopmentTeam = FB5948YR3S; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = C83B996AD0F1EAD487069A8B /* Build configuration list for PBXProject "Aesel" */; @@ -141,6 +192,7 @@ projectRoot = ""; targets = ( 90FA23DF12444BC96C87BB1F /* Aesel */, + B06E0CA251E11584E7EFAA6A /* AeselMac */, ); }; /* End PBXProject section */ @@ -158,6 +210,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D69DF980C6E5B37A19FF3612 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BAC151C354229839F584BDDF /* Assets.xcassets in Resources */, + EE331E96C91793CBF5E52157 /* COMIC-RELIEF-OFL-1.1.txt in Resources */, + 15625BEE72859E0B3AA9421E /* ComicRelief-Bold.ttf in Resources */, + C5E4BB3C192D944F7CB281FC /* Session in Resources */, + 084DDDE9E374CC8B27DEA518 /* slab-theme.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -166,11 +230,14 @@ buildActionMask = 2147483647; files = ( 2DB3100BC43BD21115A1BA37 /* AeselApp.swift in Sources */, + A989A8D07DAA87AB483EC748 /* AeselButtonStyle.swift in Sources */, FBC8984584385405A83CB639 /* AeselHomeView.swift in Sources */, C5323C1BB9D8D59F14A1FF74 /* AeselModelPicker.swift in Sources */, A2CDE2052DDB891D3A4659D6 /* AeselNotebook.swift in Sources */, 06DAB9B425E19C4E02F25A24 /* AeselPieceView.swift in Sources */, + E269C78DB87E568CC6F63CC8 /* AeselProviderPicker.swift in Sources */, B8236CE8844912F069905928 /* AeselScene.swift in Sources */, + 9D73D7FD54B29205733E3BC9 /* ApplePlatform.swift in Sources */, C27DB5FE92281B28965A6535 /* Braincells.swift in Sources */, 1F53641C03321161A7665F3B /* ContentView.swift in Sources */, 3661DCFA19E2594688511C86 /* Session.swift in Sources */, @@ -178,6 +245,26 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FE126C5A82E5DACF43957B94 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4A5FFF82FBF2EF81CD8D158A /* AeselApp.swift in Sources */, + E9877D833B3CE6FE4FB14192 /* AeselButtonStyle.swift in Sources */, + 8A7F1BBCD12CDD5FE9707061 /* AeselHomeView.swift in Sources */, + 2026E504A366EFEB32F2F822 /* AeselModelPicker.swift in Sources */, + A4B8792BDBB5F95822088FB0 /* AeselNotebook.swift in Sources */, + 56DF7E946D2DDC7451AA9F0E /* AeselPieceView.swift in Sources */, + 23BD42525B15F37C435178AB /* AeselProviderPicker.swift in Sources */, + 5FF391305FB9EEF4AB630D42 /* AeselScene.swift in Sources */, + A766FD4831AD3177B8902DA5 /* ApplePlatform.swift in Sources */, + 466E32FFFB239E88D25A3C66 /* Braincells.swift in Sources */, + 975A2C86AF7C7B60369819B6 /* ContentView.swift in Sources */, + 0B56E0A1E07AD7B531824F41 /* Session.swift in Sources */, + 496F27216049A66E73C95E09 /* SessionHost.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -229,10 +316,10 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; @@ -311,17 +398,53 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; }; name = Debug; }; + 6D52AD0DCB661617F6C62DC4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + CODE_SIGN_ENTITLEMENTS = Mac.entitlements; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = MacInfo.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.aesel.native; + PRODUCT_NAME = "Aesel Native"; + SDKROOT = macosx; + }; + name = Debug; + }; + E3FE4B89B1DFAE93157904C4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + CODE_SIGN_ENTITLEMENTS = Mac.entitlements; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = MacInfo.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.aesel.native; + PRODUCT_NAME = "Aesel Native"; + SDKROOT = macosx; + }; + name = Release; + }; FFE58E959C6E6A865323AB74 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -352,6 +475,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + B305F863E9DCF792B982BFA5 /* Build configuration list for PBXNativeTarget "AeselMac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6D52AD0DCB661617F6C62DC4 /* Debug */, + E3FE4B89B1DFAE93157904C4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; C83B996AD0F1EAD487069A8B /* Build configuration list for PBXProject "Aesel" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/apple/aesel/Aesel.xcodeproj/xcshareddata/xcschemes/AeselMac.xcscheme b/apple/aesel/Aesel.xcodeproj/xcshareddata/xcschemes/AeselMac.xcscheme new file mode 100644 index 0000000000..bfe54561ec --- /dev/null +++ b/apple/aesel/Aesel.xcodeproj/xcshareddata/xcschemes/AeselMac.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apple/aesel/EXPERIENCE.md b/apple/aesel/EXPERIENCE.md new file mode 100644 index 0000000000..b98b4126aa --- /dev/null +++ b/apple/aesel/EXPERIENCE.md @@ -0,0 +1,132 @@ +# Aesel: drafts, identity, and appearance + +See [ROADMAP.md](ROADMAP.md) for the remaining integrations, feature contracts, +dependencies, and acceptance gates. + +The notebook opens before sign-in. Keep the live web preview in the upper-right +corner through writing, generation, and account changes. Expanding it must not +replace or discard the notebook. + +```mermaid +flowchart TD + Open[Open notebook] --> Draft[Local draft and corner preview] + Draft -->|Send without an account| Curtain[AC sign-in curtain] + Curtain -->|Cancel or fail| Draft + Curtain -->|Sign in or sign up| Account[Same draft, account connected] + Account -->|Send with available braincells| Generate[Generate and preview] + Generate -->|Current automatic save, with handle| Public[Published piece] + Draft -. Proposed limited guest access .-> Guest[Generate within guest allowance] + Guest -. Allowance exhausted .-> Curtain +``` + +## Capability model + +| Capability | Current shared native shell | Proposed guest experience | +| --- | --- | --- | +| Start and retain a local piece thread | No account required | Same | +| Type a request | Available before sign-in; retained while the sign-in sheet is open or cancelled | Persist unsent text per thread across relaunches | +| Preview existing local source | Corner web preview; runtime currently needs network access | Same; offline runtime is separate work | +| Generate or revise through hosted AI | AC account and available braincells required | Small server-enforced guest allowance | +| Publish | Authenticated account with a handle; generated saves currently auto-publish | Guest work stays local; account required to publish | +| Continue after sign-in | Existing draft stays in place; Send remains explicit | Transfer guest work without replaying requests or spending automatically | + +“No account” means a local workspace today. It does not yet mean anonymous AI, +a full local source editor, offline operation, or guaranteed anonymity from the +services loaded by the web preview. + +## Sign-in + +Use the real `prompt.mjs` curtain in the embedded AC web view, including its +Login and Sign Up actions, instead of a separate imitation. The sheet supplies +close, loading, retry, and error states. Closing or failing sign-in returns to +the same draft. Successful sign-in connects the account; it does not send the +pending request. Accept credentials only through the existing trusted AC +callback and retain the native token in Keychain. + +The live curtain is loaded from production, so its rendered layout can change +independently of an installed native build. + +## Appearance and interaction + +Follow the system appearance, including changes while the app is open. There +is no appearance setting or saved override in Aesel. Light uses the +prompt curtain's pale paper and purple ink; Dark uses its purple background and +light ink. Activity changes tint and accent without switching appearance. + +System appearance reaches native controls, notebook HTML, and embedded web views. +An artwork remains responsible for its own colors; never apply a color filter +to make a generated piece match the shell. Keep notebook text readable in both +modes, including errors, code, links, and the user's messages. + +Enabled buttons and links show a pointing hand on macOS. Disabled controls use +the normal cursor and a dimmed appearance. Text entry retains its insertion +cursor. Use real buttons for actions, preserve keyboard activation and focus, +and give icon buttons accessible names. Hover feedback supplements touch and +keyboard operation. + +## Provider and model controls + +Keep two dropdowns, Provider followed by Model. The provider options are **AC, +Claude, Codex**, in that order, with their existing desktop images to the left. +AC uses the rotating pal image with the bundled AC mark as a loading/offline +fallback. Claude and Codex use their bundled marks. Braincells describe AC's +balance; they are not the provider's name or image. + +| Provider | Model chooser | Identity and execution | +| --- | --- | --- | +| AC | Automatic; no manual model selection while AC manages routing | AC account and braincells; hosted generation | +| Claude | Existing CLI model families; retain the resolved running model | User's Claude CLI login on the connected Mac | +| Codex | Live catalog from the installed CLI, including CLI default | User's Codex CLI login on the connected Mac | + +Selecting a provider must select a real execution backend. Preserve the draft +and transcript, remember the model per provider, reset incompatible model +settings, and apply changes to the next explicit Send. Disable switching during +a turn or upload. If a provider disconnects, retain the selection and offer +reconnect; never silently switch providers or spend AC braincells instead. + +Keep AC identity separate from provider identity. In the target shared shell, +a connected Claude or Codex account can generate local work without an AC +account; AC sign-in is needed when publishing to AC. That is account-free use +of Aesel/AC, not anonymous use of the inference provider. Hosted AC generation +continues to require AC identity unless the proposed guest allowance is enabled. +The current native Send gate applies to AC only because that is its sole engine; +replace it with provider-specific readiness when the CLI bridge lands. + +Current port boundary: Electron already implements all three providers. The +native shell runs AC only. Its menu shows the existing provider identities but +disables Claude and Codex until a native execution bridge exists. AC's model row +shows Automatic. A working Claude/Codex model chooser depends on that bridge +and must read its catalog rather than pretend an unavailable model is active. +Phone access also needs a decision between a paired Mac connection and keeping +CLI providers Mac-only; no phone-to-Mac transport is implemented here. + +## Proposed guest AI contract + +This is a server feature to implement, not a client-side authentication bypass. +Issue a short-lived, narrowly scoped guest credential. Enforce a small request +and cost budget on the server, including concurrent requests, and explain the +remaining allowance before generation. Choose the actual allowance and expiry +from inference costs before enabling it. Do not rely on a resettable local flag +to limit usage. + +Guest credentials cannot publish, buy credits, access account history, or claim +a handle. On exhaustion, keep the source, transcript, preview, and unsent text; +offer sign-in in context. On successful sign-in, associate only the work the +user chooses to retain. Never replay a generation request automatically. + +Before shipping guest access: implement issuance and budget accounting, test +expiry and simultaneous requests, define retention and deletion, and explain +what leaves the device. Publishing consent also needs an explicit product +decision: current account sessions auto-publish generated saves; a future +private-draft mode must change that behavior deliberately. + +## Validation + +Check both appearances with a fresh notebook, a reply containing code and +errors, the settings sheet, and the sign-in curtain. Check live updates by changing +the OS appearance. A signed-out Send must preserve text on cancel, failure, +and success; no generation or publication should start merely by signing in. +The corner preview must remain usable at narrow widths and during generation. + +The shared shell still has separate work ahead for Electron's terminal and +local-media backends. Those ports are independent of identity and appearance. diff --git a/apple/aesel/INTEGRATION.md b/apple/aesel/INTEGRATION.md index 099ae0c2d0..c1004555b3 100644 --- a/apple/aesel/INTEGRATION.md +++ b/apple/aesel/INTEGRATION.md @@ -1,5 +1,11 @@ # aesel on Mac and iPhone +The current shared-shell contracts and remaining work are in +[EXPERIENCE.md](EXPERIENCE.md) and [ROADMAP.md](ROADMAP.md). They supersede the +older launch, appearance, preview, and prototype identity assumptions below. +The native development Mac target uses `computer.aesthetic.aesel.native`; +the established product/iOS identity remains `computer.aesthetic.easel`. + 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. diff --git a/apple/aesel/Mac.entitlements b/apple/aesel/Mac.entitlements new file mode 100644 index 0000000000..5bf7424bef --- /dev/null +++ b/apple/aesel/Mac.entitlements @@ -0,0 +1,6 @@ + + + + com.apple.security.app-sandbox + com.apple.security.network.client + diff --git a/apple/aesel/MacInfo.plist b/apple/aesel/MacInfo.plist new file mode 100644 index 0000000000..7b14fa9196 --- /dev/null +++ b/apple/aesel/MacInfo.plist @@ -0,0 +1,16 @@ + + + + CFBundleDevelopmentRegionen + CFBundleExecutable$(EXECUTABLE_NAME) + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleNameaesel + CFBundleDisplayNameaesel + CFBundlePackageTypeAPPL + CFBundleShortVersionString0.7.5 + CFBundleVersion2 + LSMinimumSystemVersion$(MACOSX_DEPLOYMENT_TARGET) + LSApplicationCategoryTypepublic.app-category.graphics-design + NSHighResolutionCapable + NSPrincipalClassNSApplication + diff --git a/apple/aesel/README.md b/apple/aesel/README.md index 12afd35df3..e4d78f62f1 100644 --- a/apple/aesel/README.md +++ b/apple/aesel/README.md @@ -1,10 +1,49 @@ -# aesel for iPhone +# aesel for Mac and iPhone + +`Aesel` (iOS 17+) and `AeselMac` (macOS 14+) compile the same SwiftUI sources. +`ApplePlatform.swift` adapts WebKit hosting and native image drawing to UIKit or +AppKit. Both run the bundled JavaScript session, notebook renderer, and AC piece +preview; the Mac target does not use Electron or start a Node process. +The live WebKit preview stays in the upper-right corner, including for new +pieces and thread changes, and can expand to fill the workspace. + +Appearance follows the system automatically. Native controls and the notebook +share the system palette; enabled Mac buttons use a pointing hand. +The [experience model](EXPERIENCE.md) defines draft, sign-in, generation, and +publishing behavior, including the proposed guest AI allowance. +The [integration roadmap](ROADMAP.md) inventories the remaining ports and their +acceptance gates, including providers, paired hosts, media, files, and releases. +The native provider dropdown retains AC, Claude, and Codex with their desktop +images. Claude and Codex remain disabled until the CLI bridge is ported; AC's +model is Automatic. The existing Electron app still supports all three. + +```sh +./run.sh mac # build and open Aesel Native.app +``` + +The native Mac target currently supports the shared Piece workflow: AC sign-in, +hosted inference, drafts, publishing, notebook, saved threads and braincells. +It uses the separate `computer.aesthetic.aesel.native` identity while desktop +terminal/CLI providers, local media renderers and other Electron integrations +are ported. It does not replace the installed Electron app or import its +credentials. Mac sessions live in the app's Application Support directory; +iPhone sessions retain their existing Documents location. Tokens use Keychain. + +To build both targets without installing, run `./bundle-session.sh` and +`xcodegen generate`, then build schemes `AeselMac` (macOS) and `Aesel` +(`generic/platform=iOS`, with signing disabled for a compile check). + +The preview state transitions have a standalone native check: + +```sh +xcrun swiftc Sources/Session.swift Tests/SessionPreviewChecks.swift -o /tmp/aesel-preview-check +/tmp/aesel-preview-check +``` 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. +edits can preview through AC's JavaScript `dropped:piece` interface. The runtime and model still need an internet connection. The notebook bundles the desktop's sanitized rich-reply renderer: ruled pages, Markdown, highlighted code, math, diagrams and color swatches. Tap the piece diff --git a/apple/aesel/ROADMAP.md b/apple/aesel/ROADMAP.md new file mode 100644 index 0000000000..10180047c0 --- /dev/null +++ b/apple/aesel/ROADMAP.md @@ -0,0 +1,251 @@ +# Aesel integration and feature plan + +One workspace across Mac and iPhone: a notebook, a persistent upper-right web +preview, and the same pieces, threads, providers, and artifacts. Share the +session and tool logic; adapt windowing, files, input, and execution to the host. +The shared shell does not require rewriting the JavaScript agent in Swift. + +This is the remaining implementation plan, dated September 21, 2026. “Native” +means the shared SwiftUI Mac/iOS targets. “Desktop” means the existing Electron +and CLI implementation. A capability present in desktop is not automatically +available in native. [EXPERIENCE.md](EXPERIENCE.md) governs appearance, the AC +curtain, guest work, and provider controls. + +## Inventory + +| Integration or feature | Present now | Remaining native work | +| --- | --- | --- | +| Shared shell | Mac/iOS targets, bundled JS session, rich notebook | Complete desktop parity; measure startup, memory, and responsiveness | +| Appearance and controls | System light/dark, button pointers, fitted title | Keyboard/VoiceOver, touch targets, reduced motion, narrow-window acceptance | +| Corner web preview | Starter, local source, public piece, expand/return, retry | Preserve one runtime through expansion, input focus, audio controls, revision-aware readiness | +| Providers and models | AC execution; all three identities in the menu | Native Claude/Codex execution and their real model catalogs | +| AC account | Trusted web curtain, native Keychain, sign-out | Expiration/refresh recovery and optional shared account component with AC iOS | +| Guest work | Local threads and draft preview | Persist unsent text; optional capped hosted guest AI | +| Braincells | Balance display and StoreKit client | Purchase/restore/receipt/device acceptance; native Mac distribution decision | +| Publishing | Piece saves auto-publish with a handle | Visible publication controls, exact-revision receipts, conflict and retry handling | +| Threads | Local source, transcript, and agent conversation | Provider handoff, durable composer, export/import, migration and concurrent writers | +| Source and revisions | Desktop source tools and revision storage | Native editing, version history, rollback, source/output identity | +| Picture | Desktop layer/proposal/render/publish toolkit | Native renderer and files, proposal acceptance, export, painting publication | +| Sound | Desktop deterministic renderer and playback | Native render/playback, waveform, clock synchronization, interruption handling | +| Paper | Desktop LaTeX/tooling and visual QA | Build execution, PDF viewer, source bundle export, QA state | +| Game Boy | Desktop GBDK build and WasmBoy preview | Build execution, native-hosted emulator, touch/controller input, ROM export | +| Files and workspaces | Desktop projects; native app-local threads | Mac file grants, iOS document import/export, portable artifact packages | +| Multiple windows and project apps | Desktop Studio and project-app prototypes | Independent native sessions/windows; preserve project identity and restart state | +| Phone-to-Mac connection | No shared-shell transport | Pairing, capabilities, reconnect, job ownership, revocation | +| MCP and host tooling | Existing desktop AC tools, Slab/prox, frame/puppet integrations | Native action/state bridge and explicit capability boundaries | +| Diagnostics and recovery | Session errors and preview retry | Structured redacted diagnostics, crash recovery, bounded reconnect | +| Distribution | Existing Electron routes; native development app | Signed native release path, migration, physical-device acceptance, store review | + +The native Claude and Codex rows stay disabled until connected to working +backends. AC's model remains Automatic. Keep the provider's image to the left, +then its name, with a separate model chooser below. Do not substitute Braincells +for the AC provider identity or add an appearance preference. + +## Execution and provider integration + +```mermaid +flowchart TD + UI[Mac or iPhone notebook] --> Session[Shared session and durable local draft] + Session --> AC[AC hosted adapter] + Session --> Bridge[Authenticated execution bridge] + Bridge --> Claude[Claude CLI] + Bridge --> Codex[Codex app-server] + Bridge --> Media[Local media toolchains] + AC --> Events[Ordered turn and artifact events] + Claude --> Events + Codex --> Events + Media --> Events + Events --> Session + Session --> Preview[Upper-right artifact preview] + Session --> Publish[Explicit publication policy and receipts] +``` + +Reuse the adapters in [`easel/src`](../../easel/src/): `ac-server.mjs`, +`claude-server.mjs`, `app-server.mjs`, `backends.mjs`, `provider-picker.mjs`, +and `provider-preferences.mjs`. WebKit cannot replace a subprocess or filesystem +with a browser shim. A native helper or paired host must provide those services. +Keep provider credentials on the machine that executes the provider. + +Define a versioned bridge with capability discovery, provider readiness, model +catalogs, thread start/resume, turn start/interrupt, tool progress, approval +requests, artifact revisions, and final outcomes. Every event needs a session +ID, turn ID, sequence, and timestamp; every submitted operation needs a unique +ID. Reconnection resumes an operation or reports its status, never blindly +repeats a paid request. A dropped stream is not evidence that generation stopped. + +Keep the transcript and selected artifact when switching providers. Transfer +the supported conversation and source, not a promise of transferring private +vendor reasoning state. Remember model choice per provider. Reject unavailable +models visibly; do not silently change providers. Tool approval has an explicit +request ID and scope; expired approvals cannot authorize a later operation. + +For the native Mac helper, first choose and prove a distribution-compatible +process and file-access design. The current native target is sandboxed and has +network-client access; spawning arbitrary installed CLIs is not already solved +by sharing SwiftUI code. Do not remove the sandbox as an incidental UI fix. + +Completion: real Claude and Codex turns edit a piece, stream to the notebook, +update the corner preview, stop correctly, survive reconnect, and use the +selected model without consuming AC braincells. A missing CLI or expired vendor +login keeps the local draft usable and offers the appropriate setup action. + +## Phone pairing and host lifecycle + +Recommended sequence: finish local Mac execution first, then expose the same +capabilities to an explicitly paired phone. Whether phone pairing belongs in +the first release remains open; the provider menu must describe actual availability. + +Pair with a short-lived challenge confirmed on the host. Use authenticated, +encrypted transport and a revocable device credential. Publish only capabilities +the host has granted; directory grants and provider access are separate. Do not +create a public unauthenticated listener or transfer CLI login files to the phone. + +The host owns running jobs. Backgrounding or disconnecting the phone preserves +the job ID and last observed event. Reconnect reports completion, failure, or +continued work. Cancellation is acknowledged by the host. Handle sleeping Macs, +network changes, revoked pairing, expired credentials, and an absent display. +Lid-closed/headless execution must not depend on a visible browser or WindowServer +screenshot; test compute and preview capture as separate capabilities. + +Completion: pair, generate, disconnect, reconnect, cancel, and revoke on a real +iPhone without duplicate turns or cross-workspace access. No account token or +private host address appears in shared URLs or exported diagnostics. + +## Accounts, consent, billing, and publication + +Treat AC login, vendor login, pairing, and publication ownership as separate +identities. A Claude/Codex user can make local work without an AC account once +the bridge exists. Publishing to AC requires the intended AC account and handle. +Hosted AC generation continues to require available braincells unless a guest +allowance is deliberately implemented. + +Retain the real AC curtain and preserve draft/composer state on cancel, failure, +and success. Extend token-expiration handling without automatically resending a +prompt. Shared sign-in with the separate AC iOS app needs changes in both apps: +registered callbacks, compatible signed Keychain access, coordinated refresh, +and defined local versus shared sign-out. See [INTEGRATION.md](INTEGRATION.md). + +Audit existing transcript-sharing behavior before claiming local-only or private +use. Desktop has required disclosure/acceptance logic in +[`required-sharing.mjs`](../../easel/src/required-sharing.mjs); the native flow +must either enforce the same account-bound contract or adopt an explicitly +revised policy. Source publication, transcript sharing, preview runtime requests, +and inference traffic are separate disclosures. Guest access must not inherit +an account's consent silently. + +Keep daily and purchased balances distinct. Purchases need verified receipts, +idempotent crediting, pending/cancelled/refunded handling, restore behavior, and +foreground refresh. Test the real StoreKit/backend round trip before enabling +a purchase button in a release. Do not infer entitlement from a successful UI +or assume a vendor subscription pays for AC inference. + +Piece generation currently auto-publishes saves. Preserve that behavior until a +deliberate policy change, but make it understandable before generation. Add a +working publication control and make private drafts an explicit supported state +before describing them as private. Bind publish outcomes to the source revision +and account; keep a newer local edit distinct from the last public version. +Retry an unresolved upload by resolving its receipt, not by assuming failure. + +Completion: failed login, expired credentials, account switching, exhausted +balance, cancelled purchase, delayed crediting, lost upload response, and a new +edit during publication all preserve local work and show the correct owner and +revision. Share/Open/QR appear only for a verified public result. + +## Notebook, preview, source, and history + +Persist unsent text per thread, alongside source and transcript, with atomic +writes and a recoverable previous checkpoint. Make app restart, interruption, +thread switching, and account changes independent of losing a draft. Keep +provider state out of credentials and preserve publication ownership. + +The corner stays upper-right. Expanding should reuse the same running preview +where possible so simulation/audio state and keyboard focus survive. Define +touch and keyboard equivalents for desktop hover, fullscreen, hide/show, +reload, text selection, copy/paste, and font sizing. A canvas must not consume +typing intended for the composer. Theme the shell from the OS while leaving +artwork colors untouched. + +Port source editing and revision tools from `revisions.mjs`, `artifacts.mjs`, +`notebook-bindings.mjs`, and the desktop version UI. Distinguish draft source, +accepted revision, rendered output, published revision, and app build version. +Rollback restores recorded source, assets, and output without rerunning paid +generation. Preserve the last good preview while a newer revision fails. + +Add preview volume/mute and user-controlled Sound playback. Resolve revision +readiness from the runtime and displayed bytes, not merely an HTTP load event. +Bundle or cache the AC runtime before promising offline preview; today's local +source still loads a network runtime. Define cache invalidation and recovery +before enabling an offline mode. + +Completion: recover unsent text after restart; compare the preview and public +result to the correct revision; switch threads during interrupted work; resize +to the minimum window and use the phone keyboard without clipping the title, +composer, or preview controls. Check VoiceOver and reduced motion on-device. + +## Media and files + +| Lane | Integration contract | Acceptance evidence | +| --- | --- | --- | +| Picture | Reuse accepted layers and pending proposals. Native import/export and renderer must preserve bytes and provenance. Paid image jobs require their existing authorization/receipt contract. Publish only accepted PNG output through AC's painting pipeline. | Accept/discard, reopen, rollback, export; resolve uncertain publication without duplicating the painting. | +| Sound | Reuse score, deterministic rendering, WAV, analysis, and provenance. Port playback/waveform and shared-clock behavior; handle phone audio interruptions. | Matching render hash, explicit play/stop, no autoplay after refresh/rollback, synchronized loop test. | +| Paper | Run a supported TeX toolchain on an available host; show source and PDF. Preserve bibliography, consulted sources, embedded source bundle, and visual QA state. | Build, inspect rendered pages, record QA against that exact PDF hash, export matching sources; a compile alone never marks it ready. | +| Game Boy | Compile with the existing GBDK toolchain, preview the resulting ROM with the existing emulator, and adapt joypad/gamepad input. | Build and play a real ROM, export exact bytes, show when source is newer than the last successful build. | + +Use the contracts in [`easel/media`](../../easel/media/) rather than introducing +new artifact formats. Optional dependencies must have capability checks and +useful errors. Keep unsupported lanes unavailable until their outputs are real. +Sound, Paper, and Game Boy need an explicit publication implementation before +acquiring a public sharing URL; local export remains a separate action. + +Mac workspaces require user-granted directory access; iPhone needs document +picker/share-sheet import and export. Package source, accepted outputs, +manifest, revisions, and provenance together. Exclude credentials and transient +signed URLs. Validate imported paths and sizes; coordinate concurrent writers +instead of overwriting another window's work. Migration should retain a backup +and be repeatable without duplicating threads. + +## Windows, project apps, automation, and diagnostics + +Carry over desktop Studio behavior: each native window has its own session, +artifact, provider, and preview; close affects only that window; app restart +checkpoints all windows. Preserve project-app bundle identity and source when +porting the existing project-app prototype. Slab/prox status is optional host +integration, not a prerequisite for a functioning app. + +Expose a native state/action adapter so MCP, frame, and puppet can inspect the +same controls as a person. Prefer stable action IDs and explicit enabled state +over fragile coordinates. Bind every action to a specific app instance and +session, reject stale requests, and separate read-only state/capture from +generation, purchases, publishing, and account changes. No arbitrary injected +JavaScript or implicit approval through an automation message. Native automation +work exists separately in the working copy; it is not a release claim here. + +Diagnostics should report build identity, capabilities, event order, revision, +and bounded error metadata. Redact credentials, prompt/source content, and +private filesystem/host details by default. Diagnostic export is explicit; +do not add automatic analytics as a side effect of this port. Test frozen web +content, helper exit, disk-full writes, network loss, and app restart with +recoverable checkpoints. + +## Delivery order + +| Milestone | Work | Gate | +| --- | --- | --- | +| 1. Complete the notebook | Composer persistence, focus/accessibility, preview lifetime/audio, source/revision identity, reliable auth recovery | Real Mac and iPhone creation/recovery walkthrough; system appearance follows live OS changes | +| 2. Restore provider parity | Native execution helper, Claude/Codex login/readiness, actual model dropdowns, approvals/interruption and handoff | A real turn and stop/reconnect test for each provider; no silent fallback | +| 3. Connect devices | Pairing and background/reconnect semantics, if included in release scope | Physical-phone job lifecycle and revocation tests | +| 4. Bring over artifacts | Portable files, revision rollback, Picture/Sound first; Paper/Game Boy when their toolchains are connected | Output-specific evidence in the media table | +| 5. Ship the shared app | Consent/billing audit, multiwindow/migration, signed packaging, diagnostics, release metadata | Verified package/device installation, upgrade recovery, release-channel smoke tests | + +Guest hosted AI, shared AC-app credentials, offline runtime, and project-app +distribution can follow these milestones. Their costs, pairing scope, +publication defaults, and distribution method are product decisions, not +unimplemented settings to expose as if they worked. + +The development Mac app keeps `computer.aesthetic.aesel.native` separate from +the established `computer.aesthetic.easel` product. Do not silently replace the +installed Electron app or assume development Keychain data migrates to a signed +release. A source push or Lith deployment updates neither an installed binary +nor App Store review status. Native app packaging, a public binary/update feed, +and Apple submission are distinct delivery steps, each requiring its own +artifact and verification. diff --git a/apple/aesel/Resources/Assets.xcassets/provider-ac.imageset/Contents.json b/apple/aesel/Resources/Assets.xcassets/provider-ac.imageset/Contents.json new file mode 100644 index 0000000000..9cf6ca13c8 --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/provider-ac.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "provider-ac.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apple/aesel/Resources/Assets.xcassets/provider-ac.imageset/provider-ac.svg b/apple/aesel/Resources/Assets.xcassets/provider-ac.imageset/provider-ac.svg new file mode 100644 index 0000000000..f2d3465a85 --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/provider-ac.imageset/provider-ac.svg @@ -0,0 +1,3 @@ + + + diff --git a/apple/aesel/Resources/Assets.xcassets/provider-claude.imageset/Contents.json b/apple/aesel/Resources/Assets.xcassets/provider-claude.imageset/Contents.json new file mode 100644 index 0000000000..e96eff056e --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/provider-claude.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "provider-claude.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apple/aesel/Resources/Assets.xcassets/provider-claude.imageset/provider-claude.svg b/apple/aesel/Resources/Assets.xcassets/provider-claude.imageset/provider-claude.svg new file mode 100644 index 0000000000..496ce9b5e9 --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/provider-claude.imageset/provider-claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/apple/aesel/Resources/Assets.xcassets/provider-codex.imageset/Contents.json b/apple/aesel/Resources/Assets.xcassets/provider-codex.imageset/Contents.json new file mode 100644 index 0000000000..0f4733b18c --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/provider-codex.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "provider-codex.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apple/aesel/Resources/Assets.xcassets/provider-codex.imageset/provider-codex.svg b/apple/aesel/Resources/Assets.xcassets/provider-codex.imageset/provider-codex.svg new file mode 100644 index 0000000000..f03c011f1d --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/provider-codex.imageset/provider-codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/apple/aesel/Sources/AeselApp.swift b/apple/aesel/Sources/AeselApp.swift index e985142975..1b899be6a7 100644 --- a/apple/aesel/Sources/AeselApp.swift +++ b/apple/aesel/Sources/AeselApp.swift @@ -8,15 +8,32 @@ struct AeselApp: App { @Environment(\.scenePhase) private var scenePhase init() { + ApplePlatform.registerFonts() let session = Session() _session = State(initialValue: session) _host = State(initialValue: SessionHost(session: session, store: SessionStore())) } var body: some Scene { - WindowGroup { + #if os(macOS) + Window("aesel", id: "workspace") { + workspace.frame(minWidth: 360, minHeight: 420) + } + .defaultSize(width: 840, height: 680) + .commands { + CommandGroup(replacing: .newItem) { + Button("New Piece") { host.newSession(medium: "piece") } + .keyboardShortcut("n") + .disabled(session.busy) + } + } + #else + WindowGroup { workspace } + #endif + } + + private var workspace: some View { ContentView(session: session, host: host) - .preferredColorScheme(.dark) .onChange(of: scenePhase) { _, phase in if phase != .active { host.save() } else if started { host.refreshCredits() } @@ -38,9 +55,10 @@ struct AeselApp: App { session.purchasedDollars = 1.41 session.append(.you, "Make a little orange circle.") session.append(.ac, "The **orange** circle follows your pointer.\n\nTry a radius of `24` or a blue background.\n\n```js\nconst radius = 24;\nwipe(\"blue\");\n```\n\n[Open Aesthetic Computer](https://aesthetic.computer)") + session.previewURL = nil if ProcessInfo.processInfo.environment["AESEL_PREVIEW_PIECE"] == "1" { - session.previewURL = URL(string: "https://aesthetic.computer/blank") - session.source = "wipe(\"orange\");" + session.previewURL = Session.draftPreviewURL + session.source = "export function paint({wipe}) { wipe(\"orange\"); }" } if ProcessInfo.processInfo.environment["AESEL_PREVIEW_BUSY"] == "1" { session.append(.you, "Now make it bounce.") @@ -53,7 +71,6 @@ struct AeselApp: App { #endif host.start(hostURL: Self.hostURL) } - } } /// Release sessions load immutable app resources. Developers can explicitly diff --git a/apple/aesel/Sources/AeselButtonStyle.swift b/apple/aesel/Sources/AeselButtonStyle.swift new file mode 100644 index 0000000000..4b53242e11 --- /dev/null +++ b/apple/aesel/Sources/AeselButtonStyle.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// Plain artwork controls still communicate that they are buttons. +struct AeselButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var enabled + func makeBody(configuration: Configuration) -> some View { + configuration.label + .contentShape(Rectangle()) + .opacity(enabled ? (configuration.isPressed ? 0.72 : 1) : 0.4) + .modifier(AeselButtonPointer(enabled: enabled)) + } +} + +struct AeselButtonPointer: ViewModifier { + let enabled: Bool + @ViewBuilder func body(content: Content) -> some View { + #if os(macOS) + if #available(macOS 15, *) { + content.pointerStyle(enabled ? .link : .default) + } else { + content.background(AeselCursorRegion(enabled: enabled)) + } + #else + content.hoverEffect(.highlight, isEnabled: enabled) + #endif + } +} + +#if os(macOS) +private struct AeselCursorRegion: NSViewRepresentable { + let enabled: Bool + final class CursorView: NSView { + var enabled = true + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override func resetCursorRects() { + if enabled { addCursorRect(visibleRect, cursor: .pointingHand) } + } + override func viewDidMoveToWindow() { window?.invalidateCursorRects(for: self) } + } + func makeNSView(context: Context) -> CursorView { CursorView() } + func updateNSView(_ view: CursorView, context: Context) { + view.enabled = enabled + view.window?.invalidateCursorRects(for: view) + } +} +#endif diff --git a/apple/aesel/Sources/AeselHomeView.swift b/apple/aesel/Sources/AeselHomeView.swift index ca2d3a8fab..8dfc7171d6 100644 --- a/apple/aesel/Sources/AeselHomeView.swift +++ b/apple/aesel/Sources/AeselHomeView.swift @@ -49,7 +49,7 @@ struct AeselHomeView: View { Text(medium.name) Spacer(minLength: 0) if medium.id != "piece" { - Text("desktop").font(Paint.font(17)).foregroundStyle(paint.dim) + Text("coming later").font(Paint.font(17)).foregroundStyle(paint.dim) } } .padding(.horizontal, 12) @@ -58,7 +58,7 @@ struct AeselHomeView: View { .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") + .accessibilityLabel(medium.id == "piece" ? "New piece" : medium.name + ", not available in this app yet") } } } else { @@ -103,7 +103,7 @@ struct AeselHomeView: View { .padding(.bottom, 10) } - .buttonStyle(.plain) + .buttonStyle(AeselButtonStyle()) .foregroundStyle(paint.ink) .background { AeselCloth().ignoresSafeArea() } } diff --git a/apple/aesel/Sources/AeselNotebook.swift b/apple/aesel/Sources/AeselNotebook.swift index 789ce02af7..aff3101457 100644 --- a/apple/aesel/Sources/AeselNotebook.swift +++ b/apple/aesel/Sources/AeselNotebook.swift @@ -2,7 +2,7 @@ import SwiftUI import WebKit /// Desktop rich replies, packaged locally; the agent host remains separate. -struct AeselNotebook: UIViewRepresentable { +struct AeselNotebook: AeselWebViewRepresentable { let session: Session var paint = Paint.base @Binding var height: CGFloat @@ -45,21 +45,18 @@ struct AeselNotebook: UIViewRepresentable { } func makeCoordinator() -> Coordinator { Coordinator(openLink: openLink) } - func makeUIView(context: Context) -> WKWebView { + func makeWebView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() config.setURLSchemeHandler(context.coordinator.scheme, forURLScheme: "aesel-bundle") config.userContentController.add(context.coordinator, name: "notebook") let view = WKWebView(frame: .zero, configuration: config) view.navigationDelegate = context.coordinator - view.isOpaque = false - view.backgroundColor = .clear - view.scrollView.backgroundColor = .clear - view.scrollView.isScrollEnabled = false - view.scrollView.contentInsetAdjustmentBehavior = .never + ApplePlatform.configureEmbeddedView(view) view.load(URLRequest(url: URL(string: "aesel-bundle://app/easel/phone/notebook.html")!)) return view } - func updateUIView(_ view: WKWebView, context: Context) { + func updateWebView(_ view: WKWebView, context: Context) { + ApplePlatform.setAppearance(view, colorScheme: paint.css["colorScheme"] == "light" ? .light : .dark) var entries = session.entries.filter { $0.kind != .edit }.map { entry in ["id": entry.id.uuidString, "kind": entry.kind == .you ? "user" : entry.kind == .ac ? "assistant" : entry.kind == .bad ? "error" : "notice", "text": entry.text] } @@ -84,7 +81,7 @@ struct AeselNotebook: UIViewRepresentable { context.coordinator.render(view) } } - static func dismantleUIView(_ view: WKWebView, coordinator: Coordinator) { + static func dismantleWebView(_ view: WKWebView, coordinator: Coordinator) { view.configuration.userContentController.removeScriptMessageHandler(forName: "notebook") } } diff --git a/apple/aesel/Sources/AeselPieceView.swift b/apple/aesel/Sources/AeselPieceView.swift index f94eea2188..06f3fac5a2 100644 --- a/apple/aesel/Sources/AeselPieceView.swift +++ b/apple/aesel/Sources/AeselPieceView.swift @@ -19,7 +19,7 @@ struct PieceView: View { 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) + .font(Paint.font(18)).foregroundStyle(paint.you).buttonStyle(AeselButtonStyle()) }.padding().frame(maxWidth: .infinity, maxHeight: .infinity) .background(paint.deep) } @@ -27,9 +27,10 @@ struct PieceView: View { } } -private struct PieceWebView: UIViewRepresentable { +private struct PieceWebView: AeselWebViewRepresentable { let url: URL let source: String + @Environment(\.colorScheme) private var colorScheme @Binding var failure: String? final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { @@ -61,10 +62,12 @@ private struct PieceWebView: UIViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator(failure: $failure) } - func makeUIView(context: Context) -> WKWebView { + func makeWebView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.userContentController.add(context.coordinator, name: "previewFailure") + #if os(iOS) configuration.allowsInlineMediaPlayback = true + #endif configuration.mediaTypesRequiringUserActionForPlayback = [] configuration.userContentController.addUserScript(WKUserScript(source: """ (() => { @@ -107,15 +110,16 @@ private struct PieceWebView: UIViewRepresentable { """, 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 + ApplePlatform.configureEmbeddedView(view) return view } - func updateUIView(_ view: WKWebView, context: Context) { + static func dismantleWebView(_ view: WKWebView, coordinator: Coordinator) { + view.configuration.userContentController.removeScriptMessageHandler(forName: "previewFailure") + } + + func updateWebView(_ view: WKWebView, context: Context) { + ApplePlatform.setAppearance(view, colorScheme: colorScheme) context.coordinator.source = url.path.hasPrefix("/@") ? "" : source if context.coordinator.requestedURL != url { context.coordinator.requestedURL = url diff --git a/apple/aesel/Sources/AeselProviderPicker.swift b/apple/aesel/Sources/AeselProviderPicker.swift new file mode 100644 index 0000000000..bf5eec361d --- /dev/null +++ b/apple/aesel/Sources/AeselProviderPicker.swift @@ -0,0 +1,73 @@ +import SwiftUI + +/// Keep the desktop's provider identity visible while the native CLI bridge is +/// being ported. Unavailable engines must never silently fall back to AC. +struct AeselProviderPicker: View { + let busy: Bool + @Environment(\.paint) private var paint + @State private var expanded = false + @State private var palURL = URL(string: "https://pals.aesthetic.computer/random.webp?menu=\(UUID().uuidString)")! + + private func mark(_ provider: String) -> some View { + Group { + if provider == "ac" { + AsyncImage(url: palURL) { image in + image.resizable().scaledToFit() + } placeholder: { + Image("provider-ac").resizable().renderingMode(.template).scaledToFit().foregroundStyle(paint.ac) + } + } else { + Image("provider-\(provider)").resizable() + .renderingMode(provider == "codex" ? .template : .original).scaledToFit() + } + } + .frame(width: 25, height: 25).accessibilityHidden(true) + } + + var body: some View { + Button { expanded.toggle() } label: { + HStack(spacing: 9) { + mark("ac") + Text("AC") + Spacer() + Image(systemName: "chevron.down").font(.system(size: 11)) + } + .frame(maxWidth: .infinity).frame(height: 36) + .contentShape(Rectangle()) + } + .frame(height: 36) + .disabled(busy) + .accessibilityLabel("Provider, AC") + .accessibilityValue(expanded ? "Expanded" : "Collapsed") + .popover(isPresented: $expanded, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 4) { + option("ac", title: "AC", available: true) + option("claude", title: "Claude", available: false) + option("codex", title: "Codex", available: false) + } + .padding(8).frame(width: 285) + .font(Paint.font(15)).foregroundStyle(paint.ink) + .background(paint.bg) + .buttonStyle(AeselButtonStyle()) + .presentationCompactAdaptation(.popover) + } + } + + private func option(_ provider: String, title: String, available: Bool) -> some View { + Button { expanded = false } label: { + HStack(spacing: 9) { + mark(provider) + Text(title) + Spacer() + if available { + Image(systemName: "checkmark") + } else { + Text("Needs desktop bridge").font(Paint.font(11)) + } + } + .padding(6).frame(maxWidth: .infinity, minHeight: 36) + .contentShape(Rectangle()) + } + .disabled(!available) + } +} diff --git a/apple/aesel/Sources/AeselScene.swift b/apple/aesel/Sources/AeselScene.swift index fd647eff08..1182b396fb 100644 --- a/apple/aesel/Sources/AeselScene.swift +++ b/apple/aesel/Sources/AeselScene.swift @@ -1,5 +1,5 @@ import SwiftUI -import UIKit + // The desktop's palette, typefaces and mascot, shared without redraws. // Title lettering is the desktop's Comic Sans MS Bold. iOS has no Comic Sans, @@ -71,17 +71,21 @@ struct Paint: Equatable { } } - static func slab(status: String, busy: Bool, failed: Bool) -> Paint { + static func slab(status: String, busy: Bool, failed: Bool, colorScheme: ColorScheme = .dark) -> Paint { let name = slabStatus(status, busy: busy, failed: failed) - guard let palette = palettes[name] ?? palettes["blank"] else { return base } + let palette = palettes[name] ?? palettes["blank"] ?? Palette(background: [70,50,100], foreground: [255,255,255], bold: [255,255,255], cursor: [255,120,178]) var paint = base - let bg = palette.background, fg = palette.foreground + // Prompt's purple night / legal-pad daylight; status tints stay subtle. + let dark = colorScheme == .dark + let paper: RGB = dark ? [70,50,100] : [252,247,197] + let bg = mix(paper, palette.cursor, name == "blank" ? 0 : 0.04) + let fg: RGB = dark ? [255,255,255] : [40,30,90] paint.bg = color(bg) - paint.deep = color(mix(bg, [0, 0, 0], 0.45)) + paint.deep = color(mix(bg, dark ? [0,0,0] : [255,255,255], 0.25)) paint.ink = color(fg) paint.rule = color(fg).opacity(0.12) - paint.accent = color(palette.cursor) - paint.you = color(palette.cursor) + paint.accent = color(readable(palette.cursor, on: bg)) + paint.you = paint.accent paint.dim = color(readable([170, 150, 205], on: bg)) paint.ac = color(readable([255, 100, 255], on: bg)) paint.edit = color(readable([0, 255, 0], on: bg)) @@ -89,9 +93,12 @@ struct Paint: Equatable { let frame = mix(bg, palette.cursor, 0.25) paint.frame = color(frame) paint.frameEdge = color(mix(frame, [255, 255, 255], 0.65)) - let userInk = mix(fg, [193, 43, 135], 0.35) + let userInk = readable(mix(fg, [193, 43, 135], 0.35), on: bg) paint.userInk = color(userInk) - paint.css = ["background": hex(bg), "foreground": hex(fg), "userInk": hex(userInk)] + paint.css = ["background": hex(bg), "foreground": hex(fg), "userInk": hex(userInk), + "error": hex(readable([255,90,90], on: bg)), + "number": hex(readable(dark ? [255,209,124] : [143,61,15], on: bg)), + "colorScheme": dark ? "dark" : "light"] return paint } @@ -137,32 +144,30 @@ extension EnvironmentValues { } /// The desktop's native Prox lettering (easel/desktop/native/credit-label.swift), -/// drawn with UIKit: cyan, purple and pink echoes under a thin-outlined face +/// drawn with the platform graphics context: cyan, purple and pink echoes under a thin-outlined face /// with a hard pink shadow, one cached image per letter so the notebook can /// tilt and sway each one on its own. enum Rock { static let inset: CGFloat = 9 - private static var cache: [String: (UIImage, CGSize)] = [:] + private static var cache: [String: (AeselImage, CGSize)] = [:] - static func font(_ size: CGFloat) -> UIFont { + static func font(_ size: CGFloat) -> AeselFont { for name in ["ComicRelief-Bold", "ComicSansMS-Bold", "ChalkboardSE-Bold"] { - if let font = UIFont(name: name, size: size) { return font } + if let font = AeselFont(name: name, size: size) { return font } } return .systemFont(ofSize: size, weight: .heavy) } /// A letter's image plus its advance box; the image overhangs the box by /// `inset` on every side so the echoes and shadow are not clipped. - static func glyph(_ text: String, size: CGFloat, face: UIColor = .white) -> (image: UIImage, advance: CGSize) { + static func glyph(_ text: String, size: CGFloat, face: AeselColor = .white) -> (image: AeselImage, advance: CGSize) { let key = "\(text)|\(size)|\(face)" if let hit = cache[key] { return hit } let font = font(size) let advance = (text as NSString).size(withAttributes: [.font: font]) let bounds = CGSize(width: ceil(advance.width + inset * 2), height: ceil(advance.height + inset * 2)) - let format = UIGraphicsImageRendererFormat.default() - format.opaque = false - let image = UIGraphicsImageRenderer(size: bounds, format: format).image { _ in - let echoes: [(UIColor, CGFloat, CGFloat)] = [ + let image = ApplePlatform.image(size: bounds) { + let echoes: [(AeselColor, CGFloat, CGFloat)] = [ (.systemCyan.withAlphaComponent(0.28), 4.5, 3), (.systemPurple.withAlphaComponent(0.40), 3, 2), (.systemPink.withAlphaComponent(0.65), 1.5, 1.5), @@ -172,13 +177,13 @@ enum Rock { .draw(at: CGPoint(x: inset + x, y: inset + y)) } let shadow = NSShadow() - shadow.shadowColor = UIColor.systemPink.withAlphaComponent(0.5) + shadow.shadowColor = AeselColor.systemPink.withAlphaComponent(0.5) shadow.shadowBlurRadius = 0 shadow.shadowOffset = CGSize(width: 1.5, height: 1.5) NSAttributedString(string: text, attributes: [ .font: font, .foregroundColor: face.withAlphaComponent(0.94), - .strokeColor: UIColor(white: 0.08, alpha: 1), + .strokeColor: AeselColor(white: 0.08, alpha: 1), .strokeWidth: -3.5, .shadow: shadow, ]).draw(at: CGPoint(x: inset, y: inset)) @@ -202,7 +207,7 @@ extension Color { } } -extension UIColor { +extension AeselColor { // "#rrggbb" from the account palette, or nil. convenience init?(hex: String) { guard hex.count == 7, let rgb = UInt32(hex.dropFirst(), radix: 16) else { return nil } @@ -240,7 +245,7 @@ struct AeselWordmark: View { var text = "aesel" @Environment(\.paint) private var paint var body: some View { - let colors: [Color] = [.orange, paint.ac, paint.edit, paint.dim, paint.you] + let colors: [Color] = [paint.accent, paint.ac, paint.edit, paint.dim, paint.you] HStack(spacing: 0) { ForEach(Array(text.enumerated()), id: \.offset) { index, letter in Text(String(letter)).foregroundStyle(colors[index % colors.count]) @@ -260,6 +265,7 @@ struct AeselTitle: View { let text: String var colors: [String] = [] var size: CGFloat = 20 + var maximumWidth: CGFloat? = nil private static func fnv(_ text: String) -> UInt32 { var hash: UInt32 = 2166136261 @@ -268,13 +274,16 @@ struct AeselTitle: View { } // "@handle" letters wear the account palette; the piece name stays white. - private func face(_ index: Int, handle: Int) -> UIColor { - guard index < handle, colors.indices.contains(index), let color = UIColor(hex: colors[index]) else { return .white } + private func face(_ index: Int, handle: Int) -> AeselColor { + guard index < handle, colors.indices.contains(index), let color = AeselColor(hex: colors[index]) else { return .white } return color } var body: some View { let handle = text.hasPrefix("@") ? text.prefix(while: { $0 != "/" }).count : 0 + // Include image overhang and sway when fitting a one-line title. + let width = text.reduce(CGFloat(24)) { $0 + (String($1) as NSString).size(withAttributes: [.font: Rock.font(size)]).width } + let scale = maximumWidth.map { min(1, max(0, $0) / width) } ?? 1 HStack(alignment: .top, spacing: 0) { ForEach(Array(text.enumerated()), id: \.offset) { index, letter in let hash = Self.fnv("rock\(index)\(text)") @@ -286,6 +295,10 @@ struct AeselTitle: View { .offset(y: -(CGFloat(hash % 5) / 2 - 1)) } } + .padding(.horizontal, 12) + .fixedSize() + .scaleEffect(scale, anchor: .leading) + .frame(width: width * scale, alignment: .leading) .accessibilityElement(children: .ignore) .accessibilityLabel(text) } @@ -296,7 +309,7 @@ struct AeselTitle: View { private struct RockLetter: View { let text: String let size: CGFloat - let face: UIColor + let face: AeselColor let beat: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var swaying = false @@ -306,7 +319,7 @@ private struct RockLetter: View { Color.clear .frame(width: glyph.advance.width, height: glyph.advance.height) .overlay(alignment: .topLeading) { - Image(uiImage: glyph.image).offset(x: -Rock.inset, y: -Rock.inset) + Image(aeselImage: glyph.image).offset(x: -Rock.inset, y: -Rock.inset) } .rotationEffect(.degrees(swaying ? 0.8 : -1.2)) .offset(y: swaying ? 0.8 : -1.2) @@ -323,12 +336,12 @@ struct AeselDonkey: View { var busy: Bool var failed: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion - private static let frames: [UIImage] = { + private static let frames: [AeselImage] = { guard let url = Bundle.main.url(forResource: "donkey-pencil-run-v2", withExtension: "png", subdirectory: "Session/easel/desktop/assets"), - let sheet = UIImage(contentsOfFile: url.path)?.cgImage else { return [] } + let sheet = ApplePlatform.cgImage(at: url) else { return [] } let cell = sheet.width / 4 return (0..<8).compactMap { index in - sheet.cropping(to: CGRect(x: (index % 4) * cell, y: (index / 4) * sheet.height / 2 + 64, width: cell, height: 416)).map { UIImage(cgImage: $0) } + sheet.cropping(to: CGRect(x: (index % 4) * cell, y: (index / 4) * sheet.height / 2 + 64, width: cell, height: 416)).map { ApplePlatform.image(cgImage: $0) } } }() @@ -336,7 +349,7 @@ struct AeselDonkey: View { TimelineView(.animation(minimumInterval: 0.09, paused: reduceMotion || !busy)) { timeline in let index = busy && !reduceMotion ? Int(timeline.date.timeIntervalSinceReferenceDate / 0.09) % 8 : 0 if Self.frames.indices.contains(index) { - Image(uiImage: Self.frames[index]) + Image(aeselImage: Self.frames[index]) .resizable() .scaledToFit() .opacity(failed ? 0.5 : 1) diff --git a/apple/aesel/Sources/ApplePlatform.swift b/apple/aesel/Sources/ApplePlatform.swift new file mode 100644 index 0000000000..9c975d6ec6 --- /dev/null +++ b/apple/aesel/Sources/ApplePlatform.swift @@ -0,0 +1,155 @@ +import SwiftUI +import WebKit +import CoreText + +#if os(macOS) +import AppKit +typealias AeselImage = NSImage +typealias AeselColor = NSColor +typealias AeselFont = NSFont + +/// Keep WebKit setup and coordinators shared; only the SwiftUI adapter differs. +protocol AeselWebViewRepresentable: NSViewRepresentable where NSViewType == WKWebView { + func makeWebView(context: Context) -> WKWebView + func updateWebView(_ view: WKWebView, context: Context) + static func dismantleWebView(_ view: WKWebView, coordinator: Coordinator) +} +extension AeselWebViewRepresentable { + func makeNSView(context: Context) -> WKWebView { makeWebView(context: context) } + func updateNSView(_ view: WKWebView, context: Context) { updateWebView(view, context: context) } + static func dismantleNSView(_ view: WKWebView, coordinator: Coordinator) { dismantleWebView(view, coordinator: coordinator) } + static func dismantleWebView(_ view: WKWebView, coordinator: Coordinator) {} +} +#else +import UIKit +typealias AeselImage = UIImage +typealias AeselColor = UIColor +typealias AeselFont = UIFont + +protocol AeselWebViewRepresentable: UIViewRepresentable where UIViewType == WKWebView { + func makeWebView(context: Context) -> WKWebView + func updateWebView(_ view: WKWebView, context: Context) + static func dismantleWebView(_ view: WKWebView, coordinator: Coordinator) +} +extension AeselWebViewRepresentable { + func makeUIView(context: Context) -> WKWebView { makeWebView(context: context) } + func updateUIView(_ view: WKWebView, context: Context) { updateWebView(view, context: context) } + static func dismantleUIView(_ view: WKWebView, coordinator: Coordinator) { dismantleWebView(view, coordinator: coordinator) } + static func dismantleWebView(_ view: WKWebView, coordinator: Coordinator) {} +} +#endif + +extension Image { + init(aeselImage: AeselImage) { + #if os(macOS) + self.init(nsImage: aeselImage) + #else + self.init(uiImage: aeselImage) + #endif + } +} + +extension WKWebView { + var aeselOpacity: CGFloat { + get { + #if os(macOS) + alphaValue + #else + alpha + #endif + } + set { + #if os(macOS) + alphaValue = newValue + #else + alpha = newValue + #endif + } + } +} + +enum ApplePlatform { + static func setAppearance(_ view: WKWebView, colorScheme: ColorScheme) { + #if os(macOS) + view.appearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua) + #else + view.overrideUserInterfaceStyle = colorScheme == .dark ? .dark : .light + #endif + } + static func registerFonts() { + #if os(macOS) + if let url = Bundle.main.url(forResource: "ComicRelief-Bold", withExtension: "ttf") { + CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) + } + #endif + } + + static func image(size: CGSize, drawing: @escaping () -> Void) -> AeselImage { + #if os(macOS) + return NSImage(size: size, flipped: true) { _ in drawing(); return true } + #else + let format = UIGraphicsImageRendererFormat.default() + format.opaque = false + return UIGraphicsImageRenderer(size: size, format: format).image { _ in drawing() } + #endif + } + + static func cgImage(at url: URL) -> CGImage? { + #if os(macOS) + return NSImage(contentsOf: url)?.cgImage(forProposedRect: nil, context: nil, hints: nil) + #else + return UIImage(contentsOfFile: url.path)?.cgImage + #endif + } + + static func image(cgImage: CGImage) -> AeselImage { + #if os(macOS) + return NSImage(cgImage: cgImage, size: CGSize(width: cgImage.width, height: cgImage.height)) + #else + return UIImage(cgImage: cgImage) + #endif + } + + static func configureEmbeddedView(_ view: WKWebView) { + view.underPageBackgroundColor = .clear + #if os(iOS) + view.isOpaque = false + view.backgroundColor = .clear + view.scrollView.backgroundColor = .clear + view.scrollView.isScrollEnabled = false + view.scrollView.contentInsetAdjustmentBehavior = .never + view.isMultipleTouchEnabled = true + #endif + } +} + +extension View { + @ViewBuilder func aeselKeyboardScrolling() -> some View { + #if os(iOS) + scrollDismissesKeyboard(.interactively) + #else + self + #endif + } + @ViewBuilder func aeselSendLabel() -> some View { + #if os(iOS) + submitLabel(.send) + #else + self + #endif + } + @ViewBuilder func aeselHelpSheet() -> some View { + #if os(iOS) + presentationDetents([.medium]) + #else + frame(minWidth: 420, minHeight: 340) + #endif + } + @ViewBuilder func aeselSignInSheet() -> some View { + #if os(macOS) + frame(minWidth: 500, minHeight: 600) + #else + self + #endif + } +} diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index d552ef3216..a92f88bf9e 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -18,6 +18,7 @@ struct ContentView: View { @State private var attended = true @FocusState private var writing: Bool @Environment(\.openURL) private var openURL + @Environment(\.colorScheme) private var colorScheme /// One ruled row of the sheet, as the desktop's --notebook-line-height. private let row: CGFloat = 24 @@ -33,7 +34,7 @@ struct ContentView: View { /// Slab's palette for what the session is doing right now. private var paint: Paint { let status = attended && session.status == "ready" ? "blank" : session.status - return Paint.slab(status: status, busy: session.busy, failed: session.health == .failed || session.fatal != nil) + return Paint.slab(status: status, busy: session.busy, failed: session.health == .failed || session.fatal != nil, colorScheme: colorScheme) } var body: some View { @@ -51,6 +52,7 @@ struct ContentView: View { } .overlay { if showSettings { settingsPane } } .font(Paint.font()) + .buttonStyle(AeselButtonStyle()) .foregroundStyle(paint.ink) .background { AeselCloth().ignoresSafeArea() } .background(HostCarrier(host: host).frame(width: 0, height: 0)) @@ -98,6 +100,9 @@ struct ContentView: View { } } } + .buttonStyle(AeselButtonStyle()) + .environment(\.paint, paint) + .aeselSignInSheet() } } @@ -115,6 +120,11 @@ struct ContentView: View { Color.clear.frame(height: previewBlockHeight) if previewVisible { previewBox } } + if !session.signedIn { + Button("Sign in to AC") { host.signIn() } + .buttonStyle(AeselButtonStyle()).foregroundStyle(paint.ink) + .frame(height: row).padding(.horizontal, 10) + } AeselNotebook(session: session, paint: paint, height: $notebookHeight) { openURL($0) } .frame(height: max(row, notebookHeight)) if session.fatal != nil { @@ -127,10 +137,10 @@ struct ContentView: View { .frame(minHeight: geometry.size.height, alignment: .top) .background(alignment: .top) { AeselRuling(spacing: row) } } - .scrollDismissesKeyboard(.interactively) + .aeselKeyboardScrolling() .overlay(alignment: .topLeading) { let clear = previewVisible ? previewSize.width + 8 + 2 + previewInset + 16 : 0 - title.frame(maxWidth: max(60, geometry.size.width - 10 - clear), alignment: .leading).clipped() + title(availableWidth: max(0, geometry.size.width - 10 - clear)) } .onChange(of: notebookHeight) { if session.busy || writing { proxy.scrollTo("prompt", anchor: .bottom) } } .onChange(of: writing) { if writing { withAnimation { proxy.scrollTo("prompt", anchor: .bottom) } } } @@ -139,15 +149,18 @@ struct ContentView: View { } } - private var title: some View { - Button { - if let url = session.shareURL { openURL(url) } - } label: { - AeselTitle(text: session.route.isEmpty ? "new piece" : session.route, colors: session.handleColors) + private func title(availableWidth: CGFloat) -> some View { + Group { + if let url = session.shareURL { + Button { openURL(url) } label: { + AeselTitle(text: session.route.isEmpty ? "new piece" : session.route, colors: session.handleColors, maximumWidth: availableWidth) + } + .buttonStyle(AeselButtonStyle()) + .accessibilityLabel("Open piece in browser") + } else { + AeselTitle(text: session.route.isEmpty ? "new piece" : session.route, colors: session.handleColors, maximumWidth: availableWidth) + } } - .buttonStyle(.plain) - .disabled(session.shareURL == nil) - .accessibilityLabel("Open piece in browser") .frame(height: row) .padding(.leading, 10) } @@ -168,7 +181,7 @@ struct ContentView: View { .overlay { RoundedRectangle(cornerRadius: 4).stroke(Color.white.opacity(0.4), lineWidth: 1) } .clipShape(RoundedRectangle(cornerRadius: 4)) } - .buttonStyle(.plain).foregroundStyle(.white).padding(5) + .buttonStyle(AeselButtonStyle()).foregroundStyle(.white).padding(5) .accessibilityLabel("Expand piece") } .padding(4) @@ -190,7 +203,7 @@ struct ContentView: View { Image(systemName: "arrow.down.right.and.arrow.up.left") .font(.system(size: 14)).padding(10).background(paint.deep.opacity(0.85)) } - .buttonStyle(.plain).padding(5) + .buttonStyle(AeselButtonStyle()).padding(5) .accessibilityLabel("Return to aesel") } } @@ -206,7 +219,7 @@ struct ContentView: View { TextField("make something…", text: $draft, axis: .vertical) .font(Paint.font(16)).foregroundStyle(paint.userInk).tint(paint.userInk) .lineLimit(1...6).textFieldStyle(.plain) - .focused($writing).submitLabel(.send) + .focused($writing).aeselSendLabel() .onSubmit { send() }.autocorrectionDisabled() .frame(minHeight: row) if session.busy { @@ -221,7 +234,7 @@ struct ContentView: View { .accessibilityLabel("Send") } } - .buttonStyle(.plain) + .buttonStyle(AeselButtonStyle()) .padding(.leading, 10).padding(.trailing, 100).padding(.bottom, 8) } @@ -231,7 +244,7 @@ struct ContentView: View { Button { openSettings() } label: { AeselTitle(text: "v\(appVersion)", size: 16).padding(9) } - .buttonStyle(.plain) + .buttonStyle(AeselButtonStyle()) .padding(.trailing, 1) .accessibilityLabel("Version \(appVersion). Settings") } @@ -250,17 +263,13 @@ struct ContentView: View { if let action { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: action) } } - private var modelName: String { - let served = session.reportedModel.isEmpty ? session.model : session.reportedModel - return served.isEmpty ? "choosing…" : served - } - private func usd(_ value: Double) -> String { value.formatted(.currency(code: "USD")) } /// The desktop's #provider-menu: a centred card over the blurred sheet, /// 15pt Helvetica, hairline sections, with the account, the automatic /// model, braincells and the way to buy more. private var settingsPane: some View { + GeometryReader { geometry in ZStack { Color.black.opacity(0.4).ignoresSafeArea() .onTapGesture { closeSettings() } @@ -281,15 +290,24 @@ struct ContentView: View { .padding(.bottom, 6) settingsField("Provider") { - HStack(spacing: 9) { - Image("braincell").resizable().scaledToFit().frame(width: 25, height: 25) - Text("Braincells") - } + AeselProviderPicker(busy: session.busy) } - settingsField("Automatic model") { - Text(modelName).lineLimit(1).truncationMode(.middle) + settingsField("Model") { + Menu { + Button("Automatic") {} + } label: { + HStack { + Text("Automatic") + Spacer() + Image(systemName: "chevron.down").font(.system(size: 11)) + } + .frame(maxWidth: .infinity).frame(height: 36) + } + .menuStyle(.borderlessButton).menuIndicator(.hidden) + .frame(height: 36) + .disabled(true) } - .accessibilityLabel("Automatic model, \(modelName)") + .accessibilityLabel("Model, managed automatically by AC") Text(["Remote inference", session.status].joined(separator: " · ")) .padding(.top, 14) @@ -299,7 +317,7 @@ struct ContentView: View { } if session.signedIn { - HStack(spacing: 9) { + Button { host.refreshCredits() } label: { HStack(spacing: 9) { Image("braincell").resizable().scaledToFit().frame(width: 25, height: 25) if let dollars = session.braincellDollars, let balance = session.braincells { Text("\(usd(dollars)) · \(balance.formatted(.number.precision(.fractionLength(0)))) braincells") @@ -308,9 +326,8 @@ struct ContentView: View { } else { Text(session.creditsStatus) } - } + } } .padding(.top, 14) - .onTapGesture { host.refreshCredits() } .accessibilityHint("Refresh balance") if let free = session.freeDollars, let paid = session.purchasedDollars { Text("\(usd(free)) daily · \(usd(paid)) purchased") @@ -338,7 +355,7 @@ struct ContentView: View { .padding(EdgeInsets(top: 20, leading: 24, bottom: 20, trailing: 24)) } .frame(maxWidth: 560) - .frame(maxHeight: UIScreen.main.bounds.height * 0.85) + .frame(maxHeight: geometry.size.height * 0.85) .fixedSize(horizontal: false, vertical: true) .background(paint.bg) .clipShape(RoundedRectangle(cornerRadius: 16)) @@ -347,8 +364,9 @@ struct ContentView: View { .padding(24) } .font(Paint.font(15)) - .buttonStyle(.plain) + .buttonStyle(AeselButtonStyle()) .transition(.opacity) + } } /// The App Store purchase: one big green button with Apple's price. @@ -415,13 +433,12 @@ struct ContentView: View { .font(Paint.font(22)).padding(24) .frame(maxWidth: .infinity, alignment: .leading) .background { AeselCloth().ignoresSafeArea() } - .presentationDetents([.medium]) + .aeselHelpSheet() } 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() @@ -431,22 +448,28 @@ struct ContentView: View { case "/settings": openSettings() case "/buy": openSettings(); Task { await braincells.buy() } case "/help": showHelp = true - default: host.ask(text) + default: + guard session.signedIn else { host.signIn(); return } + host.ask(text) } + draft = "" } + + } /// Puts the hidden session webview in the hierarchy without drawing it. -private struct HostCarrier: UIViewRepresentable { +private struct HostCarrier: AeselWebViewRepresentable { let host: SessionHost - func makeUIView(context: Context) -> WKWebView { host.attachable } - func updateUIView(_ view: WKWebView, context: Context) {} + func makeWebView(context: Context) -> WKWebView { host.attachable } + func updateWebView(_ view: WKWebView, context: Context) {} } -private struct SignInCarrier: UIViewRepresentable { +private struct SignInCarrier: AeselWebViewRepresentable { let host: SessionHost - func makeUIView(context: Context) -> WKWebView { host.signInView } - func updateUIView(_ view: WKWebView, context: Context) {} + @Environment(\.colorScheme) private var colorScheme + func makeWebView(context: Context) -> WKWebView { host.signInView } + func updateWebView(_ view: WKWebView, context: Context) { ApplePlatform.setAppearance(view, colorScheme: colorScheme) } } diff --git a/apple/aesel/Sources/Session.swift b/apple/aesel/Sources/Session.swift index c13249a35c..dea2ea50a0 100644 --- a/apple/aesel/Sources/Session.swift +++ b/apple/aesel/Sources/Session.swift @@ -46,6 +46,7 @@ struct SessionSummary: Identifiable { /// something a view can draw. @Observable final class Session { + static let draftPreviewURL = URL(string: "https://aesthetic.computer/wipe?nogap=true&nolabel=true&noauth=true")! var entries: [Entry] = [] var history: [SessionSummary] = [] var medium = "piece" @@ -57,7 +58,7 @@ final class Session { var status: String = "starting" var health: Health = .idle var route: String = "" - var previewURL: URL? + var previewURL: URL? = Session.draftPreviewURL var shareURL: URL? var source = "" var showSignIn = false @@ -167,12 +168,12 @@ final class Session { case "source": source = event["source"] as? String ?? source - previewURL = URL(string: "https://aesthetic.computer/wipe?nogap=true&nolabel=true&noauth=true") + previewURL = Self.draftPreviewURL case "piece": source = event["source"] as? String ?? source shareURL = nil - previewURL = nil + previewURL = Self.draftPreviewURL route = event["route"] as? String ?? "" case "preview": diff --git a/apple/aesel/Sources/SessionHost.swift b/apple/aesel/Sources/SessionHost.swift index 48ef078a40..44ade69a1d 100644 --- a/apple/aesel/Sources/SessionHost.swift +++ b/apple/aesel/Sources/SessionHost.swift @@ -105,17 +105,9 @@ final class SessionHost: NSObject { 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"); + let delivered = false; 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; @@ -129,7 +121,7 @@ final class SessionHost: NSObject { config.userContentController = controller let view = WKWebView(frame: .zero, configuration: config) view.navigationDelegate = self - view.alpha = 0 + view.aeselOpacity = 0 loginWebView = view return view } @@ -138,9 +130,9 @@ final class SessionHost: NSObject { session.showSignIn = true session.signInError = nil session.signInLoading = true - signInView.alpha = 0 + signInView.aeselOpacity = 0 armLoginTimeout() - signInView.load(URLRequest(url: URL(string: "https://aesthetic.computer/?aeselLogin=1")!)) + signInView.load(URLRequest(url: URL(string: "https://aesthetic.computer/")!)) } private func armLoginTimeout() { @@ -253,7 +245,7 @@ extension SessionHost: WKNavigationDelegate { MainActor.assumeIsolated { guard webView === loginWebView else { return } session.signInLoading = true - webView.alpha = 0 + webView.aeselOpacity = 0 armLoginTimeout() } } @@ -262,10 +254,11 @@ extension SessionHost: WKNavigationDelegate { // Deliberately does not mark the host ready; see the `ready` event. 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() } + // Show the actual prompt curtain; its Login / Sign Up actions + // open AC authentication. The trusted token bridge stays active. + session.signInLoading = false + webView.aeselOpacity = 1 + loginTimeout?.cancel() } else { NSLog("[aesel] host page loaded") } } } @@ -301,15 +294,20 @@ extension SessionHost: WKNavigationDelegate { } } -/// A session on disk. One JSON file in Documents, which is the container iOS -/// actually gives an app — there is no ~/.local/share to keep a piece in. +/// One atomic session file: iOS Documents or the Mac app's Application Support directory. final class SessionStore { private let url: URL private var values: [String: String] init() { - let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - url = documents.appendingPathComponent("session.json") + #if os(macOS) + let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent(Bundle.main.bundleIdentifier ?? "computer.aesthetic.aesel.native", isDirectory: true) + #else + let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + #endif + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + url = directory.appendingPathComponent("session.json") if let data = try? Data(contentsOf: url), let decoded = try? JSONDecoder().decode([String: String].self, from: data) { values = decoded @@ -341,9 +339,17 @@ final class SessionStore { try? data.write(to: url, options: .atomic) } + private static var keychainService: String { + #if os(macOS) + return (Bundle.main.bundleIdentifier ?? "computer.aesthetic.aesel.native") + ".session" + #else + return "computer.aesthetic.aesel.session" + #endif + } + private var tokenQuery: [String: Any] { [kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "computer.aesthetic.aesel.session", + kSecAttrService as String: Self.keychainService, kSecAttrAccount as String: "access-token"] } diff --git a/apple/aesel/Tests/AppearanceChecks.swift b/apple/aesel/Tests/AppearanceChecks.swift new file mode 100644 index 0000000000..71a1d583f0 --- /dev/null +++ b/apple/aesel/Tests/AppearanceChecks.swift @@ -0,0 +1,31 @@ +import SwiftUI + +@main +struct AppearanceChecks { + static func luminance(_ hex: String) -> Double { + let rgb = UInt32(hex.dropFirst(), radix: 16)! + let linear = [16, 8, 0].map { shift -> Double in + let c = Double((rgb >> shift) & 255) / 255 + return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722 + } + + static func main() { + for scheme in [ColorScheme.light, .dark] { + for status in ["", "ready", "writing", "publishing", "awaiting", "stopped", "failed"] { + for busy in [false, true] { + let paint = Paint.slab(status: status, busy: busy, failed: status == "failed", colorScheme: scheme) + precondition(paint.css["colorScheme"] == (scheme == .light ? "light" : "dark")) + let background = luminance(paint.css["background"]!) + for role in ["foreground", "userInk", "error", "number"] { + let ink = luminance(paint.css[role]!) + let contrast = (max(ink, background) + 0.05) / (min(ink, background) + 0.05) + precondition(contrast >= 4.5, "\(scheme) \(status) \(role): \(contrast)") + } + } + } + } + print("Text contrast passes in light/dark across all activity states.") + } +} diff --git a/apple/aesel/Tests/SessionPreviewChecks.swift b/apple/aesel/Tests/SessionPreviewChecks.swift new file mode 100644 index 0000000000..b6b6e30f34 --- /dev/null +++ b/apple/aesel/Tests/SessionPreviewChecks.swift @@ -0,0 +1,26 @@ +// Compile with Sources/Session.swift; no device, login or network required. +import Foundation + +@main +struct SessionPreviewChecks { + static func main() { + let session = Session() + precondition(session.previewURL == Session.draftPreviewURL) + session.receive(["type": "piece", "source": "starter", "route": "new-piece"]) + precondition(session.previewURL == Session.draftPreviewURL) + precondition(session.source == "starter" && session.shareURL == nil) + + let published = "https://aesthetic.computer/@test/painting" + session.receive(["type": "preview", "url": published]) + precondition(session.previewURL?.absoluteString == published) + precondition(session.shareURL?.absoluteString == published) + session.receive(["type": "source", "source": "edited draft"]) + precondition(session.previewURL == Session.draftPreviewURL) + precondition(session.source == "edited draft") + + session.receive(["type": "piece", "source": "next piece", "route": "next-piece"]) + precondition(session.previewURL == Session.draftPreviewURL) + precondition(session.shareURL == nil) + print("Preview remains visible for launch, drafts, published pieces and thread changes.") + } +} diff --git a/apple/aesel/bundle-session.sh b/apple/aesel/bundle-session.sh index da43cca500..7c9675cf00 100755 --- a/apple/aesel/bundle-session.sh +++ b/apple/aesel/bundle-session.sh @@ -13,3 +13,4 @@ cp ../../easel/desktop/rich.css Resources/Session/easel/desktop/ # The desktop's pencil companion sheet, drawn natively by AeselDonkey. mkdir -p Resources/Session/easel/desktop/assets cp ../../easel/desktop/assets/donkey-pencil-run-v2.png Resources/Session/easel/desktop/assets/ +cp ../../easel/desktop/assets/donkey-pencil-thinking-v1.png Resources/Session/easel/desktop/assets/ diff --git a/apple/aesel/project.yml b/apple/aesel/project.yml index 8d060a4161..c5540bf0f1 100644 --- a/apple/aesel/project.yml +++ b/apple/aesel/project.yml @@ -3,6 +3,7 @@ options: bundleIdPrefix: computer.aesthetic deploymentTarget: iOS: "17.0" + macOS: "14.0" settings: base: DEVELOPMENT_TEAM: FB5948YR3S @@ -29,3 +30,24 @@ targets: SUPPORTS_MACCATALYST: NO scheme: gatherCoverageData: false + AeselMac: + type: application + platform: macOS + sources: + - Sources + - path: Resources + excludes: + - Session + - path: Resources/Session + type: folder + buildPhase: resources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: computer.aesthetic.aesel.native + PRODUCT_NAME: Aesel Native + INFOPLIST_FILE: MacInfo.plist + CODE_SIGN_ENTITLEMENTS: Mac.entitlements + ENABLE_HARDENED_RUNTIME: YES + ASSETCATALOG_COMPILER_APPICON_NAME: "" + scheme: + gatherCoverageData: false diff --git a/apple/aesel/run.sh b/apple/aesel/run.sh index 3ebd7ee458..397022aefd 100755 --- a/apple/aesel/run.sh +++ b/apple/aesel/run.sh @@ -4,6 +4,7 @@ # ./run.sh → the simulator # ./run.sh device → the iPhone plugged into this Mac # ./run.sh device "ask…" → same, running one prompt on launch +# ./run.sh mac → the shared native Mac app # # The shared JavaScript session is bundled into the app. set -euo pipefail @@ -17,6 +18,15 @@ DERIVED=${DERIVED:-/tmp/aesel-dd} xcodegen generate >/dev/null +if [ "$MODE" = "mac" ]; then + xcodebuild -project Aesel.xcodeproj -scheme AeselMac \ + -destination 'platform=macOS' -derivedDataPath "$DERIVED" \ + -jobs 2 CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=YES build + APP="$DERIVED/Build/Products/Debug/Aesel Native.app" + open -n "$APP" --env "AESEL_ASK=$ASK" + exit 0 +fi + if [ "$MODE" = "device" ]; then xcodebuild -project Aesel.xcodeproj -scheme Aesel \ -destination 'generic/platform=iOS' \ diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css index 940e86997c..66692b0fed 100644 --- a/easel/desktop/donkey.css +++ b/easel/desktop/donkey.css @@ -175,7 +175,8 @@ body.preview-fullscreen::before{display:none;} #notebook-content{display:flow-root;flex:none;min-width:0} #notebook-preview-space{float:right;pointer-events:none} #notebook-preview-space[hidden]{display:none} -#conversation>#qr-label{overflow:hidden} +/* Native letter images include shadows and sway outside their text advance. */ +#conversation>#qr-label{overflow:visible} #notebook-page>#prose-prompt{position:relative;inset:auto;z-index:auto;flex:none;margin-top:auto;margin-right:100px;height:auto;overflow:visible;background:none;line-height:var(--notebook-line-height,24px)} #prompt-feedback{bottom:14px;right:110px} @@ -203,7 +204,7 @@ body.preview-fullscreen::before{display:none;} @media(prefers-reduced-motion:reduce){.ink-stamp{animation:none}} /* The title occupies the first ruled row, then sticks while the sheet moves. */ -#conversation>#qr-label,#conversation>#qr-label[data-swift-letters="true"]{position:sticky;top:0;left:auto;margin:0 0 0 10px;padding:0;box-sizing:border-box;height:var(--notebook-line-height,24px);min-height:var(--notebook-line-height,24px);line-height:var(--notebook-line-height,24px);display:flex;align-items:center;width:max-content;z-index:15} +#conversation>#qr-label,#conversation>#qr-label[data-swift-letters="true"]{position:sticky;top:0;left:auto;margin:0 0 0 10px;padding:0 12px;box-sizing:border-box;height:var(--notebook-line-height,24px);min-height:var(--notebook-line-height,24px);line-height:var(--notebook-line-height,24px);display:flex;align-items:center;width:max-content;max-width:none;z-index:15} #conversation>#qr-label[hidden],body[data-phase="startup"] #conversation>#qr-label,body.preview-fullscreen #conversation>#qr-label{display:none} #conversation>#qr-label .qr-letter{flex:none} body::before{left:10px} diff --git a/easel/desktop/notebook-layout.js b/easel/desktop/notebook-layout.js index 49ee27fd7f..ba48412841 100644 --- a/easel/desktop/notebook-layout.js +++ b/easel/desktop/notebook-layout.js @@ -53,11 +53,16 @@ ); } if (title) { - const available = visible - ? Math.max(0, box.left - title.getBoundingClientRect().left - gap) - : view.clientWidth - 28; - const maxWidth = `${available}px`; - if (title.style.maxWidth !== maxWidth) title.style.maxWidth = maxWidth; + const left = title.getBoundingClientRect().left; + const right = visible ? Math.min(box.left - gap, view.getBoundingClientRect().left + view.clientWidth - 14) + : view.getBoundingClientRect().left + view.clientWidth - 14; + // Keep the full intrinsic title (including its ink padding), then fit + // its transform. A max-width clips both the last glyph and long names. + const maximum = String(Math.max(0, right - left) / Math.max(1, title.offsetWidth + 1)); + if (title.dataset.proxMaxScale !== maximum) { + title.dataset.proxMaxScale = maximum; + title.dispatchEvent(new Event('prox-bounds-change')); + } } if (pin) view.scrollTop = view.scrollHeight; if (Math.abs(view.scrollTop - before) < 0.5) break; @@ -72,6 +77,7 @@ const observer = new ResizeObserver(window.layoutNotebookPreview); observer.observe(preview); observer.observe(view); + if (title) observer.observe(title); new MutationObserver(window.layoutNotebookPreview).observe(preview, { attributes: true, attributeFilter: ["hidden", "style", "class"], diff --git a/easel/desktop/prox-hover.js b/easel/desktop/prox-hover.js index ff231a4a75..e0b304cdef 100644 --- a/easel/desktop/prox-hover.js +++ b/easel/desktop/prox-hover.js @@ -4,18 +4,23 @@ window.installProxHover=(host,label=host)=>{ let hovered=false,focused=false,animation=null; const reduced=matchMedia('(prefers-reduced-motion: reduce)'); const retime=()=>{const speed=(hovered||focused)&&!reduced.matches?2.2:1;for(const ink of label.querySelectorAll('.qr-letter-ink'))for(const motion of ink.getAnimations())motion.updatePlaybackRate(speed);}; - const update=()=>{ - const to=(hovered||focused)&&!reduced.matches?1.5:1; + const update=(animate=true)=>{ + const limit=Number(host.dataset.proxMaxScale); + const maximum=Number.isFinite(limit)&&limit>=0&&host.hasAttribute('data-prox-max-scale')?limit:Infinity; + const to=Math.min(maximum,(hovered||focused)&&!reduced.matches?1.5:1); const transform=getComputedStyle(host).transform; const from=transform==='none'?1:new DOMMatrixReadOnly(transform).a; animation?.cancel();host.style.transform=`scale(${to})`; - if(!reduced.matches){ - const frames=Array.from({length:49},(_,i)=>{const t=i/48*.8;const value=to+(from-to)*Math.exp(-7.5*t)*(Math.cos(16*t)+7.5/16*Math.sin(16*t));return{transform:`scale(${i===48?to:value})`,offset:i/48};}); + if(animate&&!reduced.matches){ + const frames=Array.from({length:49},(_,i)=>{const t=i/48*.8;const value=to+(from-to)*Math.exp(-7.5*t)*(Math.cos(16*t)+7.5/16*Math.sin(16*t));return{transform:`scale(${Math.min(maximum,i===48?to:value)})`,offset:i/48};}); animation=host.animate(frames,{duration:800,easing:'linear'}); } retime(); }; host.addEventListener('pointerenter',()=>{hovered=true;update();});host.addEventListener('pointerleave',()=>{hovered=false;update();}); host.addEventListener('focus',()=>{focused=host.matches(':focus-visible');update();});host.addEventListener('blur',()=>{focused=false;update();}); - reduced.addEventListener('change',update);new MutationObserver(retime).observe(label,{childList:true,subtree:true}); + // Resize/title changes must fit immediately, including during a hover spring. + host.addEventListener('prox-bounds-change',()=>update(false)); + reduced.addEventListener('change',()=>update(false));new MutationObserver(retime).observe(label,{childList:true,subtree:true}); + update(false); }; diff --git a/easel/desktop/test/title-bounds-smoke.cjs b/easel/desktop/test/title-bounds-smoke.cjs new file mode 100644 index 0000000000..a34f2dc70f --- /dev/null +++ b/easel/desktop/test/title-bounds-smoke.cjs @@ -0,0 +1,95 @@ +// Run with Electron on macOS after building native/credit-label. +// Uses the real title renderer and layout, without starting Aesel or a session. +const { app, BrowserWindow } = require('electron'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { execFileSync } = require('node:child_process'); +const root = path.resolve(__dirname, '..'); +const output = process.env.AESEL_TITLE_TEST_OUTPUT || fs.mkdtempSync(path.join(os.tmpdir(), 'aesel-title-')); +fs.mkdirSync(output, { recursive: true }); +app.setPath('userData', path.join(output, 'profile')); +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); +const asset = name => pathToFileURL(path.join(root, name)).href; + +const timeout=setTimeout(()=>{console.error('Title smoke timed out');app.exit(1)},45000); +app.whenReady().then(async () => { + const fixture = path.join(output, 'title.html'); + fs.writeFileSync(fixture, ` + ${['style.css', 'qr-label.css', 'donkey.css'].map(name => ``).join('')} + +
+
+ + ${['qr-label.js', 'prox-hover.js', 'notebook-layout.js'].map(name => ``).join('')} + `); + // A visible window lets macOS composite the animated image layers in captures. + const win = new BrowserWindow({ show: true, width: 760, height: 420, + webPreferences: { backgroundThrottling: false, contextIsolation: true, sandbox: true } }); + const run = source => win.webContents.executeJavaScript(source); + await win.loadFile(fixture); + console.log('Title fixture loaded'); + const setTitle = async text => { + const glyphs = JSON.parse(execFileSync(path.join(root, 'native/credit-label'), [text, '16', '--glyphs'], { encoding: 'utf8' })); + await run(`window.testGlyphs=${JSON.stringify(glyphs)};updateQrLabel(${JSON.stringify(text)})`); + await wait(100); + await run(`Promise.all([...document.querySelectorAll('#qr-label img')].map(img=>img.decode()))`); + await wait(100); + assert.equal(await run(`document.getElementById('qr-label').getAttribute('aria-label')`), text); + }; + const check = async name => { + const bounds = await run(`(()=>{ + const title=document.getElementById('qr-label'),view=document.getElementById('conversation'),preview=document.getElementById('artifact-shell'); + const right=preview.hidden?view.clientWidth-14:Math.min(preview.getBoundingClientRect().left-16,view.clientWidth-14); + const images=[...title.querySelectorAll('img')].map(img=>img.getBoundingClientRect()); + return {left:Math.min(...images.map(r=>r.left)),right:Math.max(...images.map(r=>r.right)),limit:right,count:images.length}; + })()`); + assert.ok(bounds.count > 0, `${name}: no native glyphs`); + assert.ok(bounds.left >= 0 && bounds.right <= bounds.limit + .5, `${name}: ${JSON.stringify(bounds)}`); + }; + const freeze = async time => run(`for(const a of document.getAnimations()){a.pause();a.currentTime=${time}}`); + const checkPixels = async name => { + await freeze(450); + await wait(60); + const rect = { x: 0, y: 0, width: 530, height: 85 }; + const actual = await win.webContents.capturePage(rect); + // Removing clipping must not reveal missing ink, including the final shadow. + await run(`window.clipReference=document.createElement('style');clipReference.textContent='#qr-label,#conversation{overflow:visible!important}';document.head.append(clipReference)`); + await wait(60); + const reference = await win.webContents.capturePage(rect); + await run('clipReference.remove()'); + assert.ok(actual.toBitmap().equals(reference.toBitmap()), `${name}: clipped title pixels`); + fs.writeFileSync(path.join(output, `${name}.png`), actual.toPNG()); + }; + + await setTitle('@jeffrey/aesel'); + console.log('Native glyphs ready'); + await check('short title'); + await checkPixels('title'); + await run(`document.getElementById('qr-label').dispatchEvent(new PointerEvent('pointerenter'))`); + for (const time of [0, 80, 160, 240, 400, 800]) { await freeze(time); await check(`hover ${time}`); } + await checkPixels('title-hover'); + await setTitle('@jeffrey/aesel-a-long-piece-name-ending-in-W'); + await run(`document.getElementById('artifact-shell').style.left='240px'`); + await wait(100); + await check('preview resized while hovered'); + await run(`document.getElementById('qr-label').dispatchEvent(new PointerEvent('pointerleave'))`); + for (const time of [0, 160, 400, 800]) { await freeze(time); await check(`leave ${time}`); } + win.setContentSize(360, 420); + await run(`document.getElementById('artifact-shell').hidden=true`); + await wait(100); + await check('narrow window, hidden preview'); + await win.webContents.debugger.attach('1.3'); + await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + await wait(100); + await check('reduced motion'); + await setTitle('WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW'); + await check('maximum length'); + await setTitle('aesel'); + await check('short title restored'); + console.log(`Title render, hover, resize and reduced-motion checks passed. Captures: ${output}`); + clearTimeout(timeout); + app.exit(0); +}).catch(error => { console.error(error); app.exit(1); }); diff --git a/easel/phone/notebook.css b/easel/phone/notebook.css index a012932006..498d890f8f 100644 --- a/easel/phone/notebook.css +++ b/easel/phone/notebook.css @@ -3,16 +3,19 @@ ruling and owns the scroll; this document only grows to fit its prose. */ :root{color-scheme:dark;--aesel-background:#463264;--aesel-foreground:#fff;--user-ink:#e9b5d5;--number-ink:#ffd17c;--notebook-line-height:24px;--prose-font-size:16px} *{box-sizing:border-box} -html,body{margin:0;overflow:hidden;background:transparent;color:var(--aesel-foreground);font:var(--prose-font-size)/var(--notebook-line-height) Arial,Helvetica,sans-serif} +html,body{margin:0;overflow:hidden;background:var(--aesel-background);color:var(--aesel-foreground);font:var(--prose-font-size)/var(--notebook-line-height) Arial,Helvetica,sans-serif} +body{background-image:repeating-linear-gradient(to bottom,transparent 0,transparent calc(var(--notebook-line-height) - 1px),color-mix(in srgb,var(--aesel-foreground) 12%,transparent) calc(var(--notebook-line-height) - 1px),color-mix(in srgb,var(--aesel-foreground) 12%,transparent) var(--notebook-line-height))} #conversation{padding:0 10px;line-height:var(--notebook-line-height)} #notebook-page{overflow-wrap:anywhere} article{margin:0 0 24px;white-space:pre-wrap} article[data-kind=user]{color:var(--user-ink)} -article[data-kind=error]{color:#ff9898} +article[data-kind=error]{color:var(--error-ink,#ff9898)} article[data-kind=notice]{opacity:.7;font-size:14px} pre{max-width:100%;overflow:auto;white-space:pre;margin:24px 0;font:14px/24px Menlo,monospace} code{font:.88em Menlo,monospace} a{color:inherit;text-decoration:underline;text-underline-offset:3px} +a[href],button:not(:disabled),[role=button]:not([aria-disabled=true]){cursor:pointer} +button:disabled,[aria-disabled=true]{cursor:default} .user-handle{font:inherit;opacity:.5} /* The desktop's pencil companion: two rows tall beside the latest user prose, behind the ink. */ #notebook-activity{white-space:nowrap} diff --git a/easel/phone/notebook.js b/easel/phone/notebook.js index 426f8a004f..1efac3b0a2 100644 --- a/easel/phone/notebook.js +++ b/easel/phone/notebook.js @@ -3,10 +3,11 @@ window.aesel = {openLink(url) { if (/^https?:\/\//i.test(url)) window.webkit?.messageHandlers?.notebook?.postMessage({url}); }}; window.updatePhoneNotebook = value => { - for (const [name, property] of [['background','--aesel-background'],['foreground','--aesel-foreground'],['userInk','--user-ink']]) { + for (const [name, property] of [['background','--aesel-background'],['foreground','--aesel-foreground'],['userInk','--user-ink'],['error','--error-ink'],['number','--number-ink']]) { const color = value.theme?.[name]; if (/^#[0-9a-f]{6}$/i.test(color || '')) document.documentElement.style.setProperty(property, color); } + if (['light','dark'].includes(value.theme?.colorScheme)) document.documentElement.style.colorScheme = value.theme.colorScheme; window.setNotebookHandle(value.handle || '', value.colors || []); window.updateConversation({entries:value.entries}); placeActivity(!!value.busy, value.activity || '');