Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,29 @@ One more vocabulary mismatch to remember: deliver says `osx`, the Connect API
says `MAC_OS`, and passing the former to spaceship reports a missing version
that plainly exists.

**visionOS is a third listing, spelled three different ways** (#11). It is a
native app on the xrOS SDK, not "Designed for iPad", so App Store Connect gives
it its own platform version — which the app record must carry *before* either
lane will run (`get_edit_app_store_version` returns nil otherwise, and
`metadata_diff` stops with "No editable xros version"). The text is shared with
the other two, as it already was between iOS and macOS; only the screenshots
are per-platform. Then the vocabulary: **`visionos` names the screenshots
directory, `xros` goes to deliver, `VISION_OS` goes to spaceship**, and the
first of those is not a style choice. A Vision Pro capture is 3840×2160, the
same size as an Apple TV one, so deliver cannot resolve the display type from
the size and falls back to asking whether the *path* contains `vision`
(downcased) — name the directory after deliver's own platform value and every
screenshot is filed as `APP_APPLE_TV` on an app with no tvOS listing.
The captures need no staging: `xcrun simctl io <device> screenshot` on the
visionOS simulator writes exactly 3840×2160, the simulated room and all, which
is what visionOS screenshots look like anyway. They do carry an alpha channel,
so `-alpha off` applies here like everywhere else. And no new identifier is
needed — spaceship maps `xros` onto the **iOS** `BundleIdPlatform`, so the App
IDs the iPhone/iPad build already registered are the ones visionOS signs
against.

**A release is a `v*` tag** (#4). Xcode Cloud runs one `Release` workflow off
it — two Archive actions, iOS and macOS, each with a TestFlight internal
it — an Archive action per platform, each with a TestFlight internal
post-action bound to its own archive artifact. It carries no Build or Test
action: GitHub Actions has already run the lint, the Kit tests and both
platform builds on the way to main, and Xcode Cloud's 25 free compute
Expand Down
11 changes: 6 additions & 5 deletions .github/workflows/appstore-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ on:
platform:
description: Which listing
type: choice
options: [both, ios, macos]
default: both
options: [all, ios, macos, visionos]
default: all
apply:
description: Upload (unticked, only the diff is shown)
type: boolean
Expand Down Expand Up @@ -48,9 +48,10 @@ jobs:
run: |
lane='${{ inputs.apply && 'metadata_push' || 'metadata_diff' }}'
case '${{ inputs.platform }}' in
ios) platforms="ios" ;;
macos) platforms="mac" ;;
*) platforms="ios mac" ;;
ios) platforms="ios" ;;
macos) platforms="mac" ;;
visionos) platforms="visionos" ;;
*) platforms="ios mac visionos" ;;
esac
for p in $platforms; do
echo "::group::$p $lane"
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ jobs:
destination: generic/platform=iOS Simulator
- name: macOS
destination: platform=macOS
# visionOS (#11). It shares every source file with iPadOS, so what
# this catches is the `#if` that quietly stops applying: SwiftUI
# marks a handful of iOS API unavailable there (`ToolbarSpacer`),
# and an `#if os(iOS)` compiles clean on all three platforms while
# doing nothing on this one.
- name: visionOS
destination: generic/platform=visionOS Simulator
steps:
- uses: actions/checkout@v7

