# Lee Johnson (leej.uk): full text > The readable text of every public page on https://leej.uk/, for AI assistants and search tools. Lee Johnson is a UK app and web developer: Apple Watch and iOS apps, websites, data sites and 244 free browser-based developer tools. Short index: https://leej.uk/llms.txt. Sitemap: https://leej.uk/sitemap.xml. Contact: hello@leej.uk. --- ## Apple Watch, iPhone & Mac Apps | Lee Johnson URL: https://leej.uk/apps Apple apps by Lee Johnson for Apple Watch, iPhone and Mac, including Stromascape, +MetricTime, bPositive and Tipple Units. Built in Swift and SwiftUI. ### Stromascape watchOS Live Experience the sophisticated realm of Stromascape Apple Watch faces, meticulously designed with artistic precision and featuring dynamic live complications and extensive customisation options. Currently offering 49 meticulously crafted faces, with new designs consistently introduced to enhance your collection. - Web version: stromascape.com ↗ - App Store ↗ - Project page → ### +MetricTime watchOS Available Explore a revolutionary perspective on timekeeping with MetricTime, featuring a decimal-based system with 10 hours and beyond. This innovative approach underscores the continuous evolution of horological science and its enduring intrigue. - App Store ↗ - Review and analysis → - Enhancing the app → ### bPositive watchOS Available This watchOS application is engineered to inspire and empower users on their path to enhanced self-positivity and mental well-being. It delivers carefully curated motivational quotes encompassing themes such as self-love, resilience, positivity, and personal development. Discover 220 carefully selected quotes designed to spark positivity and inspire daily. These concise, uplifting affirmations appear hourly on your Apple Watch from 8 AM to 9 PM, fostering self-belief and resilience with every gentle reminder. ### Tipple Units iOS and watchOS Coming soon Take control of your drinking with Tipple Units. Stay informed and make smarter choices with Tipple Units, a user-friendly iOS and watchOS app designed to monitor your alcohol consumption with precision. Effortlessly track every drink by inputting alcohol percentage, volume, and timing, and let the app calculate your intake using trusted UK government guidelines. Tipple Units goes beyond tracking: it estimates your safe driving time based on your body's alcohol metabolism, helping you stay safe and responsible. Coming soon to the App Store ### +StromaNotes watchOS In production A sophisticated and user-friendly note-taking application designed exclusively for the Apple Watch, offering seamless integration of voice and text input. Capture ideas, reminders, and tasks on the go, using voice dictation or manual text entry directly from your wrist. With its intuitive interface and robust performance, it ensures a streamlined and efficient note-taking experience, optimised for the Apple Watch's unique capabilities. ### AiCuttr macOS In testing Say goodbye to AI-cluttered Google search results. A sleek macOS app that filters out AI-generated content, delivering clean, human-curated results that respect your search intent. Simple to use, lightweight, and built for those who value authentic information. ### The apps at a glance Apps by platform watchOS - Stromascape Live - +MetricTime Available - bPositive Available - +StromaNotes In production iOS and watchOS - Tipple Units Coming soon macOS - AiCuttr In testing Six apps across Apple Watch, iPhone and Mac. Where each app is - In production +StromaNotes - In testing AiCuttr - Coming soon Tipple Units - Available Stromascape, +MetricTime, bPositive From first build to the App Store. bPositive's day An affirmation on the hour, every hour from 8am to 9pm, drawn from 220 quotes. ### What I do on Apple platforms Native apps for Apple Watch, iPhone and Mac, written in Swift and SwiftUI with Xcode. The Apple Watch is my speciality: a screen where every tap, every second of attention and every percent of battery counts, so design and engineering have to work as one. - watchOS apps and complications SwiftUI interfaces designed for glanceable use on the wrist, complications with WidgetKit and ClockKit, and scheduled content such as bPositive's hourly affirmations from 8am to 9pm. - iPhone and Apple Watch together Apps that work across both devices, such as Tipple Units, which tracks drinks against UK government guidelines on iPhone and Apple Watch. Keeping the two in step with WatchConnectivity is covered in my articles. - Mac utilities Lightweight macOS tools such as AiCuttr, which filters AI-generated clutter out of search results. - Watch face design StromaScape: a collection of Apple Watch faces with live complications, fully editable layers and a typeface I designed for the brand. - Performance and battery life Profiling with Instruments, efficient background work and lean SwiftUI views, covered in depth in my article on battery efficiency. - Accessible by default VoiceOver labels, Dynamic Type and clear contrast built in from the start rather than added at the end. - Modern Swift async/await, Combine, actors and the wider Swift standard library, documented in my Swift A–Z reference for other developers. The full skill set: web, data, infrastructure and delivery → ### Let's connect Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen. Start a project → Articles · Swift A–Z · Projects --- ## iOS Development, Swift & Apple Watch Articles | Lee Johnson URL: https://leej.uk/articles In-depth iOS development guides in Swift and SwiftUI, plain-English advice for businesses planning an app, and Apple Watch articles by Lee Johnson. ### iOS development In-depth guides to building modern iPhone apps in Swift and SwiftUI, with current code you can copy. 01 iOS · SwiftUI · Architecture · 9 min read #### SwiftUI state and app architecture with Observation How to structure a SwiftUI app with the Observation framework: who owns state, @State vs @Bindable vs @Environment, services, loading and errors. Read the article → 02 iOS · Swift · Concurrency · 9 min read #### Swift concurrency in practice: async/await, actors and the main actor A practical guide to Swift concurrency for iOS apps: async/await, async let, task groups, actors, cancellation, Sendable and main-actor isolation. Read the article → 03 iOS · SwiftData · Persistence · 8 min read #### Persisting data with SwiftData: models, queries, background work and migrations A practical SwiftData guide for iOS apps: defining models and relationships, @Query, background imports with @ModelActor, schema migrations and iCloud sync. Read the article → 04 iOS · Performance · Instruments · 8 min read #### Finding and fixing iOS performance problems with Instruments How to find and fix slow launches, hangs, janky scrolling and memory growth in iOS apps with Instruments, signposts, XCTest metrics and MetricKit. Read the article → 05 iOS · SwiftUI · Accessibility · 8 min read #### Accessible iOS apps in SwiftUI: VoiceOver, Dynamic Type and beyond Make SwiftUI apps work for everyone: VoiceOver labels and actions, Dynamic Type layouts, contrast, Reduce Motion, automated audits and App Store labels. Read the article → 06 iOS · Testing · Quality · 7 min read #### Testing iOS apps with Swift Testing and XCTest What to test in an iOS app and how: Swift Testing's @Test and #expect, parameterised tests, async code, test doubles, UI tests with XCTest and CI. Read the article → 07 iOS · WidgetKit · App Intents · 7 min read #### Beyond the app icon: widgets, App Intents and Live Activities How to put your iOS app's most useful actions on the Home Screen, Lock Screen, Siri and Spotlight with WidgetKit, App Intents and ActivityKit. Read the article → 08 iOS · Privacy · App Store · 7 min read #### Privacy manifests, permissions and passing App Store review Get iOS apps through App Store review: privacy manifests, required-reason APIs, permission prompts, tracking, account deletion and review notes. Read the article → ### For clients Plain-English guides for businesses planning an app: costs, platforms, launch, upkeep and choosing a developer. A reading path for your iOS app - Choose a developer - Plan the MVP - Native or cross-platform - What it costs - Launch on the App Store - Look after it The client guides in the order a project usually needs them. 01 For clients · Budget · Scoping · 8 min read #### What an iOS app really costs, and how to scope one properly What decides the cost of building and running an iOS app: features, design, backend, integrations, testing, Apple fees and upkeep, and how to scope it. Read the article → 02 For clients · Strategy · Platforms · 7 min read #### Native iOS or cross-platform? A plain guide for businesses Swift and SwiftUI, React Native, Flutter, Kotlin Multiplatform or a web app? How to choose an app platform for your business, with honest trade-offs. Read the article → 03 For clients · App Store · Launch · 7 min read #### From build to App Store: the iOS launch process and a realistic timeline Every step between a finished build and your app on the App Store: developer accounts, TestFlight, product page, privacy details, review, release options. Read the article → 04 For clients · Maintenance · Planning · 7 min read #### After launch: what it really takes to maintain an iOS app What keeping an iOS app healthy involves after launch: yearly iOS releases, Apple's SDK deadlines, crashes, dependencies, security, reviews and planning upkeep. Read the article → 05 For clients · Hiring · Ownership · 7 min read #### How to choose an iOS developer: questions to ask and red flags to spot Hiring an iOS developer or agency? What to look for, the questions to ask, red flags, and how to make sure you own your code and Apple Developer account. Read the article → 06 For clients · MVP · Planning · 6 min read #### The iOS app MVP checklist: what to build first, and what to leave out A practical checklist for planning an iOS MVP: defining the core journey, what version one needs, what it can skip, and the launch basics Apple requires. Read the article → 07 For clients · Case study · watchOS · 7 min read #### Case study: small, focused Apple apps, and what they show about scoping A walk through Lee Johnson's own Apple apps, from MetricEdition to Tipple Units, and the scoping decisions behind them that apply to any client project. Read the article → ### Apple Watch Building and designing for the Apple Watch, plus a review of my MetricEdition app and its code. 01 watchOS · Swift · SwiftUI · 10 min read #### How to Build Your First Apple Watch App: A Detailed Step-by-Step Guide with Swift & SwiftUI The Apple Watch is a remarkable device that extends the power of iOS development to the wrist, allowing for intimate, glanceable interactions. Read the article → 02 watchOS · Design · SwiftUI · 19 min read #### Designing for the Apple Watch: Crafting Impactful Experiences with Swift & SwiftUI Crafting Impactful Apple watch experiences with Swift and SwiftUI. Read the article → 03 iOS · watchOS · Performance · 8 min read #### Optimising Battery Efficiency: Techniques and Tools for Lightweight iOS and watchOS Apps Battery life remains one of the most important factors in user satisfaction for iOS and watchOS apps. Read the article → 04 watchOS · App review · 6 min read #### MetricEdition Watch App – An In‑Depth Review and Analysis Innovation often starts with curiosity. MetricEdition, created by Lee Johnson under the StromaScape brand, is a perfect example of how curiosity about time… Read the article → 05 watchOS · SwiftUI · Code · 20 min read #### Enhancing the MetricEdition Apple Watch App: A Comprehensive Guide to Proposed Changes Innovation often starts with curiosity. MetricEdition, created by Lee Johnson under the StromaScape brand, is a perfect example of how curiosity about time… Read the article → A–Z Reference #### Swift A–Z reference Essential Swift and SwiftUI terms from Actor to ZStack, each with a clear definition, a code example and a usage tip. Open the reference → ### iOS in practice Further practical guides to Swift, SwiftUI and the details of shipping on iOS, beyond the foundations. 01 iOS · SwiftUI · Migration · 14 min read #### How to migrate a UIKit app to SwiftUI A practical, incremental way to move a UIKit app to SwiftUI: wrapping views, sharing state, navigation, testing and when to leave UIKit in place. Read the article → 02 iOS · SwiftUI · Navigation · 14 min read #### SwiftUI NavigationStack and deep links How to model SwiftUI navigation as state with NavigationStack, typed routes, deep links, state restoration and the usual broken-stack bugs. Read the article → 03 iOS · StoreKit · Monetisation · 13 min read #### StoreKit 2 in-app purchases for indie apps How to add StoreKit 2 purchases and subscriptions to a small iOS app: products, entitlements, testing in Xcode and the checks App Review expects. Read the article → 04 iOS · Networking · URLSession · 14 min read #### Reliable iOS networking with URLSession How to build reliable iOS networking with URLSession: typed errors, retries, cancellation, decoding, auth headers and tests, without a heavy library. Read the article → 05 iOS · Background work · BGTaskScheduler · 12 min read #### iOS background tasks with BGTaskScheduler How to use BGTaskScheduler for refresh and processing tasks, what iOS will and will not guarantee, and how to test, budget and debug background work. Read the article → 06 iOS · Localisation · UK English · 12 min read #### How to localise an iOS app, including UK English How to localise an iOS app: String Catalogs, UK versus US English, dates, currency, images, pluralisation and tests that catch missing strings. Read the article → 07 iOS · Push · UserNotifications · 13 min read #### iOS push notifications that users keep on How to add iOS push notifications that earn their permission: when to ask, tokens, rich notifications, Focus modes and the work on the server. Read the article → 08 iOS · Swift · Swift Package Manager · 10 min read #### How to ship and version a Swift package How to create, version and publish a Swift package: API design, semantic versioning, Git tags, DocC, privacy manifests and breaking changes. Read the article → 09 iOS · App Clips · Product decisions · 12 min read #### When an App Clip is worth building What an App Clip can and cannot do, the size and invocation limits, and a plain decision guide for whether a small app should have one. Read the article → 10 iOS · PhotoKit · Privacy · 11 min read #### Privacy-safe photo access with PhotoKit How to read and write photos with PhotoKit under limited library access, when to use the picker, truthful permission strings and review issues. Read the article → 11 iOS · SwiftUI · Design QA · 11 min read #### Using SwiftUI previews for design QA How to use SwiftUI previews as a design QA workflow: traits, Dynamic Type, colour schemes, sample data, snapshot tests and keeping previews fast. Read the article → 12 iOS · CI · DevOps · 11 min read #### Xcode Cloud vs GitHub Actions for iOS CI A practical comparison of Xcode Cloud and GitHub Actions for iOS: signing, tests, cost, privacy, runners and a setup that can use either. Read the article → ### Technical SEO and AI search Technical SEO and AI-visibility guides: crawlers, sitemaps, schema, indexing and how to write pages that search and AI can use. 01 Technical SEO · AI search · llms.txt · 13 min read #### How to write llms.txt that AI tools can use How to write llms.txt and llms-full.txt for AI tools: what to put in each file, where they go, how they relate to robots.txt, and how to check them. Read the article → 02 Technical SEO · AI crawlers · robots.txt · 12 min read #### How to control AI crawlers without blocking Google How to allow, limit or block AI crawlers with robots.txt, headers, firewall rules and usage policies, without taking the site out of Google. Read the article → 03 Technical SEO · Canonicals · Duplicate URLs · 12 min read #### Canonical tags, trailing slashes and duplicate URLs How duplicate URLs appear through trailing slashes, www, parameters and HTTP, how to set an honest canonical tag, and how to check it live. Read the article → 04 Technical SEO · Migrations · Checklist · 12 min read #### Website migration SEO checklist A practical SEO checklist for moving a website: inventory, URL map, redirects, canonicals, Search Console, AI crawlers and go-live checks. Read the article → 05 Technical SEO · Indexing · Soft 404s · 12 min read #### How to find and fix soft 404 errors What a soft 404 is, why ‘page not found’ served as HTTP 200 quietly drops pages from search, and a method for finding and fixing them on a small site. Read the article → 06 Technical SEO · Sitemaps · Indexing · 12 min read #### How to keep an XML sitemap accurate How to build and maintain an XML sitemap that matches the live site: what to include, lastmod, images, coverage gaps, and the checks that catch stale URLs. Read the article → 07 Technical SEO · robots.txt · Indexing · 12 min read #### robots.txt vs X-Robots-Tag: which to use When to use robots.txt and when to use X-Robots-Tag or a meta robots tag, what each one can actually do, and a simple rule for HTML pages, files and APIs. Read the article → 08 Technical SEO · Internal linking · Site architecture · 12 min read #### Internal linking for small websites […continues at the page URL] --- ## Accessible Colour Contrast, WCAG and APCA | Lee Johnson URL: https://leej.uk/articles/accessible-colour-contrast-wcag-apca How to choose readable text and UI colours: WCAG contrast, APCA, colour blindness, dark mode, and a method that does not rely on one magic ratio. On this page 10 sections - Readable colour is a requirement, not a preference - WCAG contrast, and what the ratios mean - APCA, and why some teams look at it too - Body text, UI chrome and disabled states - Dark mode is a second palette, not an invert - Colour blindness and charts - A method for picking a palette and checking it - What automated checks miss - Key takeaways - Frequently asked questions I keep light and dark palettes on leej.uk, and I built the Contrast Checker and the APCA Palette Optimiser because a pair that looked fine on a calibrated monitor did not always survive a phone screen. The method below is how I pick and check a palette, not a promise that one ratio will do all of the work. ### Readable colour is a requirement, not a preference Colour is part of making a page usable, in the same way typography and focus states are. A light-grey paragraph on a white card may match a design file and still be a strain. A green-and-red chart may be obvious to you and unreadable to someone with deuteranopia. A dark theme that is only an invert of the light theme will usually produce at least one failing pair. This is not a request to make the site ugly, or to abandon a brand colour. It is a request to give body text, controls and data enough contrast against the background they actually sit on, in both themes, at the size they are actually used. I use the same seriousness on the web that I do when I write about accessible iOS apps in SwiftUI: if a person has to struggle to read the primary content, the feature is not finished. Brand colours often fail as text. They can still be used for a large display heading, a border, or a block of fill, with a neutral, high-contrast colour for the body. That is a design decision you should make on purpose. ### WCAG contrast, and what the ratios mean WCAG 2 measures contrast as a ratio between two colours, from 1:1 (identical) to 21:1 (black and white). For current WCAG 2.1 and 2.2 conformance, the commonly used targets are: - Normal text, Level AA: at least 4.5:1. - Large text, Level AA: at least 3:1. “Large” here means at least 18 point (typically 24 CSS pixels) or 14 point bold (typically 18.66 CSS pixels). Confirm the current wording in the Understanding document if your type is near the boundary. - Normal text, Level AAA: at least 7:1, and 4.5:1 for large text. - User interface components and graphical objects needed to understand the content, Level AA (1.4.11 Non-text Contrast): at least 3:1 against adjacent colours. Those ratios are the conformance method for WCAG 2. If you need to claim AA, you measure with this algorithm, using the text size and weight that the user actually gets, including after zoom and after your CSS has run. A few practical cautions: - 4.5:1 is a minimum for AA, not a comfortable ceiling, especially for small, light-weight text, or for people reading on a poor display. If a paragraph is 14 pixels and weight 300, I would rather exceed 4.5:1 than sit on it. - Placeholder text, disabled text and legal footnotes still need to be readable if a person is expected to read them. Disabled controls have some nuance in the guidelines; do not use that as a reason to fade important helper text until it vanishes. - The ratio is between the text and the background it actually sits on, including a gradient, an image, or an overlay. A 4.5:1 pair on a flat swatch can fail on a photograph. - Link colour must contrast with the background, and if you do not use another cue such as an underline, it must also be distinguishable from surrounding text. Do not rely on colour alone. The Contrast Checker will compute a WCAG ratio for a pair. It will not see the image behind your hero heading unless you test that pairing too. ### APCA, and why some teams look at it too APCA, the Accessible Perceptual Contrast Algorithm, is a newer way of estimating how contrast is perceived, including the difference between dark text on a light background and light text on a dark background. It is part of the work around WCAG 3, and it is not how you conform to WCAG 2 today. I will not pretend a particular APCA value is a legal stand-in for 4.5:1. It is still a useful second check, because WCAG 2’s ratio can rate some dark-mode pairs more generously, or more harshly, than they feel, and because polarity (which colour is the text) matters to readers. APCA guidance is expressed in Lc (lightness contrast) values, with different suggested floors for body text, large headings, and UI, which also depend on size and weight. Those lookup tables can be updated; use the current APCA documentation when you adopt a number, and do not treat a remembered Lc as a permanent standard. On this site I use WCAG 2 as the ship/no-ship bar for conformance, and I use APCA, via the APCA Palette Optimiser, when I am building a palette, especially the dark one, to catch pairs that are technically close to a WCAG pass and still feel thin. If the two methods disagree, I do not pick the more flattering one. I tighten the pair until I am happy with both, or I change the type size. ### Body text, UI chrome and disabled states Different jobs need different floors, even inside one theme. Body text and long-form reading should be the most conservative. This is where I want a comfortable WCAG margin, not a pair that only just passes at 16 pixels. If the brand colour cannot get there on the page background, the body should use a near-black or near-white of the theme, not the brand hue. Headings can sometimes use a slightly lighter or more colourful treatment if they are large enough to meet the large-text rule, but a thin, pale heading over a busy image is still a failure. Check the actual background. UI chrome — buttons, borders, icons, form outlines, focus rings, graph axes — must meet the non-text 3:1 requirement where the component has to be seen to be used. A 1-pixel pale border around an input on a white card is a common miss. A focus ring that is only a slight colour shift is another. If you design for small screens or for a watch-like density, the same lesson I use when designing for the Apple Watch applies: the control has to remain identifiable at a glance, not only in a large desktop mock. Disabled states are often faded until they fail both body and non-text contrast. If the control is visible, the label should still be readable enough to be understood, or you should hide the control instead of leaving a ghost of the text. Do not rely on “disabled is meant to look faint” as a complete argument. Visited and hover styles should not collapse to a colour that only works for one of those states. Test each. ### Dark mode is a second palette, not an invert Inverting a light palette, or swapping each colour for its complementary, will usually produce at least one of these problems: - Mid greys that passed on white become muddy on near-black, or the reverse. - Brand colours that were dark enough as text on cream are not light enough as text on charcoal, or they glow and vibrate. - Borders that were dark lines on paper become invisible on a dark surface. - Shadows and overlays that assumed a light page no longer separate layers. - An APCA or perceptual check disagrees with a WCAG pass you had relied on, because polarity changed. Build a second palette: backgrounds, surfaces, body text, muted text, borders, links, focus, and alerts, each tested against the surface it sits on. Then test the same components in both themes, including images and charts. A theme switch that only inverts the CSS variables without a second pass is unfinished. I would catch that in a preview the same way I would catch a layout bug in SwiftUI previews for design QA: look at both states, on purpose, before you ship. Also test a system “increase contrast” or forced-colours mode if you can. The Forced Colours Previewer is a way to see how little of your brand survives when the user has asked the browser to impose a colour scheme. The page should still be readable, with your borders and focus styles not depending on colours that will be discarded. ### Colour blindness and charts Colour-vision deficiency is common, and it is not one condition. Deuteranopia and protanopia affect red–green distinctions; tritanopia affects blue–yellow. A red-versus-green success and error pair, or a “two-line” chart in blue and purple that collapse together, will lose information even when both lines passed a contrast check against the background. Rules that hold up: - Do not use colour as the only cue. On a chart, use position, pattern, line style, or a direct label. In a form, use text and an icon as well as a red border. - Check the palette through a simulator. The Colour Blindness Simulator will show you whether two series still separate. The CVD-Safe Chart Palette is a starting set, not a guarantee: you still have to test it on the actual chart, at the actual size, on both backgrounds. - Adjacent colours in a key or a choropleth must also contrast with each other enough to be told apart, not only with the page background. - Print and a greyscale export are still a useful sanity check for a data graphic, even if you never print the page. A pretty palette that fails these tests is a decoration, not a data display. ### A method for picking a palette and checking it This is the sequence I use when I add a theme, or when I am asked to “just use the brand colours”: - Lock the backgrounds first: the page, the card, the header, in both light and dark. These are the surfaces everything else must work on. - Choose a body text colour that is comfortable on each surface. Measure it with a WCAG checker, and look at a paragraph, not a single word in a swatch. Adjust until you have margin above 4.5:1, and until an APCA check does not look thin for that size. - Choose muted text, links, and visited links, and test each against the same surfaces, including link-in-paragraph contrast if you are not underlining. - Set borders, icons, and focus rings to at least the 3:1 non-text bar, on every surface they appear on, including hover and dark mode. - Place the brand colour where it can be large or decorative, or where you have already shown it can meet the bar as text. If it cannot, do not use it for small labels. - Build a chart palette with a colour-blind safe set, extra cues, and a check on both themes. - Walk the real components: a form, a table, a button row, an alert, a disabled field, a hero with an image, a mobile width. Use the Contrast Checker on any pair you are unsure about, including text over a photograph. - Only then save the tokens. A palette that exists only in a logo presentation will not survive contact with a footer of 12-pixel links. If a pair fails, change the colour, the size, the weight, or the background. Do not change the requirement. ### What automated checks miss Automated contrast tools are necessary and incomplete. They typically miss: - Text on images, gradients, video, or glassmorphism that was sampled from the wrong pixel. - Text that is computed in CSS (color-mix, transparency, a theme variable that does not apply in the test) and is not the colour in the design token file. - Overlays, sticky headers, and cookie banners that sit on top of copy. - Placeholders, ::selection, and caret colours. - Focus rings drawn as a shadow rather than a border, which some checkers do not treat as the component boundary. - Charts rendered in a canvas, and colours that only appear after JavaScript runs. - Forced-colours and high-contrast operating system settings. - The lived experience of small type, thin fonts, and a dim screen, which no ratio fully captures. A heavy page is also harder to read on a slow, dim screen, so a performance budget and readable colour are allies, not separate polish passes. So you still have to look. Automate the tokens and the obvious text-on-surface pairs, then preview the real pages in both themes, with a colour-blindness simulation, at a realistic size. Write the checks down the same way you would write any other method meant to be cited accurately: the ratio you claim should be the one you measured. If a check cannot see the hero text, you are not done. ### Key takeaways - WCAG 2 contrast ratios are the current conformance method: 4.5:1 for normal text at AA, 3:1 for large text and for needed UI graphics. Treat 4.5:1 as a floor, especially for small type. - APCA is a useful second, perceptual check, particularly in dark mode, but it is not a substitute for WCAG 2 conformance, and its suggested values can change. - Build a second palette for dark mode; do not invert the light one and hope. Test body text, muted text, borders, focus and charts on the surfaces they actually use. - Do not rely on colour alone in charts or errors. Use a colour-blind safe palette and a second cue, and check both themes. - Automated checkers miss text on images, overlays, canvases and forced colours. Walk the real components before you freeze the tokens. If you want a second pair of eyes on a palette that only works in the light theme, tell me about it. ### Frequently asked questions What contrast ratio does WCAG require for text? For WCAG 2 Level AA, normal text should be at least 4.5:1 against its background, and large text at least 3:1. Level AAA raises those figures to 7:1 and 4.5:1. Large text is defined by size and weight in the WCAG Understanding documents; confirm the current definition if you are close to the threshold. What is APCA and how is it different from WCAG contrast? APCA is a newer model of perceived contrast that takes polarity (light-on-dark versus dark-on-light), and the size and weight of the type, into account more explicitly than WCAG 2’s ratio. It is used in work towards WCAG 3 and as a design check today. It is not the algorithm you use to claim WCAG 2 conformance. Is 4.5:1 enough for small text? It is the AA minimum for normal text, not always a comfortable one. Small, light-weight type, a poor screen, or a coloured background can still feel weak at 4.5:1. I prefer to give body text more margin, or to increase size and weight, rather than to sit on the line. How do I check contrast in dark mode? Treat it as a separate palette. Measure each text, border and control against the dark surfaces it sits on, in WCAG 2 and, if you use it, APCA. Do not assume a pair that passed on white will pass when inverted. Walk real pages, including images, charts and overlays, not only a token sheet. How do I pick a chart palette that is colour-blind safe? Start with a palette that has been designed to remain distinct under common colour-vision deficiencies, then add a second cue such as a pattern, a line style or a direct label. Simulate deuteranopia, protanopia and tritanopia on the actual chart, in both themes, at the size it will be seen. The CVD-Safe Chart Palette tool on this site is a starting point, not a complete test. Written by Lee Johnson. Published 10 November 2026. All articles · Swift A–Z · Apps ### Questions, or an app in mind? Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen. Start a project → --- ## SwiftUI Accessibility: VoiceOver, Dynamic Type | Lee Johnson URL: https://leej.uk/articles/accessible-ios-apps-in-swiftui Make SwiftUI apps work for everyone: VoiceOver labels and actions, Dynamic Type layouts, contrast, Reduce Motion, automated audits and App Store labels. On this page 8 sections - Why it’s worth doing properly - VoiceOver: labels, values, traits and grouping - Dynamic Type: let the layout breathe - Colour, contrast and motion - Testing: by hand and automatically - A checklist for every new screen - Voice Control, focus and announcements - Key takeaways ### Why it’s worth doing properly People use the accessibility features on their iPhones far more widely than “accessibility” suggests. Larger text, bold text, Reduce Motion and VoiceOver are used by people with permanent disabilities, by people with temporary ones such as a broken wrist, and by people in situations that limit them, such as bright sunlight or holding a child in one arm. There are also concrete business reasons: - Legal duties. In the UK, public sector bodies have specific accessibility requirements for their websites and mobile apps under the Public Sector Bodies Accessibility Regulations 2018, and service providers more broadly have duties under the Equality Act 2010. If you sell in the EU, the European Accessibility Act has applied to many consumer services, including e-commerce and banking, since June 2025. Take legal advice for your own situation; the point here is that “we’ll do it later” is no longer a safe plan for many apps. - App Store visibility. Apple added Accessibility Nutrition Labels to App Store product pages, where developers state which features their app supports, such as VoiceOver, larger text and sufficient contrast. See Overview of Accessibility Nutrition Labels. You can only claim a feature honestly if common tasks in your app work with it. - Better apps for everyone. Clear labels, layouts that cope with large text and good contrast make an app easier for all users. Apple’s Human Interface Guidelines on accessibility are the design reference. ### VoiceOver: labels, values, traits and grouping VoiceOver reads each accessibility element aloud: its label (what it is), value (its current state), traits (button, header, selected) and hint (what happens if you activate it). SwiftUI fills these in for standard controls. Custom views need help. Images and icon buttons need a label. A button with only an SF Symbol gets a guessed label from the symbol name, which is often wrong: Swift Button { toggleFavourite() } label: { Image (systemName: isFavourite ? "heart.fill" : "heart" ) } .accessibilityLabel(isFavourite ? "Remove from favourites" : "Add to favourites" ) Purely decorative images should be hidden with Image(decorative:) or .accessibilityHidden(true), so VoiceOver users don’t have to swipe past them. Rows made of several pieces should usually read as one element: Swift struct OrderRow : View { let order: Order let onTrack: () -> Void var body: some View { HStack { Image (systemName: order.status.symbol) .accessibilityHidden( true ) VStack (alignment: .leading) { Text ( "Order \(order.number)" ) Text (order.status.label).foregroundStyle(.secondary) } Spacer () Text (order.total, format: .currency(code: "GBP" )) } .accessibilityElement(children: .combine) .accessibilityAction(named: "Track parcel" , onTrack) } } .combine merges the texts into one element, so VoiceOver reads “Order 1042, Dispatched, £24.00” in a single swipe instead of four. The custom action puts “Track parcel” in VoiceOver’s actions rotor, so the row doesn’t need a tiny extra button. Headings let VoiceOver users jump between sections with the rotor. Mark section titles that aren’t already headers: Swift Text ( "Upcoming" ) .font(.title2.bold()) .accessibilityAddTraits(.isHeader) Custom controls should present themselves as the standard control they behave like. accessibilityRepresentation lets a hand-drawn slider be read and adjusted exactly like a system Slider: Swift struct RatingDial : View { @Binding var rating: Double var body: some View { DialShape (value: rating) .gesture(dragGesture) .accessibilityRepresentation { Slider (value: $rating, in : 0 ... 5 , step: 1 ) { Text ( "Rating" ) } } } } ### Dynamic Type: let the layout breathe Dynamic Type lets people choose their text size, and the largest accessibility sizes are very large. Three habits handle nearly all of it: - Use text styles, such as .body, .headline and .caption, rather than fixed point sizes. For a custom font, .font(.custom("Brand", size: 17, relativeTo: .body)) still scales. - Scale non-text sizes with @ScaledMetric, so icons and spacing grow with the text: Swift struct StatTile : View { let title: String let value: String @ScaledMetric (relativeTo: .body) private var iconSize = 24.0 var body: some View { HStack { Image (systemName: "chart.bar.fill" ) .resizable() .scaledToFit() .frame(width: iconSize, height: iconSize) .accessibilityHidden( true ) VStack (alignment: .leading) { Text (title).font(.subheadline) Text (value).font(.title3.bold()) } } } } - Switch layouts at accessibility sizes. Side-by-side content that fits at the default size often gets squashed at the largest ones. Stack it instead: Swift struct ProductHeader : View { @Environment (\.dynamicTypeSize) private var typeSize let product: Product var body: some View { let layout = typeSize.isAccessibilitySize ? AnyLayout ( VStackLayout (alignment: .leading, spacing: 12 )) : AnyLayout ( HStackLayout (alignment: .top, spacing: 16 )) layout { ProductImage (product: product) ProductSummary (product: product) } } } ViewThatFits is an alternative when you’d rather SwiftUI pick the first layout that fits the space. Avoid .lineLimit(1) on anything important; let text wrap. ### Colour, contrast and motion - Contrast. Body text needs strong contrast against its background in both light and dark mode. The WCAG contrast ratios are a sensible benchmark, and the Accessibility Inspector in Xcode can check colours on screen. - Don’t rely on colour alone. A red border on a form field should come with an error message or an icon. The same goes for charts: use labels or patterns as well as colours. - Respect system settings. SwiftUI’s environment exposes the user’s choices: accessibilityReduceMotion, accessibilityReduceTransparency, colorSchemeContrast and accessibilityDifferentiateWithoutColor. Reduce Motion is the one most often ignored. Large zooms, parallax and spinning transitions can make some people feel unwell: Swift struct ExpandingCard : View { @Environment (\.accessibilityReduceMotion) private var reduceMotion @State private var isExpanded = false var body: some View { CardContent (isExpanded: isExpanded) .onTapGesture { isExpanded.toggle() } .animation(reduceMotion ? .easeInOut(duration: 0.15 ) : .spring(duration: 0.45 ), value: isExpanded) } } The reduced version still animates, but with a short fade-like change rather than movement and bounce. ### Testing: by hand and automatically Use the features yourself. Turn on VoiceOver (Settings › Accessibility › VoiceOver, or set the Accessibility Shortcut to triple-click the side button) and complete your app’s three most important tasks with the screen curtain on. Then set text to the largest accessibility size and do the same. Twenty minutes of this finds more than any checklist. Accessibility Inspector, in Xcode’s Developer Tools menu, shows each element’s label, value and traits, and its audit finds missing labels, small hit areas, clipped text and contrast problems. Automated audits in UI tests catch regressions. Since Xcode 15, XCUIApplication can run the same audit: Swift import XCTest final class AccessibilityAuditTests : XCTestCase { @MainActor func testHomeScreenPassesAudit() throws { let app = XCUIApplication () app.launch() try app.performAccessibilityAudit() } @MainActor func testCheckoutPassesAuditIgnoringKnownIssue() throws { let app = XCUIApplication () app.launch() app.buttons[ "Basket" ].tap() try app.performAccessibilityAudit( for : [.dynamicType, .contrast, .hitRegion, .sufficientElementDescription]) { issue in // Return true to ignore an issue you've reviewed and accepted. issue.element?.identifier == "promo-banner" } } } The automated audit won’t tell you whether a flow makes sense when read aloud, so it complements manual testing rather than replacing it. ### A checklist for every new screen - Every interactive element has a meaningful label; decorative images are hidden. - Related text is grouped so VoiceOver reads a row once, with custom actions for secondary buttons. - Section titles have the header trait. - The screen works at the largest accessibility text size without clipping or overlapping. - Tap targets are comfortably large; Apple’s guidelines recommend at least 44 by 44 points. - Information isn’t conveyed by colour alone, and contrast holds in light and dark mode. - Animations respect Reduce Motion. - The UI test for the screen calls performAccessibilityAudit(). The Apple Watch has its own constraints; Designing for the Apple Watch covers accessibility on the wrist. ### Voice Control, focus and announcements VoiceOver isn’t the only assistive technology that reads your labels. Voice Control lets people operate the phone by speaking the names of buttons, so a label also has to be something people would naturally say. When several phrases are reasonable, list them: Swift Button { compose() } label: { Image (systemName: "square.and.pencil" ) } .accessibilityLabel( "New message" ) .accessibilityInputLabels([ "New message" , "Compose" , "Write" ]) Focus matters when something appears that the user needs to know about, such as a validation error after tapping “Save”. Sighted users see the red text; VoiceOver users hear nothing unless you move focus to it: Swift struct SignUpForm : View { @State private var email = "" @State private var errorMessage: String ? @AccessibilityFocusState private var errorFocused: Bool var body: some View { Form { TextField ( "Email" , text: $email) .textContentType(.emailAddress) .keyboardType(.emailAddress) if let errorMessage { Text (errorMessage) .foregroundStyle(.red) .accessibilityFocused($errorFocused) } Button ( "Create account" , action: submit) } } private func submit() { guard email.contains( "@" ) else { errorMessage = "Enter an email address, like name@example.com." errorFocused = true return } errorMessage = nil } } For changes that don’t need focus, such as “Saved” after an autosave, post an announcement instead: AccessibilityNotification.Announcement("Saved").post(). Use announcements sparingly; a running commentary is as tiring to listen to as it would be to read. Switch Control and Full Keyboard Access move through the same accessibility elements in order. If VoiceOver’s order through a screen makes sense and every action is reachable as a button or a custom action, these usually work too. Test them once on each key screen. ### Key takeaways - SwiftUI’s standard controls are accessible by default; the work is in custom views, icons and grouped rows. - Use labels, traits, .combine and custom actions to make VoiceOver efficient, not just possible. - Use text styles and @ScaledMetric, and switch layouts at accessibility sizes. - Respect Reduce Motion and contrast settings, and never rely on colour alone. - Test by hand with VoiceOver and large text, and add performAccessibilityAudit() to UI tests. If you need an accessibility review of an existing app, or want to make sure a new one can honestly display Accessibility Nutrition Labels at launch, get in touch. Written by Lee Johnson. Published 30 October 2026. All articles · Swift A–Z · Apps ### Questions, or an app in mind? Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen. Start a project → --- ## Accessible Web Forms Checklist | Lee Johnson URL: https://leej.uk/articles/accessible-web-forms-checklist A checklist for accessible web forms: labels, autocomplete, errors, focus, keyboard use, and tests that catch problems a validator can miss. On this page 10 sections - A form is a conversation, not a visual layout - Every control has a visible label - Autocomplete, input types and names - Errors, help text and the focus move - Keyboard, focus order and the submit button - Groups, required fields and disabled states - Testing with a keyboard, a screen reader and a checker - A copy-paste checklist - Key takeaways - Frequently asked questions I use this checklist on the contact form on leej.uk and on the free tools that take input. The same habits transfer to a newsletter sign-up or a checkout. It is the web counterpart of the approach in Accessible iOS apps in SwiftUI: prefer the platform’s built-in controls, and test the path a person actually takes. ### A form is a conversation, not a visual layout A form fails when the visual layout is treated as the only interface. A column of boxes can look tidy and still be unusable if the labels live in placeholder text, the errors appear only as a red outline, or the submit button cannot be reached with the Tab key. Think about the questions in order: - What is this form for, in one sentence? - Which fields are actually required to do that job? - For each field, what should the browser or password manager already know? - If something is wrong, how does the person find out, and how do they get back to the field? The WCAG 2.2 criteria that come up most often are labels, instructions, error identification, error suggestion, name-role-value, and keyboard access. If you build for a public sector body in the UK, or for a service covered by the Equality Act, take advice for your own situation; a form that cannot be completed with a keyboard or a screen reader is already a poor form. Keep the form short. Every extra field is another chance to get the label, the error and the focus wrong. Optional questions belong after the required ones, and should be marked optional in the label. ### Every control has a visible label Placeholder text is not a label. It disappears when the person starts typing, it is often too faint to meet contrast requirements, and a screen reader may not treat it as the accessible name. The same is true of a label that only exists in an aria-label you cannot see. Visible text, permanently next to the control, is the default that works. The straightforward pattern is a