Claude Skill

app-resizability

Use when an app's layout must adapt to a window that changes size while the app runs, or when an app must support more than one window. That includes being asked to make an app resizable or support a resizable window, a layout that breaks or does not adapt as the window resizes,

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download artemnovichkov-xcode-skills-skills_app-resizability-aa5c1cb.zip · 48 KB
Part of artemnovichkov/xcode-skills — 15 skills

Install

skills CLI npx skills add https://github.com/artemnovichkov/xcode-skills/tree/main/skills/app-resizability
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install artemnovichkov-xcode-skills@llmmart
Git git clone https://github.com/artemnovichkov/xcode-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole artemnovichkov/xcode-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

App Resizability Skill

When to Use

Use this skill whenever the request is about any of the following, whether or not it names an API:

  • Getting an app ready for, or optimizing it for, the foldable iPhone Duo.
  • An app that must adapt when its window changes size while it runs.
  • Split-screen multitasking, Stage Manager, or a resizable scene.
  • Any deprecated API named in the Scope section below.

The request does not have to be technical. "Get my app ready for the iPhone Duo", "optimize this app for the foldable iPhone", and "make this app resize properly" all activate this skill. iPhone Duo is the foldable iPhone, so treat questions about iPhone Duo or the foldable iPhone as the same request. Treat a request about the foldable iPhone Duo as a request for every task in the Task Registry, because a screen that changes size exposes all of them at once.

Purpose

Modernize UIKit apps to behave correctly on modern iOS by:

  • Eliminating references to legacy shared-state APIs
  • Migrating from application lifecycle to scene lifecycle
  • Supporting dynamic scene sizing and multi-window environments

Scope

This skill performs specific, targeted modernizations in both Swift and Objective-C codebases:

  • Replace legacy shared-state APIs with context-appropriate modern APIs
  • Migrate to scene-based lifecycle
  • Update apps to support a resizable user interface by removing usage of:
    • main screen (UIScreen.mainScreen, UIScreen.main)
    • interface orientation (interfaceOrientation)
    • user interface idiom (userInterfaceIdiom, UI_USER_INTERFACE_IDIOM())
    • assumptions of symmetric safe areas (safeAreaLayoutGuide, safeAreaInsets)
    • application lifecycle in place of scene lifecycle (UIApplicationDelegate)