Expand Down
10 changes: 10 additions & 0 deletions App/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,16 @@
}
}
},
"Open Another Drawing" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ほかの えを ひらく"
}
}
}
},
"Order" : {
"localizations" : {
"ja" : {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "AppIcon_Back.png",
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.solidimagestacklayer"
},
{
"filename" : "Middle.solidimagestacklayer"
},
{
"filename" : "Back.solidimagestacklayer"
}
]
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "AppIcon_Front.png",
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "AppIcon_Middle.png",
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
2 changes: 1 addition & 1 deletion App/TortoiseBlocksApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct TortoiseBlocksApp: App {
// unavailable on macOS and SwiftUI has no empty `Scene` to return in
// its place, so this `#if` can't hide inside a modifier the way the
// ones in `PlatformModifiers` do.
#if os(iOS)
#if !os(macOS)
LaunchScene()
#endif
}
Expand Down
76 changes: 72 additions & 4 deletions App/Views/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,60 @@ with `.toolbar(removing: .title)` (#31). The back chevron beside it is not
ours to remove — neither dropping that column's toolbar nor
`navigationBarBackButtonHidden` touches it.

**visionOS needs three things the other two get for free** (#11), and all three
are `#if os(visionOS)` rather than shared, because on iPadOS and macOS each
would be a second copy of something that already exists.

*A way back to the browser.* iPadOS puts a chevron beside the document title
and macOS has File ▸ Open with a window per document; visionOS has neither, so
the window carries the drawing it was opened with and the only route to another
was to close it and launch again. `documentBrowserToolbar()` puts a folder
button in the sidebar's bar, and `dismiss` — what a `DocumentGroup` document
closes itself with — is what it calls. **Where `dismiss` is read from decides
whether it does anything.** Read inside the toolbar item's own view, which is
the obvious place, it resolves against the toolbar's context and the button is
inert: it highlights on press and nothing happens. It has to come from the
environment of the *content* the toolbar is attached to, which is why this is a
`ViewModifier` and not a view inside the `toolbar` block. Nothing warns you —
the code compiles and the button draws.

*The name is deliberately in one place.* The sidebar carries the
DocumentGroup's own title with its rename chevron, and `CanvasPane` still drops
its copy with `.toolbar(removing: .title)` (#31). A second, self-drawn label in
the canvas pane was tried — `\.documentConfiguration`'s `fileURL`, which unlike
the system chrome is right from the first frame — and taken back out: on a
window this wide the two read as one name printed twice rather than as a title
and a reminder.

*A hover effect, but never on a drag source.* visionOS does not give a button
with a custom `ButtonStyle` the system hover treatment, so without
`pointerHover()` a palette block is the one thing on screen that never lights
up when looked at — and gaze feedback is the whole targeting affordance there.
The catch is that a hover effect and `draggable` **on the same view** segfault
(a `swift_release` inside SwiftUI's update of that view's body, before a window
appears). The palette entry is a drag source, so its hover lives inside
`PaletteBlockButtonStyle` — one level below the `Button` that carries
`draggable` — while every other call site applies it directly. The crash blames
the body, not the modifier, which is why this first read as "`hoverEffect`
crashes on visionOS": it does not, the pairing does.

**The code pane is paper, not a semantic surface** (#11). It sat on
`.background.secondary`, which resolves to near-white or near-black on iPad and
Mac but to light translucent glass on visionOS — and the syntax colors had
nowhere to stand: system `.purple` and `.blue` are tuned for an opaque backdrop
and `.plain` was `Color.primary`, which is *white* there, so the plain text and
its ground were both light. It is now white, opaque, the same in both
appearances, rounded the same 8 as the canvas — the two swap places inside one
`ZStack`, so pressing the toggle should change the content and nothing else.
The token colors are fixed values measured against white (8.6:1, 8.4:1, 5.1:1,
16.9:1) for the reason the block fills are fixed (#41). Two traps came with it.
The copy button stays *outside* the paper: on it, it needed the ink as a tint
to be legible, and on visionOS the tint went to the button's capsule instead of
its label, leaving a black lozenge with invisible text. And a program narrower
than the pane sat in the middle of it — in a scroll view that scrolls both ways
the content is offered no width to fill, so a `.leading` frame does nothing and
`defaultScrollAnchor(.topLeading)` is what places it.

**A block row is one VoiceOver element, a container header is not** (#1).
Swiping a program should say "まえへ、かず 100、じっこうちゅう" once per block,
not stop three times, so a simple row is `.accessibilityElement(children:
Expand Down Expand Up @@ -123,10 +177,24 @@ damage rather than fixing it: with the label served first, `0.6` in the next row
broke as "0." over "6", which is worse — a chip holds a number, a name or a
colour, all atomic. So `WorkspaceChipButtonStyle` pins its label with
`.fixedSize(horizontal: true, vertical: false)`. Priority rather than
`fixedSize` on the *label*, deliberately: a row two levels deep with a long name
(「はこにかける」) genuinely runs out of room, and there the label should still
wrap instead of overflowing its block. Each level of nesting costs 18pt, so no
column width wins that race — the wrap is accepted there.
`fixedSize` on the *label*, deliberately: a row deep enough with a long name
genuinely runs out of room, and there the label should still wrap instead of
overflowing its block. Each level of nesting costs 18pt, so no column width wins
that race for ever — the wrap is the correct last resort, not the working state.

**And `ideal` is not a starting point on every platform.** macOS and iPadOS 26
both let the split view's divider be dragged, so there the ideal is only where
the workspace column *opens*. **visionOS has no draggable divider**, so there
the ideal is the width, for good, and the *detail* column absorbs every extra
point the window has. On a 1280pt visionOS window that used to read 360 here
and ~690 on the canvas (#11) — with 「くりかえす 10 かい」 wrapping か/い at the
**top** level, no nesting involved, and 「はこにかける」 splitting in the middle
three levels down. The ideal is now 440, measured against exactly that program:
every row fits on one line at three levels, and the canvas still clears its own
420 ideal. `max` (560) has to stay above `ideal`, or the two platforms that can
drag could only ever drag narrower. Judge a change to it on a *nested Japanese*
program — English fits where 「はこにかける」 does not, and the top-level wrap is
invisible in a flat one.

**Drop model**: a `DropGap` between rows carries `(BodyAddress, index)`, so
insertion semantics need no y-coordinate math and every mouth — an if's else
Expand Down
64 changes: 52 additions & 12 deletions App/Views/CodePane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,68 @@ struct CodePane: View {
.labelStyle(.titleAndIcon)
}
.padding(8)
// The code is on paper, the same paper the canvas is on (#11). It
// used to sit on `.background.secondary`, which is a *semantic*
// surface: near-white or near-black on iPad and Mac, and on
// visionOS light translucent glass over a room. The syntax colors
// then had nowhere to stand — system `.purple` and `.blue` are
// tuned for an opaque backdrop, and `.plain` was `Color.primary`,
// which is *white* there, so the plain text and the ground behind
// it were both light. Opaque, named, and the same in both
// appearances, for the reason the block fills are (#41): this is
// the app's surface, not a response to its surroundings.
//
// White rather than an editor's dark theme because the code pane
// and the canvas swap places inside one `ZStack` — the same sheet,
// rounded the same 8, means pressing the toggle changes only the
// *content*.
//
// The paper wraps the code and nothing else. The copy button stays
// outside it, on the pane's own ground with the window's other
// controls: inside, it needed the ink as a tint to be legible, and
// on visionOS the tint went to the *capsule* instead of the label,
// leaving a black lozenge with invisible text on it.
ScrollView([.vertical, .horizontal]) {
Text(highlightedCode)
.font(.system(.callout, design: .monospaced))
.textSelection(.enabled)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
// A program narrower than the pane sat in the *middle* of it, which
// is not where source starts. The `.leading` frame above cannot fix
// that on its own: in a scroll view that scrolls both ways the
// content is offered no width to fill, so it takes its own and the
// scroll view centres what is left over. The anchor is what places
// undersized content, on both axes at once.
.defaultScrollAnchor(.topLeading)
.background(Color.white, in: Self.sheet)
.clipShape(Self.sheet)
}
.background(.background.secondary)
}

/// Colors each `CodeTokenizer` span with a semantic color, so both
/// light and dark mode stay legible.
/// The canvas's shape, so the two panes are the same sheet — see
/// `CanvasPane.sheet`, which this deliberately matches.
private static let sheet = RoundedRectangle(cornerRadius: 8)

/// Colors each `CodeTokenizer` span with a fixed color.
///
/// Fixed, not semantic: the pane is white in both appearances, so a color
/// that inverts with the appearance would be picking its contrast against
/// a background it no longer has. These are measured against white —
/// 8.6:1, 8.4:1, 5.1:1 and 16.9:1 — so every kind clears AA at the callout
/// size, and the three accents stay far enough apart in hue to be told
/// apart at a glance.
private func color(for kind: CodeTokenKind) -> Color {
switch kind {
case .keyword: Color(.sRGB, red: 0.604, green: 0.129, blue: 0.588) // #9A2196
case .number: Color(.sRGB, red: 0.106, green: 0.220, blue: 0.784) // #1B38C8
case .methodOrProperty: Color(.sRGB, red: 0.031, green: 0.396, blue: 0.435) // #08656F
case .plain: BlockCategory.ink
}
}

/// Colors each `CodeTokenizer` span, so the pane reads as source.
private var highlightedCode: AttributedString {
var result = AttributedString()
for token in CodeTokenizer.tokenize(code) {
Expand All @@ -46,15 +95,6 @@ struct CodePane: View {
}
return result
}

private func color(for kind: CodeTokenKind) -> Color {
switch kind {
case .keyword: .purple
case .number: .blue
case .methodOrProperty: .teal
case .plain: .primary
}
}
}

/// Shared with the Run menu's "Copy Code" command (#23), so both paths to
Expand Down
Loading