Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
This commit is contained in:
7
.claude/skills/axiom-swiftui-layout/.openskills.json
Normal file
7
.claude/skills/axiom-swiftui-layout/.openskills.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"source": "CharlesWiltgen/Axiom",
|
||||
"sourceType": "git",
|
||||
"repoUrl": "https://github.com/CharlesWiltgen/Axiom",
|
||||
"subpath": "axiom-codex/skills/axiom-swiftui-layout",
|
||||
"installedAt": "2026-04-12T08:06:48.854Z"
|
||||
}
|
||||
391
.claude/skills/axiom-swiftui-layout/SKILL.md
Normal file
391
.claude/skills/axiom-swiftui-layout/SKILL.md
Normal file
@@ -0,0 +1,391 @@
|
||||
---
|
||||
name: axiom-swiftui-layout
|
||||
description: Use when layouts need to adapt to different screen sizes, iPad multitasking, or iOS 26 free-form windows — decision trees for ViewThatFits vs AnyLayout vs onGeometryChange, size class limitations, and anti-patterns preventing device-based layout mistakes
|
||||
license: MIT
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# SwiftUI Adaptive Layout
|
||||
|
||||
## Overview
|
||||
|
||||
Discipline-enforcing skill for building layouts that respond to available space rather than device assumptions. Covers tool selection, size class limitations, iOS 26 free-form windows, and common anti-patterns.
|
||||
|
||||
**Core principle:** Your layout should work correctly if Apple ships a new device tomorrow, or if iPadOS adds a new multitasking mode next year. Respond to your container, not your assumptions about the device.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- "How do I make this layout work on iPad and iPhone?"
|
||||
- "Should I use GeometryReader or ViewThatFits?"
|
||||
- "My layout breaks in Split View / Stage Manager"
|
||||
- "Size classes aren't giving me what I need"
|
||||
- "Designer wants different layout for portrait vs landscape"
|
||||
- "Preparing app for iOS 26 window resizing"
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
"I need my layout to adapt..."
|
||||
│
|
||||
├─ TO AVAILABLE SPACE (container-driven)
|
||||
│ │
|
||||
│ ├─ "Pick best-fitting variant"
|
||||
│ │ → ViewThatFits
|
||||
│ │
|
||||
│ ├─ "Animated switch between H↔V"
|
||||
│ │ → AnyLayout + condition
|
||||
│ │
|
||||
│ ├─ "Read size for calculations"
|
||||
│ │ → onGeometryChange (iOS 16+)
|
||||
│ │
|
||||
│ └─ "Custom layout algorithm"
|
||||
│ → Layout protocol
|
||||
│
|
||||
├─ TO PLATFORM TRAITS
|
||||
│ │
|
||||
│ ├─ "Compact vs Regular width"
|
||||
│ │ → horizontalSizeClass (⚠️ iPad limitations)
|
||||
│ │
|
||||
│ ├─ "Accessibility text size"
|
||||
│ │ → dynamicTypeSize.isAccessibilitySize
|
||||
│ │
|
||||
│ └─ "Platform differences"
|
||||
│ → #if os() / Environment
|
||||
│
|
||||
└─ TO WINDOW SHAPE (aspect ratio)
|
||||
│
|
||||
├─ "Portrait vs Landscape semantics"
|
||||
│ → Geometry + custom threshold
|
||||
│
|
||||
├─ "Auto show/hide columns"
|
||||
│ → NavigationSplitView (automatic in iOS 26)
|
||||
│
|
||||
└─ "Window lifecycle"
|
||||
→ @Environment(\.scenePhase)
|
||||
```
|
||||
|
||||
## Tool Selection
|
||||
|
||||
### Quick Decision
|
||||
|
||||
```
|
||||
Do you need a calculated value (width, height)?
|
||||
├─ YES → onGeometryChange
|
||||
└─ NO → Do you need animated transitions?
|
||||
├─ YES → AnyLayout + condition
|
||||
└─ NO → ViewThatFits
|
||||
```
|
||||
|
||||
### When to Use Each Tool
|
||||
|
||||
| I need to... | Use this | Not this |
|
||||
|-------------|----------|----------|
|
||||
| Pick between 2-3 layout variants | `ViewThatFits` | `if size > X` |
|
||||
| Switch H↔V with animation | `AnyLayout` | Conditional HStack/VStack |
|
||||
| Read container size | `onGeometryChange` | `GeometryReader` |
|
||||
| Adapt to accessibility text | `dynamicTypeSize` | Fixed breakpoints |
|
||||
| Detect compact width | `horizontalSizeClass` | `UIDevice.idiom` |
|
||||
| Detect narrow window on iPad | Geometry + threshold | Size class alone |
|
||||
| Hide/show sidebar | `NavigationSplitView` | Manual column logic |
|
||||
| Custom layout algorithm | `Layout` protocol | Nested GeometryReaders |
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: ViewThatFits
|
||||
|
||||
**Use when:** You have 2-3 layout variants and want SwiftUI to pick the first that fits.
|
||||
|
||||
```swift
|
||||
ViewThatFits {
|
||||
// First choice: horizontal
|
||||
HStack {
|
||||
Image(systemName: "star")
|
||||
Text("Favorite")
|
||||
Spacer()
|
||||
Button("Add") { }
|
||||
}
|
||||
|
||||
// Fallback: vertical
|
||||
VStack {
|
||||
HStack {
|
||||
Image(systemName: "star")
|
||||
Text("Favorite")
|
||||
}
|
||||
Button("Add") { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Limitation:** ViewThatFits doesn't expose which variant was chosen. If you need that state for other views, use AnyLayout instead.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: AnyLayout for Animated Switching
|
||||
|
||||
**Use when:** You need animated transitions between layouts, or need to know current layout state.
|
||||
|
||||
```swift
|
||||
struct AdaptiveStack<Content: View>: View {
|
||||
@Environment(\.horizontalSizeClass) var sizeClass
|
||||
|
||||
let content: Content
|
||||
|
||||
var layout: AnyLayout {
|
||||
sizeClass == .compact
|
||||
? AnyLayout(VStackLayout(spacing: 12))
|
||||
: AnyLayout(HStackLayout(spacing: 20))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
layout {
|
||||
content
|
||||
}
|
||||
.animation(.default, value: sizeClass)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For Dynamic Type:**
|
||||
|
||||
```swift
|
||||
@Environment(\.dynamicTypeSize) var dynamicTypeSize
|
||||
|
||||
var layout: AnyLayout {
|
||||
dynamicTypeSize.isAccessibilitySize
|
||||
? AnyLayout(VStackLayout())
|
||||
: AnyLayout(HStackLayout())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: onGeometryChange (Preferred for Geometry)
|
||||
|
||||
**Use when:** You need actual dimensions for calculations. Preferred over GeometryReader.
|
||||
|
||||
```swift
|
||||
struct ResponsiveGrid: View {
|
||||
@State private var columnCount = 2
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount)) {
|
||||
ForEach(items) { item in
|
||||
ItemView(item: item)
|
||||
}
|
||||
}
|
||||
.onGeometryChange(for: Int.self) { proxy in
|
||||
max(1, Int(proxy.size.width / 150))
|
||||
} action: { newCount in
|
||||
columnCount = newCount
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For aspect ratio detection (iPad "orientation"):**
|
||||
|
||||
```swift
|
||||
struct WindowShapeReader: View {
|
||||
@State private var isWide = true
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.onGeometryChange(for: Bool.self) { proxy in
|
||||
proxy.size.width > proxy.size.height * 1.2
|
||||
} action: { newValue in
|
||||
isWide = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: GeometryReader (When Necessary)
|
||||
|
||||
**Use when:** You need geometry AND are on iOS 15 or earlier, OR need geometry during layout phase (not just as side effect).
|
||||
|
||||
```swift
|
||||
// ✅ CORRECT: Constrained GeometryReader
|
||||
VStack {
|
||||
GeometryReader { geo in
|
||||
Text("Width: \(geo.size.width)")
|
||||
}
|
||||
.frame(height: 44) // MUST constrain!
|
||||
|
||||
Button("Next") { }
|
||||
}
|
||||
|
||||
// ❌ WRONG: Unconstrained (greedy)
|
||||
VStack {
|
||||
GeometryReader { geo in
|
||||
Text("Width: \(geo.size.width)")
|
||||
}
|
||||
// Takes all available space, crushes siblings
|
||||
Button("Next") { }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Size Class Truth Table (iPad)
|
||||
|
||||
| Configuration | Horizontal | Vertical |
|
||||
|--------------|------------|----------|
|
||||
| Full screen portrait | `.regular` | `.regular` |
|
||||
| Full screen landscape | `.regular` | `.regular` |
|
||||
| 70% Split View | `.regular` | `.regular` |
|
||||
| 50% Split View | `.regular` | `.regular` |
|
||||
| 33% Split View | `.compact` | `.regular` |
|
||||
| Slide Over | `.compact` | `.regular` |
|
||||
| With keyboard | (unchanged) | (unchanged) |
|
||||
|
||||
**Key insight:** Size class only goes `.compact` on iPad at ~33% width or Slide Over. For finer control, use geometry.
|
||||
|
||||
---
|
||||
|
||||
## iOS 26 Free-Form Windows
|
||||
|
||||
### What Changed
|
||||
|
||||
| Before iOS 26 | iOS 26+ |
|
||||
|---------------|---------|
|
||||
| Fixed Split View sizes | Free-form drag-to-resize |
|
||||
| `UIRequiresFullScreen` allowed | **Deprecated** |
|
||||
| No menu bar on iPad | Menu bar via `.commands` |
|
||||
| Manual column visibility | `NavigationSplitView` auto-adapts |
|
||||
|
||||
### Apple's Guideline
|
||||
|
||||
> "Resizing an app should not permanently alter its layout. Be opportunistic about reverting back to the starting state whenever possible."
|
||||
|
||||
**Translation:** Don't save layout state based on window size. When window returns to original size, layout should too.
|
||||
|
||||
### NavigationSplitView Auto-Adaptation
|
||||
|
||||
```swift
|
||||
// iOS 26: Columns automatically show/hide
|
||||
NavigationSplitView {
|
||||
Sidebar()
|
||||
} content: {
|
||||
ContentList()
|
||||
} detail: {
|
||||
DetailView()
|
||||
}
|
||||
// No manual columnVisibility management needed
|
||||
```
|
||||
|
||||
### Migration Checklist
|
||||
|
||||
- [ ] Remove `UIRequiresFullScreen` from Info.plist
|
||||
- [ ] Test at arbitrary window sizes (not just 33/50/66%)
|
||||
- [ ] Verify layout doesn't "stick" after resize
|
||||
- [ ] Add menu bar commands for common actions
|
||||
- [ ] Test Window Controls don't overlap toolbar items
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ Device Orientation Observer
|
||||
|
||||
```swift
|
||||
// ❌ WRONG: Reports device, not window
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: UIDevice.orientationDidChangeNotification, ...
|
||||
)
|
||||
|
||||
let orientation = UIDevice.current.orientation
|
||||
if orientation.isLandscape { ... }
|
||||
```
|
||||
|
||||
**Why it fails:** Reports physical device orientation, not window shape. Wrong in Split View, Stage Manager, iOS 26.
|
||||
|
||||
**Fix:** Use `onGeometryChange` to read actual window dimensions.
|
||||
|
||||
### ❌ Screen Bounds
|
||||
|
||||
```swift
|
||||
// ❌ WRONG: Returns full screen, not your window
|
||||
let width = UIScreen.main.bounds.width
|
||||
if width > 700 { useWideLayout() }
|
||||
```
|
||||
|
||||
**Why it fails:** In multitasking, your app may only have 40% of the screen.
|
||||
|
||||
**Fix:** Read your view's actual container size.
|
||||
|
||||
### ❌ Device Model Checks
|
||||
|
||||
```swift
|
||||
// ❌ WRONG: Breaks on new devices, wrong in multitasking
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
useWideLayout()
|
||||
}
|
||||
```
|
||||
|
||||
**Why it fails:** iPad in 1/3 Split View is narrower than iPhone 14 Pro Max landscape.
|
||||
|
||||
**Fix:** Respond to available space, not device identity.
|
||||
|
||||
### ❌ Unconstrained GeometryReader
|
||||
|
||||
```swift
|
||||
// ❌ WRONG: GeometryReader is greedy
|
||||
VStack {
|
||||
GeometryReader { geo in
|
||||
Text("Size: \(geo.size)")
|
||||
}
|
||||
Button("Next") { } // Crushed
|
||||
}
|
||||
```
|
||||
|
||||
**Fix:** Constrain with `.frame()` or use `onGeometryChange`.
|
||||
|
||||
### ❌ Size Class as Orientation Proxy
|
||||
|
||||
```swift
|
||||
// ❌ WRONG: iPad is .regular in both orientations
|
||||
var isLandscape: Bool {
|
||||
horizontalSizeClass == .regular // Always true on iPad!
|
||||
}
|
||||
```
|
||||
|
||||
**Fix:** Calculate from actual geometry if you need aspect ratio.
|
||||
|
||||
---
|
||||
|
||||
## Pressure Scenarios
|
||||
|
||||
### "Designer wants iPhone-specific layout"
|
||||
|
||||
**Temptation:** `if UIDevice.current.userInterfaceIdiom == .phone`
|
||||
|
||||
**Response:** "I'll implement these as 'compact' and 'regular' layouts that switch based on available space. The iPhone layout will appear on iPad when the window is narrow. This future-proofs us for Stage Manager and iOS 26."
|
||||
|
||||
### "Just use GeometryReader, it's fine"
|
||||
|
||||
**Temptation:** Wrap everything in GeometryReader.
|
||||
|
||||
**Response:** "GeometryReader has known layout side effects — it expands greedily. `onGeometryChange` reads the same data without affecting layout. It's backported to iOS 16."
|
||||
|
||||
### "Size classes worked before"
|
||||
|
||||
**Temptation:** Force everything through size class.
|
||||
|
||||
**Response:** "Size classes are coarse. iPad is `.regular` in both orientations. I'll use size class for broad categories and geometry for precise thresholds."
|
||||
|
||||
### "We don't support iPad multitasking"
|
||||
|
||||
**Temptation:** `UIRequiresFullScreen = true`
|
||||
|
||||
**Response:** "Apple deprecated full-screen-only in iOS 26. Even without active Split View support, the app can't break when resized. Space-based layout costs the same."
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
**WWDC**: 2025-208, 2024-10074, 2022-10056
|
||||
|
||||
**Skills**: axiom-swiftui-layout-ref, axiom-swiftui-debugging, axiom-liquid-glass
|
||||
3
.claude/skills/axiom-swiftui-layout/agents/openai.yaml
Normal file
3
.claude/skills/axiom-swiftui-layout/agents/openai.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "SwiftUI Layout"
|
||||
short_description: "Layouts need to adapt to different screen sizes, iPad multitasking, or iOS 26 free-form windows"
|
||||
Reference in New Issue
Block a user