Core Principles

  1. Closest to consumer — Prefer information nearest the point of use (e.g., view's trait collection over window's).

  2. Always apply a replacement when the target API is present. A TODO alone is a failure. An empty diff for a file containing the target API is also a failure. If the file contains the target deprecated API and a concrete replacement is feasible under any pattern in the active task's reference file, apply it. Only skip when the target API appears exclusively inside dead code (#if 0/#endif). When uncertain between two valid replacements, pick the one that best fits the user's request rather than producing an empty diff. Never silently skip a file: if you are unwilling to apply a change, talk to the user about possible options — never produce no output for it. Do not get stuck weighing edge cases on simple files; when the substitution is obvious, apply it and move on.

  3. TODOs must be actionable. State why the change is needed, what the replacement would look like, and any lifecycle or threading concerns. Place the TODO on its own line above the unchanged code, never inline. A vague TODO ("fix this later") is worse than none, because it consumes review attention and informs nobody.

    Indent every line of an inserted comment to match the line it precedes. A continuation line at a different indent leaves the block misaligned. Re-emitting the following line to realign it edits a line you were not asked to touch.

  4. Don't add a redundant TODO when an existing annotation already covers the migration. If the call site already has a #pragma clang diagnostic ignored paired with a bug-report reference, an existing // TODO, or a deprecation comment that points at the migration, do not add another one. Only add a new TODO when it provides additional migration guidance not present in the existing annotation.

  5. Ask the user before making a risky code change; fall back to a TODO only when interactive guidance is unavailable. When a replacement risks breaking callers or changing observable behavior (e.g., changing a method signature in a header that other modules import; substituting width > height for orientation when left-vs-right matters), the first move is to ask the user how to proceed. Only when the skill is running non-interactively, or when the user explicitly declines to provide guidance, drop a TODO and move on. This does not apply to standard, drop-in safe replacements specified by the active task's reference file — those must be applied per Core Principle 2.

  6. Honor explicit user instructions; otherwise apply the defaults from the task reference file. When the user asks for a specific approach — a particular attribute, parameter name, parameter position, trait source, or fallback behavior — use that exactly. Don't silently substitute what you consider the modern equivalent. When the user is general ("modernize this app", "fix UIScreen.main usages"), apply the defaults from the active task's reference file.

  7. Never replace dynamic values with literals — Always keep replacements dynamic.

  8. Preserve control flow — Prefer drop-in replacements that maintain the original code structure. Only add guard/early-return patterns when a direct substitution does not work. When editing code around control flow (if/else, switch/case/default, do/catch), verify that the branching structure is preserved after your edit. Never collapse an if/else into sequential execution: both bodies then run unconditionally, which is a critical bug. Keep every branch whose condition is sound, and replace only the deprecated value inside it. Remove a branch only when the active task names that condition itself as the defect and states that the correct behavior is unconditional. In that case delete the condition, keep the body that matches the correct behavior, and name the branch you removed in your summary so a reviewer can check it.

  9. Stay in scope — no opportunistic cleanup. Only modify lines containing the target deprecated API for the active task. Do NOT also fix other deprecation that happens to live nearby. Do NOT trim trailing whitespace, reformat blank lines, or "clean up" surrounding formatting. Even if you see an obvious modernization opportunity on an adjacent line, leave it alone — each task is independent and out-of-scope edits convert a successful in-scope change into a warning.

  10. Extract repeated expressions — When the same replacement value is used multiple times in a scope, extract it into a named local variable.

  11. Never walk global scene/window state — Never use UIApplication.shared, UIDevice.current, UIScreen.main, or other shared objects as a replacement. If no local object is available, modify the method to accept a new parameter and update its callers to pass one.

  12. Complete patterns — atomic, never partial — Every multi-part pattern the active task defines requires ALL of its parts applied together as a single unit. When the active task requires both an API replacement AND a reactive update (e.g., trait change observation), these form a single atomic change — never apply one without the other. If you cannot complete every part the task specifies, do not apply a partial change — either complete the pattern or skip with an explicit reason.

  13. Preserve unrelated guards and fallbacks. When removing a reference to the target API, change ONLY that reference. Do not simultaneously delete respondsToSelector: checks, nil guards, if (x != nil) defenses, version checks (#available, @available), or any other defensive logic that wraps the call site — unless the user explicitly asks for it. Each guard exists for an independent reason (selector availability across SDK versions, nil-window safety, feature flags); the modernization touches only the value you came to replace, not the surrounding control flow.

  14. Off-target replacement guard. Before editing any line, verify two things: (a) the line contains the target deprecated API for the active task, and (b) you're editing the deprecation the user asked about — not a nearby line that "looks similar."


Prerequisites

Run all three checks once per project, before Phase 1. These checks are read-only: add no key, change no value, and delete no key, in the Info.plist or in the build settings. Report what you read, then continue with the workflow. An app that fails a check cannot resize, whatever the source code says.

Read each key in the target Info.plist and in the build settings. Build settings live in the project file and in any .xcconfig. The build merges every source, so read them all before you report. A key missing from one source proves nothing. When two sources set the same key, the project file wins.

Skip all three checks when the request names files rather than a project. Say nothing about them. Do not search the file system for a project.

Check Already satisfied when Otherwise report
Launch screen The target sets UILaunchScreen, UILaunchScreens, UILaunchStoryboardName, UILaunchStoryboards, or INFOPLIST_KEY_UILaunchScreen_Generation The app declares no launch screen. iOS 27 rejects the App Store upload with ITMS-90870. Refer to TN3208. One key is enough, so leave an existing storyboard alone
iPad orientations The iPad declaration lists all four orientations, or the target declares none. UISupportedInterfaceOrientations~ipad is the iPad declaration, and plain UISupportedInterfaceOrientations is when that key is absent Name the orientations the declaration omits. Add that a supportedInterfaceOrientations override can still lock the scene, and that you did not look for one. Check iPad only: a portrait-only iPhone declaration is normal
Full screen opt-out UIRequiresFullScreen is absent, or UIRequiresFullScreenIgnoredStartingWithVersion is already set The target sets UIRequiresFullScreen. iOS 27 ignores it and resizes the scene anyway. Refer to TN3192

Both keys in the last row stay exactly as you found them. Never delete UIRequiresFullScreen, because deletion makes the app resizable at once and the layouts may not be ready. Never add UIRequiresFullScreenIgnoredStartingWithVersion, because its value decides which releases keep the old behavior, and that is the developer's decision. A value of 27 or earlier makes the app fully resizable on iOS 27, which is the opposite of a safe step. The key comes out after the app handles resizing. Confirming that means running the app and resizing it. One view controller can break while the rest is correct.


Workflow

Phase 0: Fast Path for Simple Cases

Before reaching for the decision tree, check if the occurrence matches the simple case. A large fraction of UIScreen.main/UIScreen.mainScreen occurrences are simple substitutions inside a UIView/UIViewController instance method where the value is consumed fresh. These cases need no analysis — just substitute and move on:

Original Replacement
UIScreen.main.scale (Swift) inside a UIView/UIViewController instance method, used inline (not stored) self.traitCollection.displayScale
[UIScreen mainScreen].scale (ObjC) inside a UIView/UIViewController instance method, used inline (not stored) self.traitCollection.displayScale
UIScreen.main.scale inside layoutSubviews, drawRect:, updateConstraints, or viewIsAppearing: self.traitCollection.displayScale (no registration needed — UIKit auto-calls these on trait change)

Do not over-think simple substitutions. If the enclosing class is UIView/UIViewController and the value isn't being assigned to an ivar, layer property, constraint, or stored image, just substitute. Empty diffs on simple files are the most common mistake — apply the substitution and move on. Reach for the decision tree only when the simple case doesn't fit (non-view class, cached value, class/static method, special user instructions).

Phase 1: Detection

Identify patterns to modernize using each relevant task file's detection patterns. Run detection for every task in the Task Registry that applies to this codebase, not just one — see Task Registry below.

Phase 2: Analysis

For each occurrence, read surrounding context to understand:

  • Class hierarchy (UIView/UIViewController subclass vs plain NSObject vs non-view class)
  • Method type (instance, static, free function, cached dispatch_once helper)
  • Lifecycle phase (init, viewDidLoad, viewWillAppear, layoutSubviews)
  • Code intent (what question the code was asking: available space, layout variant, rendering, device class, lifecycle)

The active task's reference file may add task-specific bullets to this list.

Use subagents to identify code that needs to be updated to keep your context window small.

Phase 3: Decision & Validation

Condition Action
Safe 1:1 replacement exists Apply it. No added commentary (no // TODO: FIXME, no // TODO, no // FIXME — just the replacement). Use the replacement specified by the active task's reference file.
Multiple valid approaches or code relocation >10 lines Ask the user.
No safe replacement possible (extremely rare) Add todo with an explicit task outlined for the user. Never produce a silent empty diff. Re-check every pattern with a subagent before concluding nothing applies.

Use subagents to validate against the active task's Post-file Checklist before any code change.

Phase 3b: File Processing Completeness

Process EVERY file that contains the target deprecated API. Do not stop early, skip files, or silently drop files from the work queue. A file that was identified in Phase 1 but produces no diff and no skip explanation is a processing failure.

Explicit file tracking: At the start of processing, write out the complete list of files to be modified using available task / todo tools or a markdown file. As you process each file, mark it done. Before finishing, compare this list against your output — any file without a diff or an explicit skip reason is a failure that must be addressed before completing.

Context size: If you are concerned about context size, use subagents to process individual files or tasks.

Silent-drop prevention: Before finishing, use subagents to compare the list of files you were given against the list of files you produced output for. If any file is missing from your output, go back and process it. Common causes of silent drops:

  • File size: Large files (1000+ lines) are not exempt. Process them with the same approach.
  • Complexity: Files with preprocessor macros, complex class hierarchies, or unusual code patterns still need changes.
  • Project grouping: Do not skip all files from a specific project or directory. If you notice you've dropped multiple files from the same project, that indicates a systematic issue — investigate and fix.
  • Ambiguity: If you're unsure how to fix a file, ask the user — do not silently produce an empty diff.

Large or complex files: Files with heavy preprocessor usage (#if/#ifdef nesting), 1000+ lines, or less common patterns (C++ interop, dispatch_once caching, deeply nested macros) are not exempt from processing. If the target API appears in such a file, apply the same decision tree. If the file is too large to edit in one pass, process the deprecated API usages one at a time. Use subagents if helpful. If you genuinely cannot determine a safe replacement due to macro expansion or preprocessor complexity, ask the user — never silently skip it.

Batch processing discipline: When processing a list of files, do NOT attempt to analyze all files first and then produce all diffs at once. Instead, process files one at a time or in small batches (3–5 files): read context, decide, produce the diff, then move to the next batch. This prevents the tail end of the file list from being silently dropped due to output limits or context exhaustion. If you notice you have produced output for fewer files than you were given, STOP and process the remaining files before finishing.

If you find empty diffs for files that should have straightforward replacements, go back and process them — straightforward files are fast to handle and should never be dropped.

Phase 4: Implementation

Apply the active task's implementation gates, rules, and post-file checklist from its reference file. The pattern-specific decision tree, gate questions, and validation rules live alongside the patterns they govern in each task file. Use subagents for verification.

Phase 5: Final Verification

File coverage audit: Use subagents to compare the list of files you were given (or detected in Phase 1) against the files you actually produced diffs for. Every input file must have a non-empty diff. If any file is missing changes, go back and process it now.

The active task's reference file may add task-specific verification steps.


Task Registry

Apply every task in this registry to the codebase unless the developer's request explicitly scopes to a subset. Each task is independent and has its own detection patterns, decision tree, and verification rules in its reference file. Run them in order from top to bottom.

Task File Description
UIScreen.main modernization uiscreen-task.md Replace UIScreen.main with context-appropriate APIs
userInterfaceOrientation modernization orientation-task.md Replace layout-related orientation checks with size classes or window bounds, and migrate the deprecated scene geometry callback
Scene lifecycle migration scene-lifecycle-task.md Migrate AppDelegate to SceneDelegate
Safe Area Insets safe-area-task.md Replace topLayoutGuide and bottomLayoutGuide, replace hard coded values for insets with safe area references, and ensure that existing references work with asymmetric safe areas and with insets that change while the app runs
userInterfaceIdiom modernization idiom-task.md Replace layout-related idiom checks with size classes, and read a genuinely needed idiom from the local trait collection
Files (xcode-skills)
  • references
    • idiom-task.md 15.7 KB
      # Task: User Interface Idiom Modernization
      
      ## Overview
      
      `userInterfaceIdiom` encodes a device class, not the space a view actually has. Apps already run at many sizes: Split View, Slide Over, Stage Manager, iPhone Mirroring, and external displays. An idiom check therefore does not predict how much room the layout has. `UIDevice.current` also reports the device rather than the environment the code is rendering in, so it can disagree with the context the view is actually in.
      
      **Detection patterns:**
      
      - `UIDevice.current.userInterfaceIdiom` / `UIDevice.currentDevice.userInterfaceIdiom`, and the ObjC bracket form `[[UIDevice currentDevice] userInterfaceIdiom]`
      - `UITraitCollection.current.userInterfaceIdiom`
      - `UIUserInterfaceIdiomPad` / `UIUserInterfaceIdiomPhone`, and `.pad` / `.phone` in Swift. Also `.tv`, `.carPlay`, `.mac`, and `.vision` when they gate layout
      - `UI_USER_INTERFACE_IDIOM()` — deprecated since iOS 13
      - `#if targetEnvironment(macCatalyst)` and `ProcessInfo.processInfo.isiOSAppOnMac` when either drives layout
      - App-defined idiom macros and helpers: `IS_PAD`, `isiPad`, `isPad`, `isPhone`, `deviceIsPad`
      - A layout decision that already reads `traitCollection.userInterfaceIdiom` locally. Reading it from the right place does not make it the right question; Pattern 1 still applies
      
      App-defined helpers are the target site. Fix the helper, not only its callers. Renaming such a helper and updating its call sites in the same file is in scope, even though the call-site lines do not themselves contain the target API.
      
      ---
      
      ## Scope: Layout and Available-Space Uses Only
      
      **Migrate uses that decide layout or react to available space.** A use is in scope if it:
      
      - Chooses a layout variant (one column vs two, sidebar vs stack, grid column count)
      - Decides whether UI is shown, hidden, or relocated to reclaim space
      - Sets sizes, spacing, insets, or font metrics that exist because "iPad is bigger"
      - Picks a presentation style that is really about room (`.formSheet`/`.popover` vs full screen)
      
      **Leave out of scope, with no change and no TODO:** device-specific asset or resource names, analytics and telemetry dimensions, `.vision` / `.tv` / `.carPlay` platform branches, App Store or entitlement gating, and test fixtures that construct a specific idiom deliberately.
      
      **Out of scope, so leave it alone here:** an idiom check driving `supportedInterfaceOrientations` or any other orientation mask. Do not convert it to a size class, and do not remove any existing comment or TODO attached to it.
      
      **One exception, and it is a source change only.** An idiom read that is correct as written, and that drives UI behavior, must read from the local trait collection when one is reachable, keeping the same comparison and the same branches. This includes a hardware capability gate, such as a pointer or hover affordance. The trait reflects the scene the code renders in, and `UIDevice.current` cannot, because one process can drive scenes with different idioms. When no trait collection is reachable, and reaching one would mean changing a signature or its callers, ask the user how to proceed (Core Principle 5). A read that reports the device rather than the environment keeps `UIDevice.current`, including analytics dimensions and per-device asset names.
      
      ---
      
      ## Step 1: Classify the Purpose
      
      | Category                     | How to recognize                                                    | Replacement                                 |
      | ---------------------------- | ------------------------------------------------------------------- | ------------------------------------------- |
      | **Available space**          | Two columns, sidebar, popover, wider margins on pad                 | Horizontal size class                       |
      | **Constrained height**       | Hides a bar or shrinks a header on phone                            | Vertical size class                         |
      | **Genuine idiom dependence** | Any decision that truly turns on idiom, not on space                | Keep idiom, but read it locally (Pattern 2) |
      | **Product decision**         | Feature exists on one device but not because of space               | Ask the user (Core Principle 5)             |
      
      When the intent is not clear from the code, it is the fourth row. Ask; do not guess which size class the designer meant.
      
      ---
      
      ## Step 2: Apply the Correct Replacement
      
      ### Pattern 1: Available space → size class
      
      | Original intent | Replacement |
      |---|---|
      | "iPad, so there is room for the wide layout" | `traitCollection.horizontalSizeClass == .regular` |
      | "iPhone, so use the narrow layout" | `traitCollection.horizontalSizeClass == .compact` |
      | "iPhone landscape, so vertical space is tight" | `traitCollection.verticalSizeClass == .compact` |
      
      ```swift
      // Before
      if UIDevice.current.userInterfaceIdiom == .pad {
          stackView.axis = .horizontal
      } else {
          stackView.axis = .vertical
      }
      
      // After
      if traitCollection.horizontalSizeClass == .regular {
          stackView.axis = .horizontal
      } else {
          stackView.axis = .vertical
      }
      ```
      
      Use `self.traitCollection` in `UIView` and `UIViewController` subclasses. Never substitute `UITraitCollection.current`, and never reach `UIDevice.current` or `UIScreen.main` for this.
      
      **The mapping is not one to one, and that is the point.** An iPad in a narrow Split View reports `.compact` horizontally, and a large iPhone in landscape reports `.regular`. The old code asked the wrong question; the replacement asks about space, which is what the layout needed.
      
      **Pick the axis the code is actually about.** Width decisions (columns, sidebars, popovers, horizontal margins) use `horizontalSizeClass`. Height decisions use `verticalSizeClass`:
      
      ```swift
      // Before — hides the tall header on phone because vertical room is tight
      headerView.isHidden = UIDevice.current.userInterfaceIdiom == .phone
      
      // After
      headerView.isHidden = traitCollection.verticalSizeClass == .compact
      ```
      
      Mapping a height decision onto `horizontalSizeClass` is the most common inversion. A phone in landscape is `.compact` vertically and can be `.regular` horizontally, so the two are not interchangeable.
      
      ### Pattern 2: Genuine idiom dependence → read the idiom locally
      
      When the check really is about idiom, keep the check and change where the value comes from. `UITraitCollection` carries `userInterfaceIdiom`, and it reflects the environment the view is in, which is what the code wants. `UIDevice.current.userInterfaceIdiom` describes the hardware and does not vary by context, so prefer the trait collection and treat `UIDevice.current` as a last resort.
      
      ```swift
      // Before — the Mac idiom draws a window title bar, so the in-app header duplicates it
      customHeaderView.isHidden = UIDevice.current.userInterfaceIdiom == .mac
      
      // After
      customHeaderView.isHidden = traitCollection.userInterfaceIdiom == .mac
      ```
      
      An `else` branch keeps working unchanged, because `UIUserInterfaceIdiomUnspecified` falls to it exactly as it did before the edit. No extra handling is required.
      
      If the stored result of a Pattern 2 check is cached, it needs invalidation like any other trait-derived value. Refer to the invalidation section below.
      
      ### Where to read the trait collection from
      
      | Enclosing type | Source |
      |---|---|
      | `UIView` / `UIViewController` subclass, instance method | `self.traitCollection` |
      | Non-view class holding a view or view controller (property or parameter) | That object's `traitCollection`, preferring the most local one |
      | Non-view class with no view reachable at all | Add a `traitCollection: UITraitCollection` parameter and update the callers to pass their own |
      
      A read does not need new API when a view is reachable. Use the reachable object's trait collection and make the smallest edit.
      
      ### Pattern 3: Read what an app-defined helper actually tests
      
      An `IS_PAD` macro or `isiPad` helper is not always a plain `idiom == .pad`. Some cover additional cases, and Mac Catalyst reports the `.pad` idiom unless the app is built as Optimized for Mac, so a helper written years ago may be relied on to be true there. Read the definition before rewriting any call site; narrowing it to `idiom == .pad` can silently change behavior.
      
      For a layout use, Pattern 1 removes the question entirely, because a size class check covers every case the helper covered. When the use is genuinely about idiom, preserve each case the helper tested:
      
      ```objc
      // Before — helper covers more than one idiom
      if (IS_PAD) { ... }
      
      // After — genuine idiom use, every case the helper covered is preserved
      UIUserInterfaceIdiom idiom = self.traitCollection.userInterfaceIdiom;
      if (idiom == UIUserInterfaceIdiomPad || idiom == UIUserInterfaceIdiomMac) { ... }
      ```
      
      **A global or `static` helper is deleted, not rewritten.** A `static let isPad = UIDevice.current.userInterfaceIdiom == .pad` has no environment and no trait collection to read, and it evaluates once per process, so it can never follow a resize. Delete it and read the size class in each view that used it. This is the one case where the fix lands at the call sites rather than at the helper.
      
      ---
      
      ## Invalidation: the replaced value is no longer fixed
      
      This is the regression this migration introduces. `UIDevice.current.userInterfaceIdiom` is fixed for the life of the process, so caching a decision derived from it at `init` was safe. Both replacements are traits, and traits change: a size class changes whenever the scene resizes, and the trait collection's `userInterfaceIdiom` is inherited per trait environment rather than being a process-wide constant. The same cached decision that was safe before the edit is now stale.
      
      Whenever a trait drives a stored value (a constraint constant, an ivar, a configured subview, a cached layout choice), register for the change:
      
      ```swift
      registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (self: MyView, previousTraitCollection) in
          self.updateLayoutForSizeClass()
      }
      ```
      
      > **ObjC:** `[self registerForTraitChanges:@[UITraitHorizontalSizeClass.class] withHandler:^(typeof(self) self, UITraitCollection *previousTraitCollection) { ... }]`, or `withAction:@selector(updateLayoutForSizeClass)`.
      
      Register for the trait you actually read: `UITraitHorizontalSizeClass`, `UITraitVerticalSizeClass`, or `UITraitUserInterfaceIdiom` for a cached Pattern 2 value. Where an `update…` method already exists, the handler calls it by name rather than duplicating its body.
      
      **These APIs need iOS 17, Mac Catalyst 17, tvOS 17, or visionOS 1, or later.** `registerForTraitChanges` and its trait types, such as `UITraitHorizontalSizeClass`, do not exist before then. With an earlier deployment target, put the same recalculation in `traitCollectionDidChange:`, which UIKit still calls on later releases.
      
      **A trait read during `init` is not yet meaningful.** A view that is not in a window reports `UIUserInterfaceSizeClassUnspecified`, so setup code reading the size class in `initWithFrame:` or `awakeFromNib` gets the fallback branch. The registration is what delivers the first correct value, which is another reason it is not optional here.
      
      **Two ways to satisfy this, and either is acceptable.** Register and recalculate as above, or move the read to the point of use so nothing is cached. From those same releases, UIKit tracks the traits an override reads from its own trait collection, then invalidates that method when one of those traits changes. Registration is therefore not needed inside `layoutSubviews`, `drawRect:`, `updateConstraints`, `viewWillLayoutSubviews`, `viewDidLayoutSubviews`, `updateViewConstraints`, or `updateConfiguration`. `updateProperties` joins them in iOS 26, and [Automatic trait tracking](https://developer.apple.com/documentation/UIKit/automatic-trait-tracking) lists every supported method. This is OS behavior rather than new API, so an existing binary gets it with no rebuild. Pick one; do not leave a cached value with no invalidation path.
      
      **When the enclosing type cannot register.** `registerForTraitChanges` requires a `UITraitChangeObservable`, which a plain `NSObject` helper is not. In that case do not cache: read the trait from the reachable view at the moment the value is used, rather than once in a stored property or `lazy var` initializer. Moving the read is the fix; a TODO is not.
      
      ---
      
      ## SwiftUI
      
      The replacement is the environment value:
      
      ```swift
      @Environment(\.horizontalSizeClass) private var horizontalSizeClass
      @Environment(\.verticalSizeClass) private var verticalSizeClass
      
      // Before
      .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 32 : 16)
      if UIDevice.current.userInterfaceIdiom != .phone { heroHeader }
      
      // After — test `.regular`, so a nil size class takes the narrow branch
      .padding(.horizontal, horizontalSizeClass == .regular ? 32 : 16)
      if verticalSizeClass == .regular { heroHeader }
      ```
      
      nil means the environment has no size class, the same state UIKit reports as `unspecified`. Write "there is room" as `== .regular`, never as `!= .compact`: with an Optional the two differ, because `!= .compact` is also true when the size class is absent.
      
      A height decision reads `@Environment(\.verticalSizeClass)` instead. The axis rule from Pattern 1 applies unchanged.
      
      **Nothing to register for.** SwiftUI re-evaluates the body when the environment changes, so a `View` that reads the environment directly needs none of the invalidation work above.
      
      **When there is no environment to read.** The size class describes the space available to the view that reads it, so read it in a `View` or a `ViewModifier`. `@Environment` also resolves in an `App` or a `Scene`, where there is no such view, so do not drive layout from it. A model, view model, or other non-`View` type receives the size class as a parameter on each call, or the decision moves into the view. Do not store it on that type unless absolutely necessary. Doing so adds the burden of keeping it up to date on every resize.
      
      **At the UIKit boundary.** In a `UIViewRepresentable` or a `UIViewControllerRepresentable`, read `context.environment.horizontalSizeClass` in `makeUIView` and `updateUIView`. A SwiftUI view hosted in a `UIHostingController` inherits the host's traits, so its environment already reflects the host and needs no plumbing.
      
      **Pattern 2 has no SwiftUI equivalent.** There is no idiom environment value, so leave a remaining `UIDevice.current.userInterfaceIdiom` in place rather than inventing a source.
      
      ---
      
      ## Post-file Checklist
      
      - [ ] Every migrated site was a layout or available-space decision, not a product or capability decision?
      - [ ] An idiom-derived orientation mask (`supportedInterfaceOrientations`) left unconverted?
      - [ ] Replacement reads a local trait collection, not `UITraitCollection.current`, `UIDevice.current`, or `UIScreen.main`?
      - [ ] Non-view class with a reachable view → used that view's trait collection, without adding a parameter it did not need?
      - [ ] Non-view class with no reachable view → signature changed and callers updated?
      - [ ] A trait drives a stored value → either `registerForTraitChanges` with a handler that recalculates it, or the read was moved to the point of use?
      - [ ] Cached Pattern 2 value → registered for `UITraitUserInterfaceIdiom`, not a size class trait?
      - [ ] SwiftUI view reads `@Environment(\.horizontalSizeClass)` or `@Environment(\.verticalSizeClass)`, not `UIDevice.current`?
      - [ ] App-defined idiom helper → its definition was read, and every idiom case it covered is preserved?
      - [ ] App-defined idiom helper fixed at the helper, not only at its call sites?
      - [ ] Out-of-scope uses (assets, analytics, platform branches, tests) left untouched, with no TODO?
      - [ ] Branch count unchanged? An idiom `if`/`else` stays an `if`/`else`.
      - [ ] Vertical space decision mapped to `verticalSizeClass`, not `horizontalSizeClass`?
      
      ## API Reference
      
      - [UITraitCollection](https://developer.apple.com/documentation/uikit/uitraitcollection)
      - [EnvironmentValues.horizontalSizeClass](https://developer.apple.com/documentation/swiftui/environmentvalues/horizontalsizeclass)
      
    • orientation-task.md 11.1 KB
      # Task: userInterfaceOrientation Modernization
      
      ## Overview
      
      `userInterfaceOrientation` (on `UIApplication` and `UIViewController`) and `orientation` on `UIDevice` encode orientation as an enum. Layout code that branches on orientation does not adapt to modern iOS — under multitasking, Stage Manager, and resizable scenes, "portrait vs landscape" no longer maps cleanly to the available space.
      
      **Detection patterns:**
      
      - `UIApplication.shared.statusBarOrientation`
      - `UIApplication.shared.windows` + orientation
      - `UIDevice.current.orientation`
      - `self.interfaceOrientation` (deprecated UIViewController)
      - `UIWindowScene.interfaceOrientation` (deprecated UIWindowScene)
      - `UIWindowScene.Geometry.interfaceOrientation` in a layout decision (current API, not deprecated)
      - `windowScene(_:didUpdate:interfaceOrientation:traitCollection:)` (deprecated UIWindowSceneDelegate)
      - Any comparison against `UIInterfaceOrientation` cases (`.portrait`, `.landscapeLeft`, etc.)
      
      ## The scene's interface orientation is not the replacement
      
      `UIWindowScene.Geometry.interfaceOrientation`, reached as `windowScene.effectiveGeometry.interfaceOrientation`, is current API. Being current does not put it out of scope. A layout decision that reads it is a detection target, and it takes the same replacement as every other entry: a size class or a bounds comparison, per Step 2.
      
      Interface orientation describes how the UI is rotated relative to the device reference. It does not encode an aspect ratio. A scene reporting a landscape orientation can be taller than wide, as a narrow iPad Split View column is. So rewriting `UIApplication.shared.statusBarOrientation` as `windowScene.effectiveGeometry.interfaceOrientation` renames the API and keeps the defect this task exists to remove. The same holds for the deprecated `UIWindowScene.interfaceOrientation`, whose modern spelling is the geometry property, not a different value.
      
      Some code needs the interface orientation rather than the shape of the space. Two cases: distinguishing landscape-left from landscape-right, and driving a rotation transform or an animation direction. Those are not an invitation to swap one orientation source for another. Pattern 2 lists them as cases where the output is a TODO and the existing source stays put.
      
      ---
      
      ## Scope: Layout-Related Uses Only
      
      **Only migrate uses that drive layout.** A use is layout-related if it:
      - Appears in a `UIView` or `UIViewController` subclass (or extension)
      - Appears in layout related methods like `layoutSubviews`, `updateProperties`, etc.
      - Drives frame calculations, constraint setup, or visibility of UI elements
      - Controls layout direction (horizontal vs vertical stacking)
      
      **Leave non-layout uses alone** (camera capture, motion sensors, analytics, video recording). Add no TODO, make no change.
      
      **One exception, in a scene delegate.** Pattern 4 migrates a deprecated `UIWindowSceneDelegate` callback. It therefore applies outside a view or view controller, and to work that is not layout, such as regenerating assets sized to the scene. The deprecated method is the target there. The work inside the callback is what the guard protects. Every other pattern in this task keeps the view and layout restriction above.
      
      ### Orientation Locking (Non-Layout)
      
      For apps locking orientation (e.g., games), the modern API is `prefersInterfaceOrientationLocked` (iOS 26+). Override in VC and call `setNeedsUpdateOfPrefersInterfaceOrientationLocked()` when preference changes.
      
      Outside this task's auto-fix scope. When encountering `supportedInterfaceOrientations` or forced orientation APIs, add a TODO:
      
      ```swift
      // TODO: Modernization - Consider adopting `prefersInterfaceOrientationLocked` (iOS 26+)
      // as the modern replacement for orientation locking via `supportedInterfaceOrientations`.
      ```
      
      ---
      
      ## Step 1: Classify the Purpose
      
      | Category | How to recognize | Replacement approach |
      |----------|-----------------|---------------------|
      | **Constrained space removal** | Hides/removes UI in landscape to reclaim space | Size class check |
      | **Aspect ratio detection** | Checks wider-than-tall to choose layout variant | Superview bounds comparison |
      | **Subview flow direction** | Chooses horizontal vs vertical stacking | Size class or superview bounds |
      
      ---
      
      ## Step 2: Apply the Correct Replacement
      
      ### Pattern 1: Constrained Space → Size Class
      
      | Original intent | Replacement |
      |----------------|-------------|
      | Narrow horizontal space (landscape iPhone) | `traitCollection.horizontalSizeClass == .compact` |
      | Narrow vertical space (landscape iPhone hiding toolbar) | `traitCollection.verticalSizeClass == .compact` |
      
      Use `self.traitCollection` in view/VC subclasses — never `UITraitCollection.current` when an instance is available.
      
      ---
      
      ### Pattern 2: Aspect Ratio → Compare Window Bounds (only when clearly equivalent)
      
      **Do NOT replace with `width > height` heuristics when:**
      - Code distinguishes **landscape-left vs landscape-right** — window bounds cannot distinguish these
      - Orientation drives **animation direction or rotation transforms** — these depend on actual orientation
      - Replacement requires inventing heuristics (checking `window.transform`) — never do this
      
      In these cases, add a TODO explaining why bounds cannot substitute. Do not swap the source of the orientation either. `UIDevice.current.orientation` and `windowScene.effectiveGeometry.interfaceOrientation` are different values, and which one a rotation transform wants depends on what the content is rotating against. The TODO is the output here, and the developer decides which value the code wants.
      
      **When replacement IS clearly equivalent (simple portrait-vs-landscape for layout):**
      
      ```swift
      // After
      if view.bounds.height > view.bounds.width {
          useVerticalLayout()
      } else {
          useHorizontalLayout()
      }
      ```
      
      In view controller subclasses using `view` to check for the available size is correct. In view subclasses, using `superview` is appropriate.
      
      ---
      
      ### Pattern 3: Subview Flow Direction → Size Class or View Bounds
      
      Choose based on context:
      - Decision "compact vs regular" → use size class (Pattern 1)
      - Decision purely geometric ("wider than tall") → use view bounds (Pattern 2)
      
      ```swift
      // Geometric — is the available space taller than wide?
      stackView.axis = view.bounds.height > view.bounds.width ? .vertical : .horizontal
      
      // Trait-based — compact width means stack vertically
      stackView.axis = traitCollection.horizontalSizeClass == .compact ? .vertical : .horizontal
      ```
      
      ---
      
      ### Pattern 4: Deprecated Scene Callback → didUpdateEffectiveGeometry
      
      `windowScene(_:didUpdate:interfaceOrientation:traitCollection:)` is deprecated. The replacement for its geometry work is `windowScene(_:didUpdateEffectiveGeometry:)`.
      
      **Everything in this pattern requires iOS 26 or later.** The replacement callback, `effectiveGeometry.coordinateSpace`, and `isInteractivelyResizing` all arrived in iOS 26.
      
      - **Target is iOS 26 or later.** Apply the pattern. Replace the deprecated method.
      - **Target is earlier.** Leave the deprecated method as it is. Add a TODO naming the replacement, and do not add the new callback. Do not use `@available`: a wrapped implementation does not run below the floor. Removing the deprecated method as well stops the app responding to geometry changes there. Supporting both releases needs both methods, which is a larger change than this task makes.
      - **No target is discoverable**, as in a single file with no project. Assume the current SDK and apply the pattern.
      
      **The deprecated method has four triggers, and the new one covers three.** The old callback fires on the coordinate space, the interface orientation, the trait collection, and a move between screens. UIKit guarantees both methods on a screen move, so screen-change work carries straight over. `UIWindowScene.Geometry` carries no trait, so the new callback never fires for a trait change alone.
      
      Read the body before you delete the method. A body that depends on the trait collection needs a second destination. The deprecation attribute names it: traits inherited from the scene, read through the `traitCollection` of a view or a view controller. Move that work to `registerForTraitChanges` on the view that owns the value. A Dark Mode change, a Dynamic Type change, or a contrast change alters the traits and leaves the geometry alone. Moving such a body into the new callback alone stops the update running.
      
      The rename alone is incomplete. Both parts below are required together, per Core Principle 12.
      
      1. Implement `windowScene(_:didUpdateEffectiveGeometry:)` in place of the deprecated method. Carry over the geometry work and any screen-change work. Move trait work to the view that owns it.
      2. Guard the body. Compare the value the work depends on against a copy the delegate stored, and do the work only when that comparison shows a change. Where the work is expensive and keyed on the scene's size, also require that the interaction has finished, by checking `isInteractivelyResizing` on the geometry.
      
      **Read the current geometry from `windowScene.effectiveGeometry`, and do not read the parameter.** The parameter is the geometry as it was before this change; the body needs the geometry as it is now. Read the current value from the scene, and compare it against a copy the delegate stored on the previous call.
      
      A body migrated from the deprecated callback often reads `windowScene.coordinateSpace` directly. That property is deprecated too, replaced by `effectiveGeometry.coordinateSpace` in the same release as this callback. Migrate that read in the same edit.
      
      This is the one property outside this task's detection list that the task touches, and the exception is deliberate. The read sits inside the method being rewritten, so it is part of the change and not nearby code (Core Principle 9). Do not migrate such a read anywhere else in the file.
      
      ```swift
      class SceneDelegate: UIResponder, UIWindowSceneDelegate {
          private var lastSize: CGSize = .zero
      
          func windowScene(_ windowScene: UIWindowScene, didUpdateEffectiveGeometry previousEffectiveGeometry: UIWindowScene.Geometry) {
              let geometry = windowScene.effectiveGeometry
              let size = geometry.coordinateSpace.bounds.size
              guard !geometry.isInteractivelyResizing, size != lastSize else { return }
              lastSize = size
              rebuildExpensiveContent(for: size)
          }
      }
      ```
      
      A body that does its work on every call is a regression rather than a migration. The callback arrives far more often than the size changes during an interactive resize. An unguarded body therefore runs its work repeatedly across one drag.
      
      `isInteractivelyResizing` answers one question: is a user interaction resizing the scene right now. The callback runs again with the final geometry once the drag ends. The deferred work still happens, once instead of every frame.
      
      Two limits on that check. It belongs on expensive work only, such as regenerating assets, rasterizing tiles, or fetching over the network. Constraints, `layoutSubviews`, and ordinary layout are built to run every frame, so gating them makes the resize lag the drag.
      
      The check also does not replace the comparison. The callback reports any change to the scene's geometry, orientation and lock state included. Without the size comparison, the work reruns when the size it depends on did not change.
      
    • safe-area-task.md 22 KB
      # Task: Safe Area Inset Modernization
      
      ## Overview
      
      Older layouts hardcoded the heights of status bars (20pt), navigation bars (44pt), tab bars (49pt), and home indicators (34pt). They positioned content with `topLayoutGuide` and `bottomLayoutGuide`. Modern iOS exposes the same geometry through `safeAreaInsets` and `safeAreaLayoutGuide`, which already encode the current device, orientation, and split view configuration. Update code that hardcodes those numbers, re-uses one edge's inset for the opposite edge, or infers display geometry from inset values.
      
      Two more assumptions break on modern iOS. Both appear in code that uses `safeAreaInsets` correctly in other respects.
      
      **An inset can hold a leading or trailing edge for the life of the scene.** On some devices, and in some configurations, the system puts a vertical bar on one of those edges. The bar arrives as a horizontal safe area inset. That inset stays for as long as the configuration lasts. It is not a landscape condition, and it is not a sensor housing. Code that treats a horizontal inset as a temporary landscape artifact puts content under the bar.
      
      **An inset can change at any time, including with no change in size.** A scene resizes while the app runs. For example: a window is dragged, display dimensions change, a split view collapses, or a bar appears or disappears. An inset read once is wrong from the first change onward.
      
      An inset derived from a width or a size class is worse. It is wrong whenever the inset changes and the size does not. A vertical bar does exactly that.
      
      **Detection patterns:**
      
      - Deprecated guides:
        - `topLayoutGuide`, `bottomLayoutGuide`
      - Hardcoded bar heights used as constraint constants or in `UIEdgeInsets`:
        - Common literal values to look for: `20` (status bar), `44` (navigation bar), `64` (status + nav), `88` (status + large nav), `34` (home indicator), `49` (tab bar), `83` (tab + home indicator).
        - Patterns: `.constant = <literal>` for those values, `UIEdgeInsetsMake(<literal>, ...)`, `UIEdgeInsets(top: <literal>, ...)`.
      - Symmetry and asymmetry mistakes with `safeAreaInsets`:
        - The same edge accessor used on opposite anchors, for example `safeAreaInsets.left` applied to leading **and** trailing, in a ternary or a paired calculation.
        - `max(safeAreaInsets.left, safeAreaInsets.right)` applied to both sides.
        - Threshold checks such as `safeAreaInsets.top > <literal>`, `safeAreaInsets.left > 0`, or `safeAreaInsets.bottom > 0` used as a proxy for display geometry.
        - `UIDevice` model checks gating layout decisions.
      - A horizontal inset treated as an orientation condition:
        - A horizontal inset read only inside a landscape check, a `bounds.width > bounds.height` comparison, or a size class check.
        - A leading or trailing constraint whose constant is zero in one orientation and an inset in the other.
      - A stored inset:
        - A safe area inset assigned to a property, an ivar, a `lazy var`, or a `static let`.
        - An inset read in `init`, `viewDidLoad`, `awakeFromNib`, `viewWillAppear`, or any one-time setup method.
        - A constraint constant computed once from an inset with no path to update it.
        - `additionalSafeAreaInsets` set from a stored or previously read value.
      - An inset derived rather than read:
        - A helper that returns insets given a width, a size class, a screen, or a device model.
      - An inset read from the wrong object:
        - `view.window?.safeAreaInsets`, `UIApplication.shared` reached for insets, or an ancestor's insets used for a child's layout.
      - Content pinned to the wrong edges:
        - A view holding text, controls, or a list constrained to the superview's `topAnchor`, `bottomAnchor`, `leadingAnchor`, or `trailingAnchor` rather than to the safe area guide.
        - A full-bleed layer and its content pinned to the same edges, so the content bleeds with the background.
      - Scroll view inset handling:
        - `contentInsetAdjustmentBehavior` set to `.never`.
        - `contentInset` or `scrollIndicatorInsets` assigned from `safeAreaInsets`.
        - `adjustedContentInset` read and then a safe area inset added on top of it.
      - Layout margin asymmetry and system minimum overrides:
        - The same edge accessor used on opposite anchors, for example `layoutMargins.left` applied to leading **and** trailing, in a ternary or a paired calculation.
        - `max(layoutMargins.left, layoutMargins.right)` applied to both sides.
        - `viewRespectsSystemMinimumLayoutMargins = NO` or `false` without a justifying comment.
      - Keyboard avoidance computed by hand:
        - A `UIKeyboardWillShowNotification` or `keyboardWillChangeFrame` observer that reads `UIKeyboardFrameEndUserInfoKey` and applies it to a constraint, an inset, or a frame.
        - A keyboard frame used without converting it from screen coordinates, or converted once and stored.
      - Manual frame math:
        - Hardcoded numeric offsets in `layoutSubviews`, `viewWillLayoutSubviews`, or manual `frame =` assignments that must derive from `safeAreaInsets`.
      - SwiftUI:
        - `ignoresSafeArea()` called with no edges.
        - Bar content placed in a `ZStack` or an `overlay` with its own bottom or side padding.
        - Hardcoded horizontal padding that stands in for a side inset.
        - `GeometryReader` used to read insets for a layout decision.
        - An inset from a `GeometryProxy` applied as padding inside the view that reported it.
        - A bar edge inferred from a size class, a width comparison, or a device check rather than read from the environment.
      
      Read the surrounding context before you treat any literal as a target. Confirm it is a bar offset and not a font size, an animation duration, or a touch target dimension.
      
      ---
      
      ## Rules
      
      Audit the code and apply the following changes.
      
      Every rule applies to Swift and to Objective-C, except Rule 13, which is SwiftUI only. The API names are the same in both languages.
      
      ## 1. Replace deprecated layout guides
      
      Both guides are `UIViewController` properties, and `UIViewController` has no safe area guide of its own. Every replacement therefore goes through `view`. Map each spelling:
      
      - `topLayoutGuide.bottomAnchor` → `view.safeAreaLayoutGuide.topAnchor`
      - `bottomLayoutGuide.topAnchor` → `view.safeAreaLayoutGuide.bottomAnchor`
      - `topLayoutGuide.length` → `view.safeAreaInsets.top`
      - `bottomLayoutGuide.length` → `view.safeAreaInsets.bottom`
      - `topLayoutGuide.topAnchor` → `view.topAnchor`
      
      The last mapping is the trap. That anchor is the top of the view, not the top of the safe area. Mapping it to the guide pushes content down by the whole top inset.
      
      ## 2. Fix hardcoded status bar and navigation bar offsets
      
      Remove literals such as `20`, `44`, `64`, `88`, `34`, `49`, and `83` used to clear a bar or a home indicator. Constrain to `safeAreaLayoutGuide` instead. For manual layout, read `safeAreaInsets` in `layoutSubviews`.
      
      ## 3. Constrain to the safe area instead of superview edges
      
      - Pin to `safeAreaLayoutGuide` anchors when a view must not underlap a bar or a device inset.
      - Pin to the superview when a view must extend under bars, such as a background fill or a media surface.
      - For a scroll view, pin the frame to the superview and let the scroll view adjust its own content inset. Rule 7 covers that case.
      
      ## 4. Layout margins
      
      - Do NOT assume left and right layout margins are equal. Read each edge's own value instead of reusing one side for the other.
      - Report `viewRespectsSystemMinimumLayoutMargins = false` and change nothing. It narrows the margins below the system minimum, which may be deliberate, and the file rarely says why. Tell the developer where it is and let them decide.
      - Use `layoutMarginsGuide` for content that must be inset by the system standard amount.
      
      ## 5. Handle `safeAreaInsets` in manual layout
      
      - Replace hardcoded inset values in `layoutSubviews` and in manual frame math with `safeAreaInsets` from the view being laid out.
      - Read the inset in the layout method itself. Rule 9 states why a value read anywhere else goes stale.
      
      ## 6. Remove assumptions about inset symmetry and hardware placement
      
      **Asymmetric horizontal insets are the normal case.** Where a vertical bar is present, one horizontal edge usually carries the whole inset and the other carries zero. Which edge carries it changes with the orientation and with the display in use.
      
      The inset is also much larger than the horizontal insets of earlier devices. Symmetric-inset code gives away, or overdraws, a large part of the width. Treat it as wrong by default.
      
      - Do NOT assume left and right insets are equal. Either edge can carry an inset while the other carries none. The two values are unrelated.
      - Do NOT assume top and bottom insets are equal, or that one follows from the other. They vary independently by device, orientation, and window size.
      - Do NOT treat a horizontal inset as a landscape condition. It can be present in portrait, in landscape, in a window, and in a split view column. An orientation branch leaves content under the inset everywhere else.
      - Do NOT infer which edge carries an inset from the device, the model, the orientation, the size, or the size class. Orientation is one input among several, so it is never sufficient on its own. This holds for a sensor housing, a notch, a Dynamic Island, and a vertical bar alike. Interface orientation is a poor proxy: two physically different poses report the same orientation and the same geometry.
      - Read each edge's own inset value. An average, a maximum, or one edge applied to both sides collapses an asymmetric pair into one number. No single number is correct for both edges.
      - Do NOT test an inset against a threshold to decide whether a safe area exists. A layout never needs to know whether there is an inset, only how large it is, and applying a zero inset is already correct. So delete the test and the branch it gates, and apply the inset directly.
      - **Subtracting both horizontal insets from one total width is correct.** `bounds.width - safeAreaInsets.left - safeAreaInsets.right` accounts for both edges and needs no direction mapping. Leave that expression alone.
      
      Watch for these shapes:
      
      - `safeAreaInsets.top` used for both top and bottom.
      - `safeAreaInsets.left` used for both left and right.
      - One "horizontal inset" computed from `safeAreaInsets.left` and applied to both sides.
      - `max(safeAreaInsets.left, safeAreaInsets.right)` applied to both sides.
      - A device model string or a `UIDevice` check used to locate a hardware obstruction.
      - A named hardware constant subtracted from an inset, such as a sensor housing height. The inset already accounts for the hardware, so the subtraction double-counts it. Delete the constant and the arithmetic around it.
      
      ### Asking which edge holds a vertical bar
      
      Read `traitCollection.verticalBarEdge` (iOS 27.1).
      
      A read inside a layout method needs nothing else. UIKit tracks the traits a layout pass reads, so it lays the view out again when the edge changes.
      
      A read anywhere else needs a registration in the same edit. Call this from a view controller. In a `UIView` subclass, call `setNeedsLayout()` on `self`.
      
      ```swift
      registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (self: Self, _) in
          self.view.setNeedsLayout()
      }
      ```
      
      Two limits on that trait. It resolves to `leading` or `trailing`, which follow layout direction, while `safeAreaInsets` uses physical edges. And `unspecified` is ambiguous: it covers both a configuration where no bar is possible and one where a bar is allowed but the edge is not resolved. Read the inset when you need the size of the space.
      
      ### `safeAreaInsets` is physical; `safeAreaLayoutGuide` resolves direction
      
      `safeAreaInsets` describes the physical enclosure. `.left` is the physical left in both layout directions, and it never means *leading*.
      
      - Constrain to `safeAreaLayoutGuide.leadingAnchor` or `.trailingAnchor` when the code means a direction. Do NOT read `.left` or `.right` there.
      - The same rule applies where the code writes. `additionalSafeAreaInsets` is a `UIEdgeInsets`, so a value set on `left` lands on the trailing side in a right-to-left layout.
      - A container's offsets follow the rule too. A split view reports a column's offset as an inset on that column. It lands in `.left` in one direction and in `.right` in the other. A constraint to the guide is correct in both.
      
      ## 7. UIScrollView considerations
      
      - Let the scroll view adjust its own content inset. `.automatic` is already the default, so the edit a legacy app needs is removing an assignment of `.never`.
      - Do NOT add safe area insets on top of `adjustedContentInset`. That value already contains them.
      
      ## 8. Preserve existing visual behavior
      
      - Do NOT change layouts that are intentionally edge to edge, such as backgrounds, media players, and maps.
      - Adjust only content that must respect the safe area. The goal is correctness on modern devices, not a redesign.
      
      ## 9. Never store a safe area inset
      
      An inset read once describes one configuration. The next resize, rotation, or bar change makes it wrong, and nothing tells the stored copy to update. An inset read before the view reaches a window is zero, so a one-time read caches zero permanently.
      
      - Do NOT assign a safe area inset to a property, an ivar, a `lazy var`, a `static let`, or a file-scope static.
      - Delete the property the inset was stored in, and every read of it. A property left behind is always zero, and it still reads as a value the layout uses.
      - Do NOT read an inset in `init`, `viewDidLoad`, `awakeFromNib`, or `viewWillAppear`. The first three run before the view reaches a window, so they read zero.
      - Read the inset where you lay out: `layoutSubviews`, `viewWillLayoutSubviews`, or `viewDidLayoutSubviews`.
      - Prefer constraints against `safeAreaLayoutGuide`. They need no client work, because the guide updates before any change notification arrives.
      - Do NOT compute an inset from a width, a size class, a screen, or a device model. A horizontal inset can appear and disappear with no change in size, so no function of size gives the right answer. Replace a helper of the form `insets(forWidth:)` with a read from the view that lays out.
      
      ### Responding to a change
      
      - Use `safeAreaInsetsDidChange()` or `viewSafeAreaInsetsDidChange()` to invalidate layout, not to recompute a cached copy. Call `super` in the view controller override, which requires it.
      - Do NOT read a dependent view's frame in the callback. Views constrained to the guide keep their old frames until the next layout pass. Call `setNeedsLayout()` and let layout run.
      - Call `setNeedsUpdateConstraints()` instead when the read lives in `updateConstraints`. `setNeedsLayout()` does not re-run that method.
      - A change can arrive inside an animation. Constraints against the guide animate with it. Frames computed by hand in the callback do not.
      - One transition can deliver more than one inset set, and the first can be incomplete. Reading at layout time avoids building a layout from a half-updated set.
      - **`additionalSafeAreaInsets` carries only the extra space, never the safe area itself.** The property adds to the system insets rather than replacing them. Assign the amount your own chrome needs and nothing else. A value built as the current safe area plus your toolbar counts the safe area twice.
      
      ## 10. Use `keyboardLayoutGuide` for keyboard avoidance
      
      A keyboard notification reports a frame in screen coordinates. An app that occupies part of the screen has to convert it, and the converted value goes stale on the next resize. That is the same defect as reading a screen or a window instead of your own view.
      
      - Constrain content to `view.keyboardLayoutGuide`. It tracks the keyboard with no observer and no conversion.
      - Remove the notification observer and the stored frame once the constraint replaces them.
      - Keep an observer only where the app does something other than layout, such as scrolling to a field or ending an edit.
      
      ## 11. Read insets from the view that lays out
      
      Insets are set per view, not per window. An ancestor's value is not a stale version of a child's value. It is a different value.
      
      - Read `safeAreaInsets` from the view whose content you are positioning. Not the window, not `UIApplication.shared`, not an ancestor, and not another view controller.
      - A presented view carries insets its window does not. A popover's content view and its window report different values, in either direction.
      - A sheet carries its own inset for a vertical bar. Its content is inset even where the presenting view is not, so a sheet's layout has to read its own view.
      - Two sibling split view columns differ from each other, and both differ from the window. One column can carry a bar's inset while the other carries none.
      - Do NOT look for a container's contribution in `additionalSafeAreaInsets`. The contribution is composed into each view's own `safeAreaInsets` instead.
      - Two scenes of one process report different geometry at the same moment. No global answer exists to read. Follow Core Principle 11.
      
      ## 12. Corner intrusions, when manual layout needs numbers
      
      `safeAreaInsets` does not describe a rounded corner. A view with zero horizontal insets can still have content clipped at its corners.
      
      `safeAreaLayoutGuide` does not handle corners either. Its constants come from `safeAreaInsets` unchanged, so constraint-based code needs the same fix as manual layout. Ask for a corner-adapted region (iOS 26):
      
      - Constraint-based code: constrain to `view.layoutGuide(for: .safeArea(cornerAdaptation: .horizontal))`.
      - Manual layout: read `view.directionalEdgeInsets(for: .safeArea(cornerAdaptation: .horizontal))`, which returns direction-resolved values.
      
      Both equal the plain safe area in every other respect. Use them only where a corner can clip content.
      
      ## 13. SwiftUI safe areas
      
      SwiftUI reports safe areas differently from UIKit, so the UIKit advice above does not carry over. Apply this rule only in files that are already SwiftUI.
      
      - `EdgeInsets` is directional. `leading` means leading and flips with layout direction. The physical-edge problem in Rule 6 does not exist here, so change nothing for it.
      - A `GeometryProxy` reports a `size` that is already inset, unlike `view.bounds`. That size is the region the content can use.
      - Read `@Environment(\.toolbarVerticalEdge)` when SwiftUI code must know which edge holds a vertical bar (iOS 27.1). It is the counterpart to `traitCollection.verticalBarEdge` in Rule 6, it resolves to `leading` or `trailing`, and it needs no registration because a `View` re-evaluates when the environment changes. It is `nil` where the system never places a bar.
      - The insets a `GeometryProxy` reports describe the space the view was offered, not the region it ended up with. Its frame already starts at the leading offset, so applying a reported inset as padding inside that view counts the inset twice. Let the framework place the content.
      
      ### Choosing the modifier
      
      Choose by what the content is.
      
      - Use `safeAreaBar(edge:)` for bar content (iOS 26). Below that deployment target use `safeAreaInset(edge:)`. It places the content the same way, and it gives up two things: the bar appearance, and the extended scroll edge effect on any scroll view the inset affects.
      - Use `safeAreaInset(edge:)` for other content beside the inset region.
      - Use `safeAreaPadding` to add a fixed margin measured from the safe area rather than from the view edge.
      
      ### Bar content stacked as an overlay
      
      A bar in a `ZStack` or an `overlay` covers the content behind it. Repadding it leaves it covering that content. `safeAreaBar(edge:)` reserves the bar's space as well as placing it, so move the bar out of the stack.
      
      This applies only when the stacked layer is bar content. A background, an artwork layer, a gradient, or a scrim is not a bar. A `ZStack` that puts one thing behind another is doing its job, so leave that structure alone. The test is whether the layer holds controls the layout must reserve space for.
      
      ### Hardcoded padding standing in for an inset
      
      `safeAreaPadding` does not read or track the inset. It adds a fixed amount *into* the safe area, so the number survives and is measured from the safe area rather than from the view edge.
      
      That is why converting a stand-in literal to `safeAreaPadding` fixes nothing. A literal that exists to clear a bar or a device inset is still a literal, and it still cannot follow an inset that appears at runtime. **Delete it.** The framework already places content inside the safe area, so no horizontal padding is the correct result.
      
      Reach for `safeAreaPadding(edge, length)` only where the margin is a design value in its own right, one the layout wants even with no inset present. Pass the length when you do: the form with no length substitutes the system default and silently replaces a designed number.
      
      ### Scoping `ignoresSafeArea`
      
      Name the edges the content must reach in the `edges:` parameter, and name only those.
      
      - A bare `ignoresSafeArea()` extends content under every edge, including one that holds a vertical bar. That is wrong for content the user reads or touches.
      - It is correct on a layer meant to reach every edge: a background fill, an artwork backdrop, a gradient, or a scrim. Leave those alone.
      - `.all`, and a list naming every edge, mean the same thing as the bare call. Neither is a scope.
      - The first parameter selects safe area *regions*, not edges. A value such as `.container` on its own scopes nothing, and it needs an `edges:` argument beside it.
      - The modifier does not set the reported insets to zero. It stops the view from honoring them. A view that ignores the safe area and then reads `safeAreaInsets` gets values that no longer describe its content.
      
      ### Reading insets in SwiftUI
      
      Do NOT use `GeometryReader` to read insets for a layout decision. Stop needing the number instead of finding another way to read it: `safeAreaInset(edge:)`, `safeAreaBar(edge:)`, and `safeAreaPadding` place content against the safe area without handing you a value to apply yourself.
      
      Reading `proxy.size` is a different matter and stays fine. A size is the region the content can use, so a decision made from it is sound. Subtracting insets from it is the defect, because that size is already inset.
      
      A `View` also re-evaluates whenever its inputs change, including on every frame of a live resize, so nothing needs caching.
      
      ## Constraints
      
      - Do NOT convert UIKit code to SwiftUI, and do NOT add dependencies. Rule 13 applies only to files that are already SwiftUI.
      - Make the smallest change that fixes each issue.
      - Do NOT modify a file that has no issues.
      
    • scene-lifecycle-task.md 9.8 KB
      # Task: Scene Lifecycle Migration
      
      ## Overview
      
      UIKit apps must adopt scene-based lifecycle (`UISceneDelegate`) to function correctly on modern iOS. The system dispatches foreground/background transitions per-scene, not per-app — apps that only implement `UIApplicationDelegate` lifecycle methods miss these events in multi-window scenarios.
      
      **As of iOS 27, scene lifecycle is required.** Apps built against the iOS 27 SDK that haven't adopted it crash at launch.
      
      **What this task does:** Migrates from `UIApplicationDelegate`-only lifecycle to `UISceneDelegate`-based lifecycle in 3 sequential steps.
      
      **Cross-reference:** Resolves `UIWindow(frame: UIScreen.main.bounds)` TODOs from [uiscreen-task.md](uiscreen-task.md). After migration, use `UIWindow(windowScene:)` instead.
      
      **Reference:** [Transitioning to the UIKit scene-based life cycle](https://developer.apple.com/documentation/UIKit/transitioning-to-the-uikit-scene-based-life-cycle)
      
      ---
      
      ## Detection
      
      **Migration needed** (proceed with all steps):
      - `UIApplicationSceneManifest` key missing from Info.plist, AND
      - No `configurationForConnecting` implementation in AppDelegate, AND
      - No class conforming to `UIWindowSceneDelegate` found
      
      **Already migrated** (STOP):
      - `UIApplicationSceneManifest` exists in Info.plist with `UISceneConfigurations`, OR
      - A class conforming to `UIWindowSceneDelegate` exists
      
      **Partial migration** (ask user):
      - Scene manifest exists but `UISceneConfigurations` empty/missing
      - `configurationForConnecting` exists but no `SceneDelegate` class
      - `SceneDelegate` exists but lifecycle methods not moved from AppDelegate
      
      | What to search | Pattern |
      |----------------|---------|
      | Scene manifest | `UIApplicationSceneManifest` in Info.plist |
      | Dynamic config | `configurationForConnecting` in AppDelegate |
      | Scene delegate | `UIWindowSceneDelegate` conformance |
      | Lifecycle in AppDelegate | `applicationDidBecomeActive`, `applicationWillResignActive`, `applicationDidEnterBackground`, `applicationWillEnterForeground` |
      
      ---
      
      ## Scope & Automation Level
      
      | Action | Level |
      |--------|-------|
      | Add `UIApplicationSceneManifest` to Info.plist | **Auto-fix** |
      | Create `SceneDelegate` boilerplate | **Auto-fix** |
      | Move `UIWindow` creation to scene delegate | **Auto-fix** |
      | Move 4 lifecycle methods (all four together) | **Auto-fix** |
      | Choose Info.plist vs dynamic configuration | **Ask** |
      | Split `didFinishLaunchingWithOptions` (one-time vs per-scene) | **Ask** |
      | Add `SceneDelegate.swift` to `.pbxproj` | **Auto-fix** |
      | URL handling / user activity / notification migration | **TODO** |
      
      **Out of scope:** Multiple window support (`UIApplicationSupportsMultipleScenes` set to `false`), external display support.
      
      **Do not repurpose a scene-lifecycle diff to swap an unrelated `UIScreen.mainScreen` reference.** When the active task is the scene-lifecycle migration but the file also happens to contain a `UIScreen.mainScreen` use that is NOT part of `UIWindow(frame: UIScreen.main.bounds)` (which Step 2 legitimately resolves), leave that `UIScreen.mainScreen` reference for the UIScreen task. Do not, for example, substitute `self.view` (a view controller's view) for an unrelated screen reference, or swap `[UIScreen mainScreen].scale` to `traitCollection.displayScale` while doing scene-lifecycle work. If the scene-lifecycle migration genuinely cannot be applied to this file (no AppDelegate lifecycle methods, already migrated, etc.), report "skipped: [reason]" — do not produce a diff that swaps an unrelated UIScreen usage to look like progress was made.
      
      ---
      
      ## Step 1: Add Scene Manifest to Info.plist
      
      This step must complete before Step 2. The scene manifest activates the scene lifecycle system; without it, the system ignores `SceneDelegate` entirely.
      
      **Ask the user:** "Should scene configuration be **static** (Info.plist — recommended) or **dynamic** (code in AppDelegate)?"
      
      ### 1A: Static Configuration (Info.plist) — Default
      
      Add `UIApplicationSceneManifest` to the app's Info.plist:
      
      ```xml
      <key>UIApplicationSceneManifest</key>
      <dict>
          <key>UIApplicationSupportsMultipleScenes</key>
          <false/>
          <key>UISceneConfigurations</key>
          <dict>
              <key>UIWindowSceneSessionRoleApplication</key>
              <array>
                  <dict>
                      <key>UISceneConfigurationName</key>
                      <string>Default Configuration</string>
                      <key>UISceneDelegateClassName</key>
                      <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
                      <!-- Include UISceneStoryboardFile only for storyboard-based apps -->
                      <key>UISceneStoryboardFile</key>
                      <string>Main</string>
                  </dict>
              </array>
          </dict>
      </dict>
      ```
      
      For programmatic root VC setup (no storyboard), omit the `UISceneStoryboardFile` key.
      
      ### 1B: Dynamic Configuration (Code in AppDelegate) — Alternative
      
      Info.plist still needs a minimal manifest (without `UISceneConfigurations`):
      
      ```xml
      <key>UIApplicationSceneManifest</key>
      <dict>
          <key>UIApplicationSupportsMultipleScenes</key>
          <false/>
      </dict>
      ```
      
      ```swift
      // In AppDelegate.swift
      func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
          let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
          config.delegateClass = SceneDelegate.self
          return config
      }
      ```
      
      For multiple scene roles, check `connectingSceneSession.role` to return the appropriate configuration.
      
      ---
      
      ## Step 2: Create SceneDelegate
      
      Requires Step 1 complete. The scene manifest must reference the delegate class.
      
      ### 2A: Storyboard-Based App
      
      System handles window creation. SceneDelegate only needs the `window` property:
      
      ```swift
      // TODO: Modernization - Add SceneDelegate.swift to the Xcode project's Compile Sources build phase.
      import UIKit
      
      class SceneDelegate: UIResponder, UIWindowSceneDelegate {
          var window: UIWindow?
      }
      ```
      
      ### 2B: Programmatic Root View Controller
      
      Move window creation from AppDelegate to scene delegate:
      
      ```swift
      // TODO: Modernization - Add SceneDelegate.swift to the Xcode project's Compile Sources build phase.
      import UIKit
      
      class SceneDelegate: UIResponder, UIWindowSceneDelegate {
          var window: UIWindow?
      
          func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
              guard let windowScene = scene as? UIWindowScene else { return }
              window = UIWindow(windowScene: windowScene)
              window?.rootViewController = ViewController() // Replace with actual root VC
              window?.makeKeyAndVisible()
          }
      }
      ```
      
      `UIWindow(windowScene:)` replaces `UIWindow(frame: UIScreen.main.bounds)` — no frame needed.
      
      ---
      
      ## Step 3: Relocate Lifecycle Methods
      
      Requires Step 2 complete.
      
      ### 3A: 1:1 Method Mappings
      
      | AppDelegate | SceneDelegate |
      |-------------|---------------|
      | `applicationDidBecomeActive(_:)` | `sceneDidBecomeActive(_:)` |
      | `applicationWillResignActive(_:)` | `sceneWillResignActive(_:)` |
      | `applicationDidEnterBackground(_:)` | `sceneDidEnterBackground(_:)` |
      | `applicationWillEnterForeground(_:)` | `sceneWillEnterForeground(_:)` |
      
      **Migrate the four methods as a set, not individually.** The four events form a coherent observation cluster — observing some per-app and others per-scene produces mismatched counts on every multi-window state change. If all four bodies copy-paste cleanly to the scene equivalents (no `UIApplication` parameter access, no app-state branching), move all four. If any single method does not, do not migrate any of them in this pass.
      
      Copy the method body unchanged; replace the `UIApplication` parameter with `UIScene`. Remove the moved methods from AppDelegate — if both exist, only the SceneDelegate version is called.
      
      If the body calls helpers defined on AppDelegate, move them to SceneDelegate or to a shared utility. Accessing via `UIApplication.shared.delegate` is least preferred.
      
      ### 3B: `didFinishLaunchingWithOptions` — Always Ask
      
      This method typically mixes one-time app setup and per-scene UI setup. **Always ask the user** which lines move.
      
      **Stays in AppDelegate:** Analytics, database setup, push notifications, SDK initialization, global config.
      
      **Moves to SceneDelegate `scene(_:willConnectTo:options:)`:** UIWindow creation, root VC setup, `makeKeyAndVisible()`, UI appearance config, state restoration. Window creation uses `UIWindow(windowScene:)` as shown in Step 2.
      
      ### 3C: Remove `window` Property from AppDelegate
      
      After migration, `window` belongs on `SceneDelegate`. Remove `var window: UIWindow?` from AppDelegate. Search for and replace references: `appDelegate.window`, `(UIApplication.shared.delegate as? AppDelegate)?.window` → scene-appropriate access (e.g., `view.window`).
      
      ---
      
      ## API Reference
      
      | API | Minimum iOS |
      |-----|-------------|
      | `UISceneDelegate` / `UIWindowSceneDelegate` | iOS 13.0+ |
      | `UIWindowScene` / `UIWindow(windowScene:)` | iOS 13.0+ |
      | `UISceneConfiguration` | iOS 13.0+ |
      | `UIApplicationSceneManifest` (Info.plist) | iOS 13.0+ |
      
      | Info.plist Key | Type | Description |
      |----------------|------|-------------|
      | `UIApplicationSceneManifest` | Dictionary | Root key — activates scene lifecycle |
      | `UIApplicationSupportsMultipleScenes` | Boolean | `false` for single-window apps |
      | `UISceneConfigurations` | Dictionary | Static scene configurations |
      | `UIWindowSceneSessionRoleApplication` | Array | Standard window scene configs |
      | `UISceneConfigurationName` | String | Configuration identifier |
      | `UISceneDelegateClassName` | String | Scene delegate class name |
      | `UISceneStoryboardFile` | String | Main storyboard (omit for programmatic) |
      
      - [Transitioning to the UIKit scene-based life cycle](https://developer.apple.com/documentation/UIKit/transitioning-to-the-uikit-scene-based-life-cycle)
      - [Scenes — UIKit App Structure](https://developer.apple.com/documentation/uikit/app_and_environment/scenes)
      
    • uiscreen-task.md 61.6 KB
      # Task: UIScreen.main Modernization
      
      ## Overview
      
      `UIScreen.main` reflects a single-window assumption and is now deprecated for window-relative use. Modern iOS supports multiple windows (iPad multitasking, Stage Manager, iPhone Mirroring), where `UIScreen.main` may not represent the display the calling code is rendering on.
      
      **Detection patterns:**
      
      - `UIScreen.main.scale` / `UIScreen.mainScreen.scale`
      - `UIScreen.main.bounds` / `UIScreen.mainScreen.bounds`
      - `UIScreen.main.nativeBounds` / `UIScreen.mainScreen.nativeBounds`
      - `UIScreen.main.nativeScale` / `UIScreen.mainScreen.nativeScale`
      - `UIScreen.main.traitCollection` / `UIScreen.mainScreen.traitCollection`
      - `UIScreen.main.coordinateSpace` / `UIScreen.mainScreen.coordinateSpace`
      - `UIScreenBrightnessDidChangeNotification` with `UIScreen.main`/`UIScreen.mainScreen` as object
      
      **Less-obvious sites that ALSO require modernization (do NOT produce empty diffs on them):**
      
      - **Nil-screen fallbacks** — `screen == nil ? [UIScreen mainScreen] : screen`, `self.window.screen ?: [UIScreen mainScreen]`, `screen ?? UIScreen.main`. The `[UIScreen mainScreen]` fallback IS a target site, even when wrapped in a nil check. See the [Fallback Paths](#fallback-paths) section below for the full handling.
      - **Private helpers whose only `UIScreen` use is "incidental"** — e.g., a `-(CGFloat)pixelWidth` helper that internally reads `[UIScreen mainScreen].scale`. The helper is the deprecation target, even if the caller looks unrelated to display rendering.
      - **Cached `dispatch_once` / static-let / lazy-var helpers** that read `UIScreen.main` once at first call and freeze the value (e.g., `mainScreenScale()`, `isLargeDevice()`, `isRetina()`). The helper itself is the target.
      - **`UIScreen.main` passed as an argument to another function** — e.g., `MapsIdiomIsMac(UIScreen.mainScreen)`, `UIRoundToScreenScale(value, UIScreen.mainScreen.scale)`. The argument is the target site; modernize it via the helper's own `traitCollection`/parameter migration if available, or via deprecate-and-forward on the helper. **However, only edit such an argument when the user explicitly asks for it — otherwise leave it for its own task per the off-target replacement guard ([Core Principle 14 in SKILL.md](../SKILL.md#core-principles)).**
      - **Hardware/screen assumptions where a TODO is the right output** — when there's no safe replacement (e.g., `UIScreen.main.nativeScale` with no trait-collection equivalent in a context where the call site can't yet receive a window), a TODO explaining the assumption IS the right output. Producing no diff is wrong — produce the TODO.
      
      If a target appears outside this list (e.g., a safe-area-inset bug, a coordinate-space conversion site, a private method rename), follow the active task's reference file. The skill must NOT skip files because "this isn't a `.scale` substitution" — the trigger is the deprecated API appearing in a site, not the specific shape of the expression.
      
      **File-naming heuristic for non-view classes.** Files named `*Manager.m`, `*Provider.m`, `*DataProvider.m`, `*Bridge.m`, `*Helper.m`, `*Generator.m`, `*Ingester.m`, `*Source.m`, `*Downloader.m`, `*Processor.m`, `*ViewModel.swift` are virtually never UIView/UIViewController subclasses. In these files, apply deprecate-and-forward (Pattern 1, step 5) with a new overload taking `traitCollection: UITraitCollection`.
      
      ---
      
      ## Pattern 1: UIScreen.main.scale → traitCollection.displayScale
      
      **Intent:** Get display scale for pixel-perfect rendering (2x, 3x).
      
      These rules apply to any `UIScreen.main.traitCollection` access, not just `.displayScale`. The context (view vs non-view) determines the approach, regardless of which trait is being accessed.
      
      **Shared state is not a valid replacement.** `[UITraitCollection currentTraitCollection]` / `UITraitCollection.current` carries the same single-display assumption as `UIScreen.main` and produces incorrect results in multi-window environments. Substituting it for `UIScreen.main` is not a modernization — it just renames the bug. The **only** legitimate use is as the forwarding bridge inside the deprecated wrapper of the deprecate-and-forward pattern (step 5), where the wrapper exists solely to point callers at a new overload that accepts `traitCollection:` explicitly. Anywhere else — view code, SwiftUI, free functions, helpers, fallbacks, examples — it is wrong. Treat the rest of this document accordingly: the only place you should write `.current` / `currentTraitCollection` is in the body of a deprecated forwarding wrapper.
      
      **Decision tree — follow in order, stop at the first match:**
      
      1. **User provides an explicit replacement expression?** → Use it exactly. The user chose that path for correct scene/window context. Never substitute a different path — the named path reflects the correct display context for that code site, and any substitute loses scene-specific information.
      2. **SwiftUI `View` struct?** → Use `@Environment(\.displayScale) private var displayScale` as a property, then use `displayScale` at the call site. For `UIScreen.main.bounds`, use `GeometryReader` instead. **Do NOT apply deprecate-and-forward to SwiftUI views.** Even when the SwiftUI view has scale-dependent computation that "looks like" it would benefit from a `traitCollection:` parameter, the correct fix is `@Environment(\.displayScale)` — SwiftUI's environment propagation is the native mechanism. Introducing a `traitCollection: UITraitCollection` overload on a SwiftUI view is always wrong; it ignores the environment and forces callers to compute UIKit state in SwiftUI contexts.
      3. **UIView or UIViewController subclass (or extension), in an instance method?** → `self.traitCollection.displayScale`. For class methods and static methods on view subclasses, skip to step 5 (deprecate-and-forward).
      4. **View/VC or trait collection reachable through a property or method parameter?** → That object's `.traitCollection.displayScale` (e.g., `self.contentView.traitCollection.displayScale` or `detailViewController.traitCollection.displayScale`). **Always prefer the most local source.** Before constructing a path like `self.editorViewController.contentView.traitCollection.displayScale`, check whether a shorter source is available:
         - **Method parameters first (highest priority):** If the method receives a view controller, view, or any object that already carries the value, use it directly. Do not navigate through the view hierarchy to get `displayScale` separately. **A method that receives a `traitCollection` parameter and ignores it is always wrong.**
         - **Local variables and direct properties next:** If a local variable or direct property (`self.traitCollection`) already has the needed value, prefer it over traversing a longer chain. If `self` has a view property (e.g., `self.view`, `self.contentView`), use `self.view.traitCollection.displayScale`.
         - **Multi-hop chains last:** Only use a multi-hop path (3+ property accesses) when no shorter source exists. A long chain is fragile and harder to read. It also increases the risk of no longer providing the correct local value.
         
         **This step takes priority over step 5 ONLY when the class itself is a UIView/UIViewController subclass** (i.e., the method is an instance method on a view/VC and you're reaching another view's traitCollection). If the class is a **non-view class** (`*Manager`, `*Generator`, `*Provider`, `*Bridge`, `*Helper`, `*Source`, etc.), **step 5 (deprecate-and-forward) still applies** — even if a view/VC is reachable via a property or parameter. In that case, use the reachable view's `.traitCollection` **inside the new overload's body**, but still create the three-part deprecation pattern. Simply inlining `parameter.traitCollection.displayScale` in a non-view class is a regression — it hides the traitCollection dependency from callers.
         
         **Exception:** When a method already receives a `traitCollection:` parameter, use `traitCollection.displayScale` inside the body — no deprecation needed because the caller already provides the trait collection.
      5. **Non-view class, utility, static method, class method, or free function?** → Apply the deprecate-and-forward pattern: keep the original method as a deprecated wrapper, add a new overload taking `traitCollection: UITraitCollection`, and have the deprecated wrapper forward to the new overload. This is the only context where shared state belongs in the forwarding body — see the [pattern below](#deprecate-and-forward-pattern-non-view-classes) for the exact shape.
      
         **Exception — smallest possible edit for file-local helpers:** When the symbol meets ALL of the following, skip the deprecate-and-forward overhead and instead modify the existing signature in place, updating callers to pass `traitCollection`:
         - **Access:** `private` / `fileprivate` / `static` (Swift) or static C function / file-local helper (ObjC, no header declaration)
         - **Reach:** All call sites are in the same file (or in test code targeting only this file)
         - **Caller context:** Every call site has a `traitCollection` reachable (typically `self.traitCollection` from a UIView/UIViewController, or a parameter already in scope)
         - **No public surface:** The symbol is not part of a header, public API, protocol requirement, or `@objc` exposed surface
      
         For these symbols, the deprecate-and-forward pattern is over-introducing API surface — there are no external callers to protect. Inline the change: add the `traitCollection` parameter to the existing method, update the callers in the same file to pass `self.traitCollection` (or the appropriate local trait source), and ship a single coherent edit. This is the preferred choice for private helpers, single-file utilities, and test helpers.
      
         **Default to deprecate-and-forward** when (a) the symbol is `public` / `internal` / `open`, (b) the symbol is declared in a header (ObjC), (c) callers exist in other files/modules that can't be updated atomically in this diff, or (d) the symbol is part of a protocol or override hierarchy. The full three-part pattern is mandatory in those cases.
      
         **Threading the trait collection through callers:** When you keep the deprecated wrapper, callers that have a view/VC in scope must be updated separately to call the new overload directly with `self.traitCollection` — do not leave them on the deprecated path. Producing a new overload but leaving every caller on the deprecated wrapper defeats the purpose of the migration.
      
         Applies to ALL access levels and **both Swift and ObjC** — ObjC class methods follow the same pattern. Place new parameter before any trailing closure. See the [ObjC class method example](#deprecate-and-forward-pattern-non-view-classes) below.
      
      | Context | Replacement |
      |---------|-------------|
      | **SwiftUI `View` struct** | `@Environment(\.displayScale) private var displayScale` |
      | UIView/UIViewController subclass | `self.traitCollection.displayScale` |
      | View/VC reachable via property or method parameter | `someView.traitCollection.displayScale` (prefer the most local source) |
      | Non-view class / static / class method / free function | Deprecate-and-forward with `traitCollection: UITraitCollection` parameter |
      | Test code | Use the object-under-test's `traitCollection` |
      
      ### Two-part pattern: API swap + invalidation
      
      A replacement in a view/VC has two parts: (A) the API swap, and (B) a `registerForTraitChanges` call when the value is cached. Both parts are mandatory for cached values — a diff with only part A is incomplete.
      
      **Both parts below are mandatory for cached values. Do not skip part B.**
      
      ```swift
      // COMPLETE — replacement + invalidation (both parts required)
      class MyCell: UITableViewCell {
          override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
              super.init(style: style, reuseIdentifier: reuseIdentifier)
              imageView.layer.contentsScale = traitCollection.displayScale
              registerForTraitChanges([UITraitDisplayScale.self]) { (self: MyCell, previousTraitCollection) in
                  self.imageView.layer.contentsScale = self.traitCollection.displayScale
              }
          }
      }
      ```
      
      > **ObjC equivalent:** `[self registerForTraitChanges:@[UITraitDisplayScale.class] withHandler:^(typeof(self) self, UITraitCollection *previousTraitCollection) { ... }]` or use `withAction:@selector(methodName)` for a separate method.
      
      Part B is NOT needed when the value is consumed fresh every time — in `layoutSubviews`, `drawRect:`, or a method called on-demand. See [Invalidation Analysis](#invalidation-analysis-mandatory-for-every-displayscale-replacement).
      
      > **Always prefer `registerForTraitChanges` over overriding `traitCollectionDidChange:` — even when older code or older docs use the older method.** `traitCollectionDidChange:` is deprecated in iOS 17+, and `registerForTraitChanges([UITraitDisplayScale.self])` (or `registerForTraitChanges:@[UITraitDisplayScale.class]` in ObjC) is the correct modern form. Substitute `registerForTraitChanges` whenever trait-change observation is needed, regardless of which method appears in the original code.
      
      ### Deprecate-and-forward pattern (non-view classes)
      
      Three required pieces: (1) deprecation, (2) new overload, (3) forwarding. Same structure regardless of access level (`private`, `internal`, `public`).
      
      **Never remove the old declaration.** It must remain in the file as the deprecated wrapper that forwards to the new version. Changing the old signature in place instead of adding a second one is not this pattern: it drops the migration bridge and breaks callers that are not in this diff. This applies to **ObjC functions and methods** exactly as it does to Swift methods, Swift initializers, computed properties, and protocol extensions. If you find yourself editing the original signature rather than adding a new one alongside it, stop and add the new one.
      
      **This pattern applies to ALL of the following — not just instance methods:**
      - Instance methods on non-view classes
      - Static/class methods (`static func`, `class func`, ObjC class methods)
      - **Static computed properties** (e.g., `static var onePixel: CGFloat`) — deprecate the property, introduce a new `static func` with `traitCollection:` parameter
      - **Computed properties** (e.g., `var displayScale: CGFloat`) — deprecate the property, introduce a new method with `traitCollection:` parameter
      - **Protocol extensions** (e.g., `extension MyProtocol { func renderBadge() }`) — deprecate the existing method in the extension, introduce a new method with `traitCollection:` parameter
      - **Free functions** — deprecate the original, introduce a new function with `traitCollection:` parameter
      
      For static properties or protocol extensions where adding a parameter changes the API shape (property → function), that is expected and correct. The old property/method stays as the deprecated wrapper.
      
      **Apply deprecation at the lowest method that touches the deprecated API — not every public caller.** When a chain of public methods (`renderForLight`, `renderForDark`, `renderForAuto`) all funnel into a single private helper (`_renderWithStyle:`) that is the only site touching `UIScreen.mainScreen.scale`, deprecate **the helper**. Adding a `traitCollection:` parameter to three public methods when the helper is the only one that needs it produces three times the API surface churn for the same migration. The wrapper public methods stay untouched — they pick up the new helper signature internally. Conversely, when each public caller reads `UIScreen.main.scale` directly inside its own body, deprecate each one individually — deprecate where the deprecated API actually lives.
      
      **Swift (do NOT delete the old method when adding a new overload):**
      
      ```swift
      // WRONG — old method removed, only new method left (breaks ABI for out-of-diff callers):
      class ImageProcessor: NSObject {
          func generateThumbnail(for image: UIImage, traitCollection: UITraitCollection) -> UIImage {
              let scale = traitCollection.displayScale
              return processImage(image, scale: scale)
          }
          // ← old generateThumbnail(for:) was deleted — out-of-diff callers can no longer compile,
          //   and there is no deprecation signal pointing them to the new API
      }
      
      // RIGHT — full deprecate-and-forward (all three parts mandatory, OLD METHOD KEPT):
      class ImageProcessor: NSObject {
          @available(*, deprecated, message: "use generateThumbnail(for:traitCollection:) instead")
          func generateThumbnail(for image: UIImage) -> UIImage {
              return generateThumbnail(for: image, traitCollection: .current)
          }
      
          func generateThumbnail(for image: UIImage, traitCollection: UITraitCollection) -> UIImage {
              let scale = traitCollection.displayScale
              return processImage(image, scale: scale)
          }
      }
      ```
      
      **Swift initializers — the old initializer must remain as a deprecated wrapper:**
      
      ```swift
      // WRONG — old init removed:
      class GlyphButton: UIButton {
          init(glyph: Glyph, traitCollection: UITraitCollection) { ... }
          // ← old init(glyph:) was deleted — callers that don't yet pass traitCollection break
      }
      
      // RIGHT — old init kept as deprecated wrapper:
      class GlyphButton: UIButton {
          @available(*, deprecated, message: "use init(glyph:traitCollection:) instead")
          convenience init(glyph: Glyph) {
              self.init(glyph: glyph, traitCollection: .current)
          }
      
          init(glyph: Glyph, traitCollection: UITraitCollection) { ... }
      }
      ```
      
      **Objective-C:**
      
      In headers (or above the implementation when no header exists), the old method's declaration MUST carry a real deprecation attribute — not just a comment. Use `__attribute__((deprecated("use newMethod instead")))`. A `// Deprecated:` comment alone does not generate compiler warnings for callers and is NOT sufficient.
      
      ```objc
      // In ThumbnailGenerator.h — preferred default when UIKit/Availability headers are in scope:
      @interface ThumbnailGenerator : NSObject
      - (UIImage *)generateThumbnailForURL:(NSURL *)url __attribute__((deprecated("use generateThumbnailForURL:traitCollection: instead")));
      - (UIImage *)generateThumbnailForURL:(NSURL *)url traitCollection:(UITraitCollection *)traitCollection;
      @end
      
      // In ThumbnailGenerator.m:
      @implementation ThumbnailGenerator
      
      - (UIImage *)generateThumbnailForURL:(NSURL *)url {
          return [self generateThumbnailForURL:url traitCollection:[UITraitCollection currentTraitCollection]];
      }
      
      - (UIImage *)generateThumbnailForURL:(NSURL *)url traitCollection:(UITraitCollection *)traitCollection {
          CGFloat scale = traitCollection.displayScale;
          return [self renderThumbnail:url scale:scale];
      }
      
      @end
      ```
      
      For private methods declared only in the implementation file (no header), put the attribute with the implementation:
      
      ```objc
      - (UIImage *)renderBadge __attribute__((deprecated("use renderBadgeWithTraitCollection: instead"))); {
          return [self renderBadgeWithTraitCollection:[UITraitCollection currentTraitCollection]];
      }
      ```
      
      **Objective-C class methods (`+` methods) — same pattern, not inline:**
      
      ```objc
      @interface BadgeAnimationGenerator : NSObject
      + (CAAnimation *)animation __attribute__((deprecated("use animationWithTraitCollection: instead")));;
      + (CAAnimation *)animationWithTraitCollection:(UITraitCollection *)traitCollection;
      @end
      
      @implementation BadgeAnimationGenerator
      
      + (CAAnimation *)animation {
          return [self animationWithTraitCollection:[UITraitCollection currentTraitCollection]];
      }
      
      + (CAAnimation *)animationWithTraitCollection:(UITraitCollection *)traitCollection {
          CGFloat scale = traitCollection.displayScale;
          // ... use scale ...
      }
      
      @end
      ```
      
      **Forwarding-chain consistency:** When the new overload calls other methods on `self` or on wrapped/sub-objects, those calls must also use the `traitCollection:`-accepting version — not the deprecated version. A new method that internally calls `object.deprecatedMethod` instead of `object.deprecatedMethod(traitCollection: traitCollection)` silently ignores the passed `traitCollection`. Verify every call site within the new method's body.
      
      ### When the user names a specific replacement path
      
      When the user explicitly names a replacement path, use it exactly — even when a closer or "more convenient" trait source is available on `self`. The user named that specific source for a reason; substituting `self.traitCollection` to save a property hop loses scene-specific information.
      
      ---
      
      ## Invalidation Analysis (mandatory for every displayScale replacement)
      
      **THIS CHECK IS NON-NEGOTIABLE.** Every `displayScale` replacement in a UIView/UIViewController subclass must determine: **is the value cached or consumed fresh?** If cached, you must add a `registerForTraitChanges` call for `UITraitDisplayScale` — a replacement without invalidation is incomplete — the cached value goes stale on display change.
      
      **Default assumption: registration IS required.** Only skip it when you can confirm one of the explicit exceptions below. When replacing `UIScreen.mainScreen.scale` (or `.main.scale`) with `self.traitCollection.displayScale` in code that computes a visual property (border width, image scale, constraint constant, image generation, layer property), you MUST add trait change observation. **A `displayScale` replacement that feeds a cached or stored value MUST be paired with a `registerForTraitChanges` call — this is not optional, it is a hard requirement. Without it, cached values go stale when the user moves the window between displays.** The exceptions are:
      - **(a)** The code is inside a method that UIKit auto-calls on trait change: `layoutSubviews`, `drawRect:`, `updateConstraints`, `viewIsAppearing:`
      - **(b)** The code is inside a private helper called exclusively from one of the above methods
      
      If NONE of the exceptions apply, registration is required — period.
      
      **Registration pattern — register in init/setup, specify `UITraitDisplayScale`:**
      
      ```swift
      registerForTraitChanges([UITraitDisplayScale.self]) { (self: MyView, previousTraitCollection) in
          // Recalculate the cached value(s)
      }
      ```
      
      > **ObjC:** `[self registerForTraitChanges:@[UITraitDisplayScale.class] withHandler:^(typeof(self) self, UITraitCollection *previousTraitCollection) { ... }]`. Alternative: use `withAction:@selector(methodName)` when recalculation is in a separate method.
      
      When registering for trait changes to update a cached value (layer `lineWidth`, `borderWidth`, `contentsScale`, constraint constant, ivar), the handler MUST directly recalculate that specific property. Do NOT use `setNeedsLayout` or `setNeedsDisplay` as the action — these only work if `layoutSubviews` or `drawRect:` happens to recalculate that exact property, which it usually does not. A `setNeedsLayout` that doesn't lead to recalculation of the cached value is a no-op bug.
      
      ```swift
      // directly update the cached property:
      registerForTraitChanges([UITraitDisplayScale.self]) { (cell: MyCell, previousTraitCollection) in
          cell.layer.borderWidth = 1.0 / cell.traitCollection.displayScale
      }
      ```
      
      ### Quick-reference: cached vs transient
      
      Use this checklist to decide. If ANY cached indicator is true, registration is required.
      
      **Cached (registration required):**
      - Assigned to a layer property (`contentsScale`, `borderWidth`, `rasterizationScale`, `lineWidth`)
      - Assigned to a constraint constant
      - Stored in an ivar or property (`_cachedScale`, `_hairlineWidth`)
      - Used to generate an image that is then stored (`button.setImage(...)`, `imageView.image = ...`)
      - **Used inside a method that generates images for buttons, icons, badges, snapshots, or thumbnails** — e.g., `updateThemeButtonImages`, `updateBadgeImage`, `renderAppIcon`, `generateSnapshot`. Even if the method computes fresh, its output is stored on a view or ivar. **This is the most frequently missed case — generating a scale-dependent image and setting it on a button or image view without registering for trait changes means the image goes stale when the display scale changes.** The trait change handler should call the same image-generation method.
      - Inside a setup method (`init`, `viewDidLoad`, `awakeFromNib`, `configure...`, `setup...`, `update...Images`) that sets scale-dependent values on views — even if the method computes fresh, its output is stored
      - Used to compute a value passed to `CGAffineTransform`, `UIBezierPath`, or drawing code called once during setup
      
      **Transient (no registration needed):**
      - Inside `layoutSubviews`, `drawRect:`, `updateConstraints`, `viewIsAppearing:` — UIKit re-calls these on trait change
      - Inside a private helper that is ONLY called from one of the above methods
      - Used in a local variable that doesn't escape the current scope and the method runs on-demand (not just once at setup)
      - Inside a method triggered by user interaction (`@IBAction`, gesture handler) — runs fresh each time
      
      **When in doubt, register.** A redundant registration is harmless; a missing one causes stale rendering on display changes.
      
      ### Examples: when registration IS needed
      
      **Cached in init:**
      ```swift
      override init(frame: CGRect) {
          super.init(frame: frame)
          separatorLine.lineWidth = 1.0 / traitCollection.displayScale
          registerForTraitChanges([UITraitDisplayScale.self]) { (self: MyView, previousTraitCollection) in
              self.separatorLine.lineWidth = 1.0 / self.traitCollection.displayScale
          }
      }
      ```
      
      **Cached image:**
      ```swift
      func updateThemeButtonImages() {
          let scale = traitCollection.displayScale
          let renderer = UIGraphicsImageRenderer(size: size)
          cachedButtonImage = renderer.image { context in /* ... */ }
          button.setImage(cachedButtonImage, for: .normal)
      }
      
      // In init or setup — handler INVOKES the existing method, never duplicates its body:
      registerForTraitChanges([UITraitDisplayScale.self]) { (self: MyView, previousTraitCollection) in
          self.updateThemeButtonImages()
      }
      ```
      
      > **Never duplicate the update method's body inline in the handler.** The handler's job is to call `updateThemeButtonImages()` — not to copy the renderer/setImage code into the handler block. Inline duplication creates two parallel implementations that drift the moment anyone fixes a bug in one. If a method like `updateThemeButtonImages` / `updateBadgeImage` / `renderAppIcon` / `configureSeparator` already exists, the handler must call it by name. ObjC equivalent: prefer `withAction:@selector(updateThemeButtonImages)` over a `withHandler:` block that re-implements the body.
      
      ---
      
      ## Pattern 2: UIScreen.main.bounds → view.bounds
      
      **Intent:** Get available space for layout or dimensions.
      
      Do **NOT** replace with `self.bounds` when the code is asking "how big is the display area." The local view's bounds represent its own size, not the available screen/window space.
      
      Do **NOT** use `?? 0` or `?? .zero` as fallback for window bounds. Refactor the API to accept size as a parameter, or move to a lifecycle point where window is guaranteed.
      
      | Context | Replacement |
      |---------|-------------|
      | UIView/UIViewController in `loadView` or `init` (initial frame) | `CGRectZero` / `.zero`. **Never** access `self.view` in `loadView` — causes infinite recursion. Auto Layout resizes before display. |
      | UIViewController in safe lifecycle methods | `self.view.bounds` |
      | UIView in safe lifecycle methods | `self.superview.bounds` |
      | UIView/UIViewController in unsafe methods | Move code to `viewIsAppearing` for view controllers and `layoutSubviews` for views or later |
      | `UIWindowScene` in scope with no window yet, as in a `UIWindowSceneDelegate` method | `windowScene.effectiveGeometry.coordinateSpace.bounds` (iOS 26+) |
      | Non-view class / static / free function | Add `bounds: CGRect` parameter, deprecate original |
      
      > **The scene row is the last resort, not a shortcut.** Prefer the view when the question is about the view's own space. Prefer the window over the scene when a window exists (Core Principle 1). Reach for the scene only where neither is available yet, such as `scene(_:willConnectTo:options:)`.
      
      > **Limits on the scene row.** Below a deployment target of iOS 26, read `windowScene.coordinateSpace.bounds`, which exists from iOS 13 and reports the same value. A delegate method's signature is fixed by the protocol, so it cannot take an injected `bounds` parameter. And these bounds are the whole scene, insets included, so they answer no safe area question. Refer to [safe-area-task.md](safe-area-task.md) for insets.
      
      | Question | Scene geometry | View or superview bounds |
      |---|---|---|
      | Scope | The whole scene, at window-manager level | One view's own rectangle |
      | What the rectangle covers | Everything the scene occupies, bars included | Everything that view occupies, bars included. Neither subtracts the safe area; `safeAreaLayoutGuide` and `safeAreaInsets` do that |
      | Expensive work during a resize | `effectiveGeometry.isInteractivelyResizing` defers it until the drag ends | Reflow every frame, which is what a layout pass is for |
      
      > **`CGRectZero` is ONLY for `loadView`/`init`.** Substituting `CGRectZero` for `[UIScreen mainScreen].bounds` in any other context (instance methods past `viewDidLoad`, layout helpers, sizing computations) produces a zero-sized layout that breaks the feature. If the call site is in a safe lifecycle method, use `self.view.bounds` (view controller) or `self.superview.bounds` (view). If `view` may be nil, move the code or ask the user — but never substitute `CGRectZero` outside `loadView`/`init`.
      
      Safe view controller methods (view hierarchy guaranteed): `viewIsAppearing`, `viewDidAppear`, `viewWillDisappear`.
      Unsafe view controller methods (view may not be in a view hierarchy): `init`, `loadView`, `viewDidLoad`, `viewWillAppear`.
      
      **Non-view class (deprecated wrapper):**
      
      ```swift
      class LayoutHelper {
          @available(*, deprecated, message: "Pass bounds from the caller's window or view context")
          static func calculateOptimalWidth() -> CGFloat {
              // TODO: Modernization - Callers should pass bounds from their window/view context
              return calculateOptimalWidth(in: UIScreen.main.bounds)
          }
      
          static func calculateOptimalWidth(in bounds: CGRect) -> CGFloat {
              return bounds.width * 0.9
          }
      }
      ```
      
      > The deprecated wrapper keeps `UIScreen.main.bounds` as a temporary bridge. **Never** replace the bridge with `UIApplication.shared.connectedScenes` or other shared state references.
      
      ---
      
      ## Pattern 3: UIScreen.main.nativeScale — NO trait-collection equivalent
      
      `nativeScale` is the physical pixel density of the hardware display; `displayScale`/`scale` is the logical scale factor (2x, 3x). There is no trait-collection equivalent — it must come from a screen object. Same applies to `nativeBounds` and `coordinateSpace`.
      
      ```swift
      // Before
      let nativeScale = UIScreen.main.nativeScale
      // After
      let nativeScale = window.windowScene.screen.nativeScale
      ```
      
      **Always use `window.windowScene.screen`**, not `window.screen`. In multi-scene environments, `window.screen` may not reflect the correct display — `windowScene.screen` ensures the screen is resolved through the scene's connection to its display. This applies to **all** screen properties accessed via window: `nativeScale`, `nativeBounds`, `scale`, `bounds`, `coordinateSpace`. Using `self.view.window.screen.nativeScale` instead of `self.view.window.windowScene.screen.nativeScale` is always wrong.
      
      ---
      
      ## Pattern 4: Keyboard Notification Coordinate Space
      
      **Intent:** Convert keyboard frame from notification using a coordinate space.
      
      When handling keyboard notifications (`UIKeyboardWillShowNotification`, `UIKeyboardWillChangeFrameNotification`, etc.), the notification's `object` is the screen posting the notification. Use `notification.object` to get the coordinate space — **never** substitute `self.view.window.screen` or `self.view.window.windowScene.screen`.
      
      ```objc
      // WRONG — indirect path, may be nil:
      CGRect keyboardFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
      CGRect converted = [self.view.window.screen.coordinateSpace convertRect:keyboardFrame toCoordinateSpace:self.view];
      
      // RIGHT — notification.object IS the screen:
      CGRect keyboardFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
      CGRect converted = [((UIScreen *)notification.object).coordinateSpace convertRect:keyboardFrame toCoordinateSpace:self.view];
      ```
      
      This is the correct approach because:
      1. `notification.object` is guaranteed to be the screen — it's always available
      2. `self.view.window` may be nil if the view isn't in the hierarchy yet
      3. In multi-screen environments, `notification.object` is the specific screen, not necessarily the main screen
      
      ---
      
      ## Special Cases
      
      ### Catalyst Window Frame Persistence
      
      `systemFrame` exists on Mac Catalyst and nowhere else. A reference to it in an iOS build does not compile, so every use of it belongs behind `#if targetEnvironment(macCatalyst)`.
      
      A window's position on a Mac desktop lives in system coordinates, and no view can report it. `view.frame`, `view.bounds`, and `window.frame` are all in scene coordinates, so none of them can save or restore where the user put the window. `UIScreen.main.bounds` describes the display, not the window, so it is wrong for this too.
      
      Save `windowScene.effectiveGeometry.systemFrame`, and restore it with `requestGeometryUpdate(.Mac(systemFrame:))`.
      
      ```swift
      #if targetEnvironment(macCatalyst)
      @MainActor
      enum WindowFrameStore {
          private static var storedFrame: CGRect?
      
          static func saveWindowFrame(for windowScene: UIWindowScene) {
              storedFrame = windowScene.effectiveGeometry.systemFrame
          }
      
          static func restoreWindowFrame(for windowScene: UIWindowScene) {
              guard let storedFrame, !storedFrame.isEmpty else { return }
              windowScene.requestGeometryUpdate(.Mac(systemFrame: storedFrame))
          }
      }
      #endif
      ```
      
      Keep `@MainActor` on the store. `effectiveGeometry` and `requestGeometryUpdate` are both main actor isolated. A `static var` with no actor is also a compile error in the Swift 6 language mode.
      
      Guard the restore on a non-empty frame. A frame saved before the scene finished connecting can be zero, and restoring from it asks for a zero-sized window.
      
      ### Free Functions and Cached Helpers
      
      When `UIScreen.main` appears inside a free function, `dispatch_once` helper, or cached wrapper (e.g., `mainScreenScaleFactor()`, `isLargeDevice()`, `isRetina()`), the TODO belongs at the **top of the function** — not next to the UIScreen usage. The function itself is the problem. Also add a TODO at **every call site**.
      
      ```swift
      // TODO: Modernization - This cached helper assumes a single screen scale. Convert callers to pass
      // traitCollection.displayScale from their view/VC context. Once all callers are migrated, remove this function.
      func mainScreenScaleFactor() -> CGFloat {
          // ... cached dispatch_once returning UIScreen.main.scale
      }
      
      // At each call site:
      // TODO: Modernization - Replace mainScreenScaleFactor() with self.traitCollection.displayScale
      self.layer.contentsScale = mainScreenScaleFactor()
      ```
      
      For device-type cached helpers (`isLargeDevice()`, `isCompactDevice()`): the TODO must explain that with flexible windowing and iPhone Mirroring, cached screen-size checks no longer reflect the active window's dimensions. Call sites should use size classes or window bounds.
      
      ### Notification Observers
      
      When migrating `UIScreen.mainScreen` in notification observers, the TODO must note that the screen can change when a window moves between displays. The observation needs to track screen changes and re-subscribe.
      
      ```objc
      // TODO: Modernization - UIScreen.mainScreen assumes a fixed screen. When a window moves between
      // displays, the screen changes. Track the window's current screen, observe brightness on that
      // screen, and re-subscribe when the screen changes (e.g., via windowScene.screen updates).
      [[NSNotificationCenter defaultCenter] addObserver:self
          selector:@selector(brightnessChanged:)
          name:UIScreenBrightnessDidChangeNotification
          object:UIScreen.mainScreen];
      ```
      
      ### Fallback Paths
      
      When code already has `self.window.screen ?: UIScreen.mainScreen`, keep the window-based access (correct path). Only address the fallback:
      
      ```objc
      // TODO: Modernization - The UIScreen.mainScreen fallback assumes a single display. Consider
      // what should happen when self.window is nil (e.g., return early or defer until window is set).
      UIScreen *screen = self.window.screen ?: UIScreen.mainScreen;
      ```
      
      When code already has `self.traitCollection.displayScale` with a `UIScreen.mainScreen.scale` fallback (e.g., `self.traitCollection.displayScale ?: UIScreen.mainScreen.scale`), **remove the entire fallback and use just `self.traitCollection.displayScale`**. The fallback is not needed as local trait collections provide their own fallback value.
      
      ```objc
      // Before — ternary fallback:
      CGFloat scale = self.traitCollection.displayScale ?: UIScreen.mainScreen.scale;
      
      // RIGHT — remove fallback entirely:
      CGFloat scale = self.traitCollection.displayScale;
      ```
      
      When removing a UIScreen fallback where `self.traitCollection` is available, remove the entire fallback — do NOT substitute `1.0`, `?: 1`, or any other literal or invented value. If the original code was `self.traitCollection.displayScale ?: UIScreen.mainScreen.scale`, the correct replacement is `self.traitCollection.displayScale` — not `self.traitCollection.displayScale ?: 1`. The replacement must not introduce a fallback that was not present in the original non-UIScreen code path.
      
      **Magic-number substitution is forbidden across the board.** When the original fallback is guarding something other than scale (e.g., a layout constant, a default width, a layout-driven offset), do NOT collapse the expression by substituting an invented literal for the screen-derived value. Examples of forbidden replacements:
      
      ```objc
      // WRONG — invented magic number replaces the screen-derived value:
      // Original: CGFloat width = useFullWidth ? [UIScreen mainScreen].bounds.size.width : 262.f;
      CGFloat width = useFullWidth ? 262.f : 262.f;  // ← magic number invented to remove UIScreen
      
      // WRONG — CGRectZero substituted for screen bounds outside loadView/init:
      // Original: CGRect frame = [UIScreen mainScreen].bounds;
      CGRect frame = CGRectZero;  // ← only safe in loadView/init; produces zero-sized layout elsewhere
      
      // RIGHT — preserve the surrounding control structure with the correct context:
      CGFloat width = useFullWidth ? self.view.window.bounds.size.width : 262.f;
      ```
      
      If the surrounding code was using the screen as a way to get "available space," the correct replacement is `self.view.bounds` in view controllers and `self.superview.bounds` in views. If you genuinely cannot determine a safe replacement, ask the user — never substitute a magic number to make the deprecation go away.
      
      When the original code has a ternary where **both branches compute the same semantic value** (display scale) via different accessors — e.g., `self.window.screen ? self.window.screen.scale : UIScreen.mainScreen.scale` — and `self.traitCollection.displayScale` provides that same value correctly, simplify the entire expression to `self.traitCollection.displayScale`. The ternary's purpose was to avoid the UIScreen fallback when a better source was available; `traitCollection.displayScale` serves that purpose directly without the nil-check.
      
      **Important distinction:** This full-expression simplification applies only when both branches compute the **same value** (e.g., both get display scale). When the primary path computes a **different value** or uses a different public API (e.g., `window.screen.nativeScale` vs `UIScreen.mainScreen.scale`), preserve the primary path and only replace the UIScreen fallback.
      
      ### UIWindow Initialization
      
      Replace `UIWindow(frame: UIScreen.main.bounds)` **only** when a `windowScene` is locally available. Otherwise add a TODO — never fetch from `connectedScenes`.
      
      ```swift
      // windowScene in scope → safe to replace
      func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
          guard let windowScene = scene as? UIWindowScene else { return }
          window = UIWindow(windowScene: windowScene)
      }
      
      // windowScene not available → add TODO
      // TODO: Modernization - Replace with UIWindow(windowScene:) by accepting a UIWindowScene parameter
      // or moving initialization to scene(_:willConnectTo:options:).
      private let window: UIWindow = UIWindow(frame: UIScreen.main.bounds)
      ```
      
      ### SwiftUI
      
      Replace `UIScreen.main.bounds` with `GeometryReader`. For display scale, use `@Environment(\.displayScale)`. If GeometryReader adoption is too complex, add a TODO.
      
      ```swift
      // In a SwiftUI View struct:
      @Environment(\.displayScale) private var displayScale
      // ... in body:
      imgRenderer.scale = displayScale
      ```
      
      ### UIGraphicsImageRendererFormat(for: UIScreen.main.traitCollection)
      
      This pattern passes a `traitCollection` to a format initializer. **Never remove the `for:` argument — always pass a trait collection through it.**
      
      Apply the full deprecate-and-forward pattern to the enclosing method so callers can pass the correct trait collection:
      
      ```swift
      // Deprecate-and-forward on the enclosing method:
      @available(*, deprecated, message: "use renderBadge(traitCollection:) instead")
      func renderBadge() -> UIImage {
          return renderBadge(traitCollection: .current)
      }
      
      func renderBadge(traitCollection: UITraitCollection) -> UIImage {
          let format = UIGraphicsImageRendererFormat(for: traitCollection)
          // ...
      }
      ```
      
      ```objc
      // ObjC equivalent (real deprecation attribute on the declaration — prefer API_DEPRECATED_WITH_REPLACEMENT):
      - (UIImage *)renderBadge __attribute__((deprecated("use renderBadgeWithTraitCollection: instead")));
      - (UIImage *)renderBadgeWithTraitCollection:(UITraitCollection *)traitCollection;
      
      // In the implementation:
      - (UIImage *)renderBadge {
          return [self renderBadgeWithTraitCollection:[UITraitCollection currentTraitCollection]];
      }
      
      - (UIImage *)renderBadgeWithTraitCollection:(UITraitCollection *)traitCollection {
          UIGraphicsImageRendererFormat *format = [[UIGraphicsImageRendererFormat alloc] initForTraitCollection:traitCollection];
          // ...
      }
      ```
      
      This applies even to `private` methods — the deprecation signals intent and enables future callers to pass the correct trait collection.
      
      ### Call-Chain Propagation
      
      When adding a `traitCollection` parameter to method A, check callers. If a caller also lacks a local trait collection (non-view class), apply the same deprecate-and-forward pattern. Repeat until the chain reaches a UIView/UIViewController (`self.traitCollection`).
      
      ---
      
      ## Analysis
      
      In addition to the generic context read described in `SKILL.md` Phase 2:
      
      - **Cached vs on-demand** — if `displayScale` is stored in an ivar/property/constraint/layer during init/setup, a `registerForTraitChanges` call for `UITraitDisplayScale` is needed (see [Invalidation Analysis](#invalidation-analysis-mandatory-for-every-displayscale-replacement) above).
      
      ## Implementation Gates
      
      Before editing any line, answer these five gate questions:
      
      1. **SwiftUI context?** Is this inside a `struct` conforming to `View`?
         - YES → Use `@Environment(\.displayScale)` for scale, `GeometryReader` for bounds.
         - NO → Continue to question 2. **Never introduce SwiftUI patterns (`@Environment(\.displayScale)`, `GeometryReader`) into a `UIView` or `UIViewController` subclass.** Use `self.traitCollection.displayScale` — the UIKit API — even if the project also contains SwiftUI code.
      2. **Cached value?** Is the replaced value stored in a layer property, constraint, ivar, image, or button image? Or does the replacement appear inside a setup method that sets images on views (e.g., `updateThemeButtonImages`, `updateBadgeImage`, `renderAppIcon`)? Or inside `init`/`viewDidLoad`/`awakeFromNib`/`configure`/`setup` where the computed value is stored and never recomputed? Or has the user explicitly asked you to register for trait changes? **Use the [cached-vs-transient quick-reference](#quick-reference-cached-vs-transient) to decide.**
         - YES → You MUST add a `registerForTraitChanges([UITraitDisplayScale.self])` call **with either a `withHandler:` block or a `withAction:` selector**. A bare `registerForTraitChanges` with only a trait list and no handler is a compile error. A diff without registration is incomplete — the cached value will go stale on display change. **The inline API swap alone is insufficient for cached values — it only fixes the initial computation but breaks when the user moves between displays with different scales.** See [Invalidation Analysis](#invalidation-analysis-mandatory-for-every-displayscale-replacement) for cached-value indicators. **This is the most commonly missed check — verify it for every file. When in doubt, register — a redundant registration is harmless, a missing one causes stale rendering.**
         - NO → Skip the override.
      
         **Common blind spot:** Methods named `update*Images`, `update*Image`, `render*`, `generate*`, `createSnapshot*` that produce scale-dependent images and set them on views. Even though these methods compute fresh values, their outputs are stored (on buttons, image views, ivars). If called from init/viewDidLoad, you MUST register for trait changes and re-call the method in the handler. This is the most commonly missed pattern. **A replacement that swaps the API call but omits `registerForTraitChanges` for a cached value is incomplete — even if the inline replacement is correct, the cached output goes stale. The two parts (API swap + registration) are inseparable for cached values.**
      3. **View or non-view class?** Does this class inherit from UIView or UIViewController?
         - YES, **instance method** → use `self.traitCollection.displayScale`
         - YES, **but class method or static method** → Apply step 5 (deprecate-and-forward).
         - NO, **but method already receives a `traitCollection:` parameter** → use `traitCollection.displayScale` inside the method body. No deprecation needed — the caller already provides the trait collection.
         - NO, but view/VC reachable via property/parameter → use that object's `.traitCollection.displayScale`. **Always prefer the most local source.** If the method receives a view or view controller parameter, use its `.traitCollection.displayScale`. Prefer a direct property over a multi-hop chain (3+ property accesses).
         - NO, and no view/VC reachable → apply the [deprecate-and-forward pattern](#deprecate-and-forward-pattern-non-view-classes) (new overload + deprecation + forwarding). **Both ObjC and Swift — there is no exception. This is mandatory: an inline replacement in a non-view class is always wrong — apply the full three-part pattern instead.** **This is the most common mistake in Swift files:** create a new method overload with `traitCollection: UITraitCollection`, deprecate the old method, and have the old method forward to the new one. Classes named `*Provider`, `*Downloader`, `*Manager`, `*ViewModel`, `*Processor`, `*Helper`, `*Generator`, `*Bridge`, `*Source`, `*DataProvider` are almost never view subclasses. The new overload must accept `traitCollection: UITraitCollection` (not `displayScale: CGFloat`).
      4. **Dead code?** Is this inside `#if 0`/`#endif` or `#if false`? → Do not modify, modernize, or replace code within the dead block. The code was already dead; modernizing it is pointless.
      5. **Different deprecation?** Before editing a line, verify it contains the target API (`UIScreen.main`/`UIScreen.mainScreen`). If the line instead contains `interfaceOrientation`, `UIDevice.current.orientation`, `UIInterfaceOrientationIsLandscape`, `UIInterfaceOrientationIsPortrait`, `statusBarOrientation`, `verticalSizeClass`, `horizontalSizeClass`, or any other deprecation — **do not touch it**. Each task is independent. This is the #1 source of out-of-scope changes. Even if the deprecated line is adjacent to or interleaved with UIScreen lines, leave it for its own task. **This applies per-line: read the original line before writing the replacement. If the original line does not contain the target API string, your edit is out of scope — revert it immediately.**
      
      ## Implementation Rules
      
      1. Preserve code style and formatting. Handle both Swift and Objective-C.
      2. **Scope rule:** Only modify lines containing the target deprecated API. If a line in your diff does not contain the target API in the original, the change is out of scope — revert it. Do not touch other deprecations, reformat code, or fix unrelated issues. **Cross-task contamination is an issue:** when working on UIScreen replacements, do NOT also fix `interfaceOrientation`, `UIDevice.current.orientation`, `self.interfaceOrientation`, `UIInterfaceOrientationIsLandscape`, `UIInterfaceOrientationIsPortrait`, `verticalSizeClass`/`horizontalSizeClass` conversions, landscape detection logic, or other deprecations that appear nearby in the same file. Each task in the Task Registry is independent. Even if you see an obvious modernization opportunity on an adjacent line, leave it alone. **Concrete example of a wrong change:** Replacing `UIInterfaceOrientationIsLandscape(self.interfaceOrientation)` with a `verticalSizeClass == .compact` check while doing UIScreen work — this is an orientation modernization, not a UIScreen modernization, and must not be included. **Only make changes that are directly covered by the active task. Do not make additional "bonus" fixes to nearby code, even if they address related deprecations. A diff that touches lines not containing the target API is out of scope.**
      3. **Invalidation rule:** When the user explicitly asks to register for trait changes — add it. When the user is general — determine if the value is cached (see gate question 2). If cached, add `registerForTraitChanges([UITraitDisplayScale.self])` with a handler that recalculates. If consumed fresh, skip. **Always use `registerForTraitChanges` — even when the original code uses `traitCollectionDidChange:`.** `traitCollectionDidChange:` is deprecated in iOS 17+ and the modern API is the recommended form. Register for the specific trait class (e.g., `UITraitDisplayScale`) rather than checking all trait changes. Always use a `withHandler:` block that directly sets the property, or a `withAction:` selector pointing to a method that directly recalculates it.
      4. **Replacement path rule:** When the user provides an explicit replacement expression, use it exactly. Do not substitute a generic fallback or shorter path. The named path reflects the correct scene/display context — substituting it loses that context. **Method parameters always take priority.** When a method parameter directly provides the needed value (e.g., a `CALayer *layer` parameter has `layer.contentsScale`, a view parameter has `.traitCollection.displayScale`), use the parameter — even if a longer path through `self` would also work. The parameter is the most local, most reliable source. A method that ignores an available `layer` parameter and instead navigates through `self.someController.someView.traitCollection.displayScale` is always wrong — use `layer.contentsScale`. When a notification's `object` provides the needed value (e.g., `notification.object` is the screen for `UIScreenBrightnessDidChangeNotification`, or `notification.object.coordinateSpace` for keyboard notifications), use `notification.object` — never substitute `self.view.window.screen` or another indirect path. **If the user names a specific view's trait collection, that path is mandatory — not optional.**
      5. **Parameter type rule:** When introducing a new method overload for deprecate-and-forward, the parameter must be `traitCollection: UITraitCollection` (Swift) or `traitCollection:(UITraitCollection *)traitCollection` (ObjC). Never use `displayScale: CGFloat` or `scale: CGFloat`. Extract `.displayScale` inside the new method body. This ensures callers pass the full trait collection, enabling future use of other traits without another API change. **User-instruction exception:** when the user explicitly asks for a different parameter (e.g., `scale: CGFloat`), use exactly the parameter name, type, and position they specify. **Parameter position:** when the user is general, place the new parameter at the end (before any trailing closure). When the user specifies a position, use that position exactly — do NOT move it to the end.
      7. **ObjC deprecation attribute rule:** In Objective-C, every deprecate-and-forward old method must carry a real deprecation **attribute** on its declaration — not just a comment. **Default to `__attribute__((deprecated("use <newMethodName> instead")));`**. **User-instruction exception:** when the user explicitly asks for a particular attribute, follow that — the default only applies when the user is general. The attribute belongs in the header where the method is declared; for private methods without a header, place it at the implementation. A `// Deprecated:` comment alone does NOT produce compiler warnings for callers and is insufficient. Apply this consistently to every ObjC deprecate-and-forward in a file.
      8. **All occurrences rule:** Replace ALL `UIScreen.main`/`UIScreen.mainScreen` occurrences in a file, including those inside utility function/macro calls (e.g., `UIRoundToScreenScale(UIScreen.mainScreen.scale, ...)` — replace the `UIScreen.mainScreen.scale` argument with `self.traitCollection.displayScale`). Leaving some occurrences unchanged while fixing others is a partial fix and leaves the file half-migrated.
      9. **Ternary preservation rule:** When existing code has a ternary with a non-UIScreen primary path, check whether both branches compute the **same semantic value** (e.g., both get display scale). If yes and `self.traitCollection.displayScale` provides that value, simplify the entire expression. If the primary path computes a **different value** or uses a valid public API for a different purpose, only replace the `UIScreen` fallback branch — do not remove or restructure the primary path.
      10. **Utility function rule:** When existing code uses utility functions that wrap `UIScreen.main.scale` (e.g., `UIRoundToScreenScale(value, UIScreen.mainScreen.scale)`, `UIRoundToScale`), prefer replacing the `UIScreen` argument with the modern equivalent while keeping the utility function call — do not reimplement the utility function's logic inline. For example, replace `UIRoundToScreenScale(value, UIScreen.mainScreen.scale)` with `UIRoundToViewScale(value, self.view)` or `UIRoundToScale(value, self.traitCollection.displayScale)` rather than manually inlining `(scale > 0) ? round(value * scale) / scale : value`.
      11. **Forwarding-chain consistency rule:** When a new method overload (from deprecate-and-forward) calls other methods on `self` or on wrapped/sub-objects, those calls must also use the `traitCollection:`-accepting version — not the deprecated version. A new method that internally calls the deprecated API on a sub-object silently ignores the passed `traitCollection`. This is a correctness bug. **Verify ALL code paths:** if the new method has branches (if/else, switch, guard/else, optional binding), check EVERY branch — not just the happy path. A common bug is correctly using `traitCollection` in one branch but falling back to the deprecated path in another.
      12. **Existing parameter preservation rule:** When a method already has a parameter that provides scale information (e.g., `displayScale: CGFloat`, `scale: CGFloat`), do NOT change that parameter's type to `UITraitCollection`. Replace the `UIScreen` usage inside the method body using the existing parameter. Only add a new `traitCollection: UITraitCollection` parameter when introducing a NEW method overload where the original method had no way to receive the value. Changing an existing `CGFloat` parameter to `UITraitCollection` is a broader API change than needed and breaks callers.
      
      13. **Defensive-guard preservation rule:** Leave unrelated defensive logic that wraps the screen access intact. `respondsToSelector:` checks, nil-window guards, `#available`/`@available` version checks, and similar conditionals exist for reasons unrelated to the deprecation — modernize only the `UIScreen.mainScreen` reference, not the conditional that wraps it. **Failure pattern:** an `if/else` with a `respondsToSelector:` check on the primary path and a UIScreen fallback on the else branch — replace the UIScreen fallback only, not the entire if/else. **Multiple constructor paths (e.g., `initWithFrame:` AND `awakeFromNib`) that each register handlers must NOT be consolidated** — both code paths exist for object-creation differences (programmatic vs. nib loading) that the modernization has no opinion about.
      
      ## Post-file Checklist
      
      Verify before moving to the next file:
      
      - [ ] Cached value (layer property, constraint, ivar, stored image, button image, setup/image-generation method output) → `registerForTraitChanges` present? Both API swap and registration are required for cached values — independent of any deprecate-and-forward also applied in this file.
      - [ ] `registerForTraitChanges` present → has `withHandler:` or `withAction:`? In a one-time setup method (not `layoutSubviews`)? Handler directly recalculates the property (not `setNeedsLayout` as proxy)?
      - [ ] `loadView` context → `CGRectZero`/`.zero` for initial frame? Never access `self.view` (infinite recursion crash).
      - [ ] View/VC instance method → `self.traitCollection`?
      - [ ] Class method or static method → deprecate-and-forward (not `self.traitCollection`)?
      - [ ] `CALayer *layer` parameter available → `layer.contentsScale`? Applies even in non-view classes.
      - [ ] Non-view class → full deprecate-and-forward (not inline)? Applies to `*Provider`, `*Manager`, `*Helper`, `*Generator`, `*Bridge`, `*Source`, `*DataProvider`, static computed properties, protocol extensions. Verify: NEW method with `traitCollection: UITraitCollection`, `@available(*, deprecated)` on old, deprecated wrapper forwards to the new overload. Applies regardless of project context or class name. **Exception:** `private`/`fileprivate`/`static` symbol with all callers in the same file → use the smallest-edit rule (modify signature in place, update in-file callers) per the file-local helper exception in [Pattern 1](#pattern-1-uiscreenmainscale--traitcollectiondisplayscale), step 5.
      - [ ] Old method/initializer KEPT as deprecated wrapper (not deleted)? When adding a new overload via deprecate-and-forward, the original declaration must remain in the file with the deprecation attribute. Removing it breaks ABI for out-of-diff callers and strips the migration signal.
      - [ ] Unrelated guards preserved? `respondsToSelector:` checks, nil-window guards, `#available`/`@available` checks, multiple constructor paths (`initWithFrame:` AND `awakeFromNib`) — all left intact unless the user explicitly asks to remove them.
      - [ ] ObjC deprecate-and-forward → real `__attribute__((deprecated(...)))` attribute on the declaration (not just a `// Deprecated:` comment)?
      - [ ] Deprecate-and-forward applied → are in-diff callers with a view in scope updated to call the new overload directly with `self.traitCollection` (not still on the deprecated wrapper)?
      - [ ] No whitespace-only edits? Every changed line is part of the targeted replacement or a structural part of the new pattern.
      - [ ] Nil-screen *object* fallback removed (`screen ?: [UIScreen mainScreen]`) → either kept an equivalent guard or added a TODO surfacing the new "non-nil screen assumed" behavior?
      - [ ] Existing `CGFloat` scale parameter preserved (not changed to `UITraitCollection`)?
      - [ ] Multiple methods need deprecate-and-forward → applied to ALL consistently?
      - [ ] `UIGraphicsImageRendererFormat(for:)` → deprecate-and-forward on **enclosing method**
  • SKILL.md 18.8 KB
    ---
    name: app-resizability
    description: "Use when an app's layout must adapt to a window that changes size while the app runs, or when an app must support more than one window. That includes being asked to make an app resizable or support a resizable window, a layout that breaks or does not adapt as the window resizes, and preparing or optimizing an app for the foldable iPhone Duo, split-screen multitasking, or Stage Manager. Modernizes the app by replacing legacy shared-state APIs with context-appropriate alternatives: mainScreen, interfaceOrientation, userInterfaceIdiom, application and scene lifecycle, and safe area insets."
    ---
    # App Resizability Skill
    
    ## When to Use
    
    Use this skill whenever the request is about any of the following, whether or not it names an API:
    
    - Getting an app ready for, or optimizing it for, the foldable iPhone Duo.
    - An app that must adapt when its window changes size while it runs.
    - Split-screen multitasking, Stage Manager, or a resizable scene.
    - Any deprecated API named in the Scope section below.
    
    The request does not have to be technical. "Get my app ready for the iPhone Duo", "optimize this app for the foldable iPhone", and "make this app resize properly" all activate this skill. iPhone Duo is the foldable iPhone, so treat questions about iPhone Duo or the foldable iPhone as the same request. Treat a request about the foldable iPhone Duo as a request for every task in the Task Registry, because a screen that changes size exposes all of them at once.
    
    ## Purpose
    
    Modernize UIKit apps to behave correctly on modern iOS by:
    - Eliminating references to legacy shared-state APIs
    - Migrating from application lifecycle to scene lifecycle
    - Supporting dynamic scene sizing and multi-window environments
    
    ## Scope
    
    This skill performs **specific, targeted modernizations** in both **Swift and Objective-C** codebases:
    - Replace legacy shared-state APIs with context-appropriate modern APIs
    - Migrate to scene-based lifecycle
    - Update apps to support a resizable user interface by removing usage of:
      - main screen (`UIScreen.mainScreen`, `UIScreen.main`)
      - interface orientation (`interfaceOrientation`)
      - user interface idiom (`userInterfaceIdiom`, `UI_USER_INTERFACE_IDIOM()`)
      - assumptions of symmetric safe areas (`safeAreaLayoutGuide`, `safeAreaInsets`)
      - application lifecycle in place of scene lifecycle (`UIApplicationDelegate`)
    
    ## Core Principles
    
    1. **Closest to consumer** — Prefer information nearest the point of use (e.g., view's trait collection over window's).
    2. **Always apply a replacement when the target API is present.** A TODO alone is a failure. **An empty diff for a file containing the target API is also a failure.** If the file contains the target deprecated API and a concrete replacement is feasible under any pattern in the active task's reference file, apply it. Only skip when the target API appears exclusively inside dead code (`#if 0`/`#endif`). When uncertain between two valid replacements, pick the one that best fits the user's request rather than producing an empty diff. **Never silently skip a file**: if you are unwilling to apply a change, talk to the user about possible options — never produce no output for it. **Do not get stuck weighing edge cases on simple files; when the substitution is obvious, apply it and move on.**
    3. **TODOs must be actionable.** State why the change is needed, what the replacement would look like, and any lifecycle or threading concerns. Place the TODO on its own line above the unchanged code, never inline. A vague TODO ("fix this later") is worse than none, because it consumes review attention and informs nobody.
    
       **Indent every line of an inserted comment to match the line it precedes.** A continuation line at a different indent leaves the block misaligned. Re-emitting the following line to realign it edits a line you were not asked to touch.
    4. **Don't add a redundant TODO when an existing annotation already covers the migration.** If the call site already has a `#pragma clang diagnostic ignored` paired with a bug-report reference, an existing `// TODO`, or a deprecation comment that points at the migration, do not add another one. Only add a new TODO when it provides additional migration guidance not present in the existing annotation.
    5. **Ask the user before making a risky code change; fall back to a TODO only when interactive guidance is unavailable.** When a replacement risks breaking callers or changing observable behavior (e.g., changing a method signature in a header that other modules import; substituting `width > height` for orientation when left-vs-right matters), the first move is to ask the user how to proceed. Only when the skill is running non-interactively, or when the user explicitly declines to provide guidance, drop a TODO and move on. This does **not** apply to standard, drop-in safe replacements specified by the active task's reference file — those must be applied per Core Principle 2.
    6. **Honor explicit user instructions; otherwise apply the defaults from the task reference file.** When the user asks for a specific approach — a particular attribute, parameter name, parameter position, trait source, or fallback behavior — use that exactly. Don't silently substitute what you consider the modern equivalent. When the user is general ("modernize this app", "fix `UIScreen.main` usages"), apply the defaults from the active task's reference file.
    7. **Never replace dynamic values with literals** — Always keep replacements dynamic.
    8. **Preserve control flow** — Prefer drop-in replacements that maintain the original code structure. Only add guard/early-return patterns when a direct substitution does not work. **When editing code around control flow (`if`/`else`, `switch`/`case`/`default`, `do`/`catch`), verify that the branching structure is preserved after your edit. Never collapse an `if`/`else` into sequential execution: both bodies then run unconditionally, which is a critical bug. Keep every branch whose condition is sound, and replace only the deprecated value inside it. Remove a branch only when the active task names that condition itself as the defect and states that the correct behavior is unconditional. In that case delete the condition, keep the body that matches the correct behavior, and name the branch you removed in your summary so a reviewer can check it.**
    9. **Stay in scope — no opportunistic cleanup.** Only modify lines containing the target deprecated API for the active task. Do NOT also fix other deprecation that happens to live nearby. Do NOT trim trailing whitespace, reformat blank lines, or "clean up" surrounding formatting. Even if you see an obvious modernization opportunity on an adjacent line, leave it alone — each task is independent and out-of-scope edits convert a successful in-scope change into a warning.
    10. **Extract repeated expressions** — When the same replacement value is used multiple times in a scope, extract it into a named local variable.
    11. **Never walk global scene/window state** — Never use `UIApplication.shared`, `UIDevice.current`, `UIScreen.main`, or other shared objects as a replacement. If no local object is available, modify the method to accept a new parameter and update its callers to pass one.
    12. **Complete patterns — atomic, never partial** — Every multi-part pattern the active task defines requires ALL of its parts applied together as a single unit. **When the active task requires both an API replacement AND a reactive update (e.g., trait change observation), these form a single atomic change — never apply one without the other.** If you cannot complete every part the task specifies, do not apply a partial change — either complete the pattern or skip with an explicit reason.
    13. **Preserve unrelated guards and fallbacks.** When removing a reference to the target API, change ONLY that reference. Do not simultaneously delete `respondsToSelector:` checks, nil guards, `if (x != nil)` defenses, version checks (`#available`, `@available`), or any other defensive logic that wraps the call site — unless the user explicitly asks for it. Each guard exists for an independent reason (selector availability across SDK versions, nil-window safety, feature flags); the modernization touches only the value you came to replace, not the surrounding control flow.
    14. **Off-target replacement guard.** Before editing any line, verify two things: (a) the line contains the **target deprecated API** for the **active task**, and (b) you're editing the deprecation the user asked about — not a nearby line that "looks similar."
    
    ---
    
    ## Prerequisites
    
    Run all three checks once per project, before Phase 1. These checks are read-only: add no key, change no value, and delete no key, in the `Info.plist` or in the build settings. Report what you read, then continue with the workflow. An app that fails a check cannot resize, whatever the source code says.
    
    Read each key in the target `Info.plist` and in the build settings. Build settings live in the project file and in any `.xcconfig`. The build merges every source, so read them all before you report. A key missing from one source proves nothing. When two sources set the same key, the project file wins.
    
    Skip all three checks when the request names files rather than a project. Say nothing about them. Do not search the file system for a project.
    
    | Check | Already satisfied when | Otherwise report |
    |-------|------------------------|------------------|
    | Launch screen | The target sets `UILaunchScreen`, `UILaunchScreens`, `UILaunchStoryboardName`, `UILaunchStoryboards`, or `INFOPLIST_KEY_UILaunchScreen_Generation` | The app declares no launch screen. iOS 27 rejects the App Store upload with `ITMS-90870`. Refer to [TN3208](https://developer.apple.com/documentation/technotes/tn3208-preparing-your-apps-launch-screen-to-meet-app-store-requirements). One key is enough, so leave an existing storyboard alone |
    | iPad orientations | The iPad declaration lists all four orientations, or the target declares none. `UISupportedInterfaceOrientations~ipad` is the iPad declaration, and plain `UISupportedInterfaceOrientations` is when that key is absent | Name the orientations the declaration omits. Add that a `supportedInterfaceOrientations` override can still lock the scene, and that you did not look for one. Check iPad only: a portrait-only iPhone declaration is normal |
    | Full screen opt-out | `UIRequiresFullScreen` is absent, or `UIRequiresFullScreenIgnoredStartingWithVersion` is already set | The target sets `UIRequiresFullScreen`. iOS 27 ignores it and resizes the scene anyway. Refer to [TN3192](https://developer.apple.com/documentation/technotes/tn3192-migrating-your-app-from-the-deprecated-uirequiresfullscreen-key) |
    
    Both keys in the last row stay exactly as you found them. Never delete `UIRequiresFullScreen`, because deletion makes the app resizable at once and the layouts may not be ready. Never add `UIRequiresFullScreenIgnoredStartingWithVersion`, because its value decides which releases keep the old behavior, and that is the developer's decision. A value of 27 or earlier makes the app fully resizable on iOS 27, which is the opposite of a safe step. The key comes out after the app handles resizing. Confirming that means running the app and resizing it. One view controller can break while the rest is correct.
    
    ---
    
    ## Workflow
    
    ### Phase 0: Fast Path for Simple Cases
    
    **Before reaching for the decision tree, check if the occurrence matches the simple case.** A large fraction of `UIScreen.main`/`UIScreen.mainScreen` occurrences are simple substitutions inside a UIView/UIViewController instance method where the value is consumed fresh. These cases need no analysis — just substitute and move on:
    
    | Original | Replacement |
    |----------|-------------|
    | `UIScreen.main.scale` (Swift) inside a UIView/UIViewController instance method, used inline (not stored) | `self.traitCollection.displayScale` |
    | `[UIScreen mainScreen].scale` (ObjC) inside a UIView/UIViewController instance method, used inline (not stored) | `self.traitCollection.displayScale` |
    | `UIScreen.main.scale` inside `layoutSubviews`, `drawRect:`, `updateConstraints`, or `viewIsAppearing:` | `self.traitCollection.displayScale` (no registration needed — UIKit auto-calls these on trait change) |
    
    **Do not over-think simple substitutions.** If the enclosing class is `UIView`/`UIViewController` and the value isn't being assigned to an ivar, layer property, constraint, or stored image, just substitute. **Empty diffs on simple files are the most common mistake — apply the substitution and move on.** Reach for the decision tree only when the simple case doesn't fit (non-view class, cached value, class/static method, special user instructions).
    
    ### Phase 1: Detection
    
    Identify patterns to modernize using each relevant task file's detection patterns. Run detection for every task in the Task Registry that applies to this codebase, not just one — see [Task Registry](#task-registry) below.
    
    ### Phase 2: Analysis
    
    For each occurrence, read surrounding context to understand:
    - Class hierarchy (UIView/UIViewController subclass vs plain NSObject vs non-view class)
    - Method type (instance, static, free function, cached `dispatch_once` helper)
    - Lifecycle phase (init, viewDidLoad, viewWillAppear, layoutSubviews)
    - Code intent (what question the code was asking: available space, layout variant, rendering, device class, lifecycle)
    
    The active task's reference file may add task-specific bullets to this list.
    
    Use subagents to identify code that needs to be updated to keep your context window small.
    
    ### Phase 3: Decision & Validation
    
    | Condition | Action |
    |-----------|--------|
    | Safe 1:1 replacement exists | **Apply it.** No added commentary (no `// TODO: FIXME`, no `// TODO`, no `// FIXME` — just the replacement). Use the replacement specified by the active task's reference file. |
    | Multiple valid approaches or code relocation >10 lines | **Ask the user.** |
    | No safe replacement possible (extremely rare) | **Add todo** with an explicit task outlined for the user. Never produce a silent empty diff. Re-check every pattern with a subagent before concluding nothing applies. |
    
    Use subagents to validate against the active task's Post-file Checklist before any code change.
    
    ### Phase 3b: File Processing Completeness
    
    **Process EVERY file that contains the target deprecated API.** Do not stop early, skip files, or silently drop files from the work queue. A file that was identified in Phase 1 but produces no diff and no skip explanation is a processing failure.
    
    **Explicit file tracking:** At the start of processing, write out the complete list of files to be modified using available task / todo tools or a markdown file. As you process each file, mark it done. Before finishing, compare this list against your output — any file without a diff or an explicit skip reason is a failure that must be addressed before completing.
    
    **Context size:** If you are concerned about context size, use subagents to process individual files or tasks.
    
    **Silent-drop prevention:** Before finishing, use subagents to compare the list of files you were given against the list of files you produced output for. If any file is missing from your output, go back and process it. Common causes of silent drops:
    - **File size:** Large files (1000+ lines) are not exempt. Process them with the same approach.
    - **Complexity:** Files with preprocessor macros, complex class hierarchies, or unusual code patterns still need changes.
    - **Project grouping:** Do not skip all files from a specific project or directory. If you notice you've dropped multiple files from the same project, that indicates a systematic issue — investigate and fix.
    - **Ambiguity:** If you're unsure how to fix a file, ask the user — do not silently produce an empty diff.
    
    **Large or complex files:** Files with heavy preprocessor usage (`#if`/`#ifdef` nesting), 1000+ lines, or less common patterns (C++ interop, `dispatch_once` caching, deeply nested macros) are not exempt from processing. If the target API appears in such a file, apply the same decision tree. If the file is too large to edit in one pass, process the deprecated API usages one at a time. Use subagents if helpful. If you genuinely cannot determine a safe replacement due to macro expansion or preprocessor complexity, ask the user — never silently skip it.
    
    **Batch processing discipline:** When processing a list of files, do NOT attempt to analyze all files first and then produce all diffs at once. Instead, process files **one at a time or in small batches (3–5 files)**: read context, decide, produce the diff, then move to the next batch. This prevents the tail end of the file list from being silently dropped due to output limits or context exhaustion. If you notice you have produced output for fewer files than you were given, STOP and process the remaining files before finishing.
    
    If you find empty diffs for files that should have straightforward replacements, go back and process them — straightforward files are fast to handle and should never be dropped.
    
    ### Phase 4: Implementation
    
    Apply the active task's implementation gates, rules, and post-file checklist from its reference file. The pattern-specific decision tree, gate questions, and validation rules live alongside the patterns they govern in each task file. Use subagents for verification.
    
    ### Phase 5: Final Verification
    
    **File coverage audit:** Use subagents to compare the list of files you were given (or detected in Phase 1) against the files you actually produced diffs for. Every input file must have a non-empty diff. If any file is missing changes, go back and process it now.
    
    The active task's reference file may add task-specific verification steps.
    
    ---
    
    ## Task Registry
    
    Apply every task in this registry to the codebase unless the developer's request explicitly scopes to a subset. Each task is independent and has its own detection patterns, decision tree, and verification rules in its reference file. Run them in order from top to bottom.
    
    | Task | File | Description |
    |------|------|-------------|
    | UIScreen.main modernization | [uiscreen-task.md](references/uiscreen-task.md) | Replace `UIScreen.main` with context-appropriate APIs |
    | userInterfaceOrientation modernization | [orientation-task.md](references/orientation-task.md) | Replace layout-related orientation checks with size classes or window bounds, and migrate the deprecated scene geometry callback |
    | Scene lifecycle migration | [scene-lifecycle-task.md](references/scene-lifecycle-task.md) | Migrate AppDelegate to SceneDelegate |
    | Safe Area Insets | [safe-area-task.md](references/safe-area-task.md) | Replace `topLayoutGuide` and `bottomLayoutGuide`, replace hard coded values for insets with safe area references, and ensure that existing references work with asymmetric safe areas and with insets that change while the app runs |
    | userInterfaceIdiom modernization | [idiom-task.md](references/idiom-task.md) | Replace layout-related idiom checks with size classes, and read a genuinely needed idiom from the local trait collection |

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related