Repository: govuk-one-login/mobile-ios-common
Pull request #121: feat: dcmaw-11848 new error pattern error screen
Author: login: krissbennett
Created: 2025-03-21T14:36:22Z | Merged: 2025-03-24T17:00:11Z

# _New Error Pattern Error Screen_

This PR adds a new code only generated screen to match requirements for the new Error screen pattern.
https://govukverify.atlassian.net/browse/DCMAW-11848

### Screen Demos

https://github.com/user-attachments/assets/deba362e-e5b0-42b0-942e-4d89f5187c84

### VoiceOver Example


https://github.com/user-attachments/assets/af9e909f-7368-4b39-b2a6-170b469bf6ff


# Checklist

## Before raising your pull request:
- [x] Ran the app locally ensuring it builds 
- [x] Ran the tests locally ensuring they pass on Build
- [x] Pull request has a clear title with ticket ID and a short description about the feature or update
      i.e. _DCMAW-222: Added ReadID SDK to iOS app_
- [ ] Created a `draft` pull request if it is not yet ready for review

## Before your pull request can be reviewed:
- [x] Met all of the acceptance criteria specified in the user story on Jira
- [x] Reviewed your own code to ensure you are following the style guidelines
- [x] Ran the app and tested the feature on a range of device sizes
      Please include iPod Touch, iPhone SE and iPhone 11 as a minimum.
- [x] Written Unit and Integration tests if needed

- [x] Met all accessibility requirements?
    - [x] Checked dynamic type sizes are applied
    - [x] Checked VoiceOver can navigate your new code
    - [x] Checked a user can navigate only using a keyboard around your new code 

## Before merging your pull request:
- [x] Ensure that the code coverage and SonarCloud checks have passed
- [x] Actioned and resolved all comments, reaching out to reviewers for clarifications if necessary.
- [x] Ran the app to ensure that no regressions have been caused by changes during code review.
- [x] Targeted the correct branch; `develop`, `release` or `main`



Diff:
diff --git a/GDSCommon-Demo/GDSCommon-Demo.xcodeproj/project.pbxproj b/GDSCommon-Demo/GDSCommon-Demo.xcodeproj/project.pbxproj
index 57eb7599..7122e2de 100644
--- a/GDSCommon-Demo/GDSCommon-Demo.xcodeproj/project.pbxproj
+++ b/GDSCommon-Demo/GDSCommon-Demo.xcodeproj/project.pbxproj
@@ -7,6 +7,8 @@
 	objects = {
 
 /* Begin PBXBuildFile section */
+		03A7F4EC2D8D794700CFA364 /* ErrorScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A7F4EB2D8D794100CFA364 /* ErrorScreenTests.swift */; };
+		03A7F4F02D8D8D8C00CFA364 /* MockGDSErrorViewModelV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A7F4EE2D8D892B00CFA364 /* MockGDSErrorViewModelV3.swift */; };
 		1E3851602D0B179F00006E06 /* MockGDSCentreAlignedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E38515F2D0B179400006E06 /* MockGDSCentreAlignedViewModel.swift */; };
 		1E891B722B628AA000744E3D /* MockGDSInformationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E891B712B628AA000744E3D /* MockGDSInformationViewModel.swift */; };
 		1E891B772B63CCB000744E3D /* GDSCentreAlignedScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E891B762B63CCB000744E3D /* GDSCentreAlignedScreenTests.swift */; };
@@ -77,6 +79,8 @@
 /* End PBXContainerItemProxy section */
 
 /* Begin PBXFileReference section */
+		03A7F4EB2D8D794100CFA364 /* ErrorScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorScreenTests.swift; sourceTree = "<group>"; };
+		03A7F4EE2D8D892B00CFA364 /* MockGDSErrorViewModelV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGDSErrorViewModelV3.swift; sourceTree = "<group>"; };
 		1E38515F2D0B179400006E06 /* MockGDSCentreAlignedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGDSCentreAlignedViewModel.swift; sourceTree = "<group>"; };
 		1E891B712B628AA000744E3D /* MockGDSInformationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGDSInformationViewModel.swift; sourceTree = "<group>"; };
 		1E891B762B63CCB000744E3D /* GDSCentreAlignedScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GDSCentreAlignedScreenTests.swift; sourceTree = "<group>"; };
@@ -160,6 +164,7 @@
 		2B0844012ABC79BB00188BA0 /* GDSCommon-DemoTests */ = {
 			isa = PBXGroup;
 			children = (
+				03A7F4EB2D8D794100CFA364 /* ErrorScreenTests.swift */,
 				7C51376E2AD99E9D00163E83 /* Mocks */,
 				968598AF2B03A7C300462D0B /* MockBase.xib */,
 				2B08440D2ABC7A4300188BA0 /* TestExtensions */,
@@ -262,6 +267,7 @@
 				968598AC2B03878900462D0B /* MockBaseViewModel.swift */,
 				7C51376F2AD99EB600163E83 /* Scanning */,
 				2BD04BF42B729A1C00AC5279 /* MockGDSCentreAlignedViewModel.swift */,
+				03A7F4EE2D8D892B00CFA364 /* MockGDSErrorViewModelV3.swift */,
 			);
 			path = Mocks;
 			sourceTree = "<group>";
@@ -406,6 +412,7 @@
 			files = (
 				96588BEC2AD6AD8A0035FF93 /* TextInputViewControllerTests.swift in Sources */,
 				2B0844092ABC79C300188BA0 /* ModalInfoViewControllerTests.swift in Sources */,
+				03A7F4EC2D8D794700CFA364 /* ErrorScreenTests.swift in Sources */,
 				9634B46A2ACDAE8E00A60CC8 /* GDSListOptionsViewControllerTests.swift in Sources */,
 				96588BDC2AD02AB30035FF93 /* Date+Extensions.swift in Sources */,
 				D0726FA72BF77BE20043AEF1 /* GDSLoadingViewControllerTests.swift in Sources */,
@@ -422,6 +429,7 @@
 				C8A3955F2B20BA6700333669 /* GDSErrorViewControllerTests.swift in Sources */,
 				2B08440B2ABC79C300188BA0 /* MockGDSInstructionsViewModel.swift in Sources */,
 				7CBC376B2ADB01C200572312 /* MockCaptureSession.swift in Sources */,
+				03A7F4F02D8D8D8C00CFA364 /* MockGDSErrorViewModelV3.swift in Sources */,
 				2B0844112ABC7A7700188BA0 /* XCTestCase.swift in Sources */,
 				96588BE02AD046420035FF93 /* DatePickerScreenViewControllerTests.swift in Sources */,
 				2BB5C7892AD3F7DE006BE7E2 /* ScanningViewControllerTests.swift in Sources */,
@@ -673,7 +681,7 @@
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
 				INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
 				INFOPLIST_KEY_UIMainStoryboardFile = Demo;
-				INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationPortrait";
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
 				INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
 				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
@@ -708,7 +716,7 @@
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
 				INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
 				INFOPLIST_KEY_UIMainStoryboardFile = Demo;
-				INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationPortrait";
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
 				INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
 				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
diff --git a/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockBulletViewModel.swift b/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockBulletViewModel.swift
index 6a894034..b4df9705 100644
--- a/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockBulletViewModel.swift
+++ b/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockBulletViewModel.swift
@@ -6,9 +6,18 @@ struct MockBulletViewModel: BulletViewModel {
     var titleFont: UIFont?
     var text: [String]
     
-    init(title: String? = "This is the bullet view",
-         titleFont: UIFont? = .init(style: .title2, weight: .bold),
-         text: [String] = ["Here we can list things we want the user to know", "we can use this as a way to step them through an action", "or give details of a process"]) {
+    init(
+        title: String? = "This is the bullet view",
+        titleFont: UIFont? = UIFont(
+            style: .title2,
+            weight: .bold
+        ),
+        text: [String] = [
+            "Here we can list things we want the user to know",
+            "we can use this as a way to step them through an action",
+            "or give details of a process"
+        ]
+    ) {
         self.title = title
         self.titleFont = titleFont
         self.text = text
diff --git a/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockButtonViewModel.swift b/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockButtonViewModel.swift
index a70c4176..a1bff7fc 100644
--- a/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockButtonViewModel.swift
+++ b/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockButtonViewModel.swift
@@ -7,6 +7,21 @@ struct MockButtonViewModel: ButtonViewModel {
     let icon: ButtonIconViewModel?
     let shouldLoadOnTap: Bool
     let action: () -> Void
+    let overrideContentAlignment: UIControl.ContentHorizontalAlignment
+    
+    public init(
+        title: GDSLocalisedString,
+        icon: ButtonIconViewModel?,
+        shouldLoadOnTap: Bool,
+        action: @escaping () -> Void,
+        overrideContentAlignment: UIControl.ContentHorizontalAlignment = .center
+    ) {
+        self.title = title
+        self.icon = icon
+        self.shouldLoadOnTap = shouldLoadOnTap
+        self.action = action
+        self.overrideContentAlignment = overrideContentAlignment
+    }
 }
 
 struct MockButtonIconViewModel: ButtonIconViewModel {
@@ -20,6 +35,7 @@ struct MockColoredButtonViewModel: ColoredButtonViewModel {
     let shouldLoadOnTap: Bool
     let action: () -> Void
     let backgroundColor: UIColor
+    let overrideContentAlignment: UIControl.ContentHorizontalAlignment = .center
 }
 
 struct MockButtonViewModelWithVoiceOverHint: ButtonViewModel {
@@ -28,6 +44,7 @@ struct MockButtonViewModelWithVoiceOverHint: ButtonViewModel {
     let shouldLoadOnTap: Bool
     let action: () -> Void
     let accessibilityHint: GDSLocalisedString?
+    let overrideContentAlignment: UIControl.ContentHorizontalAlignment = .center
 }
 
 extension MockButtonViewModel {
@@ -46,6 +63,18 @@ extension MockButtonViewModel {
                             action: {})
     }
     
+    static var tertiary: MockButtonViewModel {
+        MockButtonViewModel(
+            title: "Tertiary Button",
+            icon: MockButtonIconViewModel(
+                iconName: "arrow.up.right",
+                symbolPosition: .afterTitle
+            ),
+            shouldLoadOnTap: false,
+            action: {}
+        )
+    }
+    
     static var secondaryQR: MockButtonViewModel {
         MockButtonViewModel(title: "Secondary Button",
                             icon: MockButtonIconViewModel(iconName: "qrcode",
@@ -69,6 +98,32 @@ extension MockButtonViewModel {
                                              action: { },
                                              accessibilityHint: "This includes a voiceover hint")
     }
+    
+    static var textCentered: MockButtonViewModel {
+        MockButtonViewModel(
+            title: "Text Button",
+            icon: MockButtonIconViewModel(
+                iconName: "arrow.up.right",
+                symbolPosition: .afterTitle
+            ),
+            shouldLoadOnTap: false,
+            action: {}
+        )
+    }
+    
+    static var textLeading: MockButtonViewModel {
+        MockButtonViewModel(
+            title: "Text Button",
+            icon: MockButtonIconViewModel(
+                iconName: "arrow.up.right",
+                symbolPosition: .afterTitle
+            ),
+            shouldLoadOnTap: false,
+            action: {},
+            overrideContentAlignment: UIControl.ContentHorizontalAlignment.leading
+        )
+    }
+    
 }
 
 extension MockColoredButtonViewModel {
diff --git a/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockErrorViewModel.swift b/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockErrorViewModel.swift
index e5b68d7e..e9f1f0a3 100644
--- a/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockErrorViewModel.swift
+++ b/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockErrorViewModel.swift
@@ -1,6 +1,36 @@
 import GDSCommon
 import UIKit
 
+var singleLineRegular: ScreenBodyItem {
+    BodyTextViewModel(
+        text: "Body single line (regular)"
+    )
+}
+
+var singleLineBold: ScreenBodyItem {
+    BodyTextViewModel(
+        text: "Body single line (body)",
+        fontWeight: .bold
+    )
+}
+
+var singleParagraph: ScreenBodyItem {
+    BodyTextViewModel(
+        text: "Body single paragraph - Lorem ipsum dolor sit amet consectetur. Purus aliquam mattis vitae enim mauris vestibulum massa tellus."
+    )
+}
+ 
+var multipleParagraph: ScreenBodyItem {
+    BodyTextViewModel(
+        text:
+        """
+            Body multiple paragraphs - Lorem ipsum dolor sit amet consectetur.
+            
+            Purus aliquam mattis vitae enim mauris vestibulum massa tellus.
+        """
+    )
+}
+
 struct MockErrorViewModel: GDSErrorViewModelV2, GDSErrorViewModelWithImage, BaseViewModel {
     let image: String = "exclamationmark.circle"
     let title: GDSLocalisedString = "This is an Error View title"
@@ -11,7 +41,130 @@ struct MockErrorViewModel: GDSErrorViewModelV2, GDSErrorViewModelWithImage, Base
     let backButtonIsHidden: Bool = false
     
     func didAppear() {}
+    func didDismiss() {}
+}
+
+struct MockErrorViewModelV3WithNoButtons: GDSErrorViewModelV3, BaseViewModel {
+    var image: ErrorScreenImage = .error
+    let title: GDSLocalisedString = "This is an Error View title"
+    let rightBarButtonTitle: GDSLocalisedString? = nil
+    let backButtonIsHidden: Bool = false
+    
+    var bodyContent: [ScreenBodyItem] = [
+        singleLineRegular,
+        singleParagraph,
+        MockBulletViewModel(),
+        MockButtonViewModel.textLeading
+    ]
+    var buttonViewModels: [any ButtonViewModel] = []
+    
+    func didAppear() {}
+    func didDismiss() {}
+}
+
+struct MockErrorViewModelV3WithTwoButtons: GDSErrorViewModelV3, BaseViewModel {
+    var image: ErrorScreenImage = .error
+    let title: GDSLocalisedString = "This is an Error View title"
+    let rightBarButtonTitle: GDSLocalisedString? = nil
+    let backButtonIsHidden: Bool = false
+    
+    var bodyContent: [ScreenBodyItem] = [
+        singleLineRegular,
+        multipleParagraph,
+        MockButtonViewModel.textCentered
+    ]
+    
+    var buttonViewModels: [any ButtonViewModel] = [
+        MockButtonViewModel.primary,
+        MockButtonViewModel.secondary
+    ]
     
+    func didAppear() {}
+    func didDismiss() {}
+}
+
+struct MockErrorViewModelV3WithThreeButtons: GDSErrorViewModelV3, BaseViewModel {
+    var image: ErrorScreenImage = .error
+    let title: GDSLocalisedString = "This is an Error View title"
+    
+    let rightBarButtonTitle: GDSLocalisedString? = nil
+    let backButtonIsHidden: Bool = false
+    
+    var bodyContent: [ScreenBodyItem] = [
+        singleLineRegular,
+        singleParagraph,
+        MockButtonViewModel.textCentered
+    ]
+    
+    var buttonViewModels: [any ButtonViewModel] = [
+        MockButtonViewModel.primary,
+        MockButtonViewModel.secondary,
+        MockButtonViewModel.tertiary
+    ]
+    
+    func didAppear() {}
+    func didDismiss() {}
+}
+
+struct MockErrorViewModelV3Modal: GDSErrorViewModelV3, BaseViewModel {
+    var image: ErrorScreenImage = .error
+    let title: GDSLocalisedString = "This is an modal Error View title"
+    let rightBarButtonTitle: GDSLocalisedString? = "Cancel"
+    let backButtonIsHidden: Bool = false
+    
+    var bodyContent: [ScreenBodyItem] = [
+        singleLineRegular,
+        singleParagraph,
+        MockBulletViewModel(),
+        MockButtonViewModel.textLeading
+    ]
+    var buttonViewModels: [any ButtonViewModel] = [
+        MockButtonViewModel.primary,
+        MockButtonViewModel.secondary
+    ]
+    
+    func didAppear() {}
+    func didDismiss() {}
+}
+
+struct MockErrorViewModelV3Warning: GDSErrorViewModelV3, BaseViewModel {
+    var image: ErrorScreenImage = .warning
+    let title: GDSLocalisedString = "This is an Warning Error View title"
+    let rightBarButtonTitle: GDSLocalisedString? = nil
+    let backButtonIsHidden: Bool = false
+    
+    var bodyContent: [ScreenBodyItem] = [
+        singleLineBold,
+        singleParagraph,
+        MockButtonViewModel.textCentered
+    ]
+    
+    var buttonViewModels: [any ButtonViewModel] = [
+        MockButtonViewModel.primary,
+        MockButtonViewModel.secondary
+    ]
+    
+    func didAppear() {}
+    func didDismiss() {}
+}
+
+struct MockErrorViewModelV3AppUpdate: GDSErrorViewModelV3, BaseViewModel {
+    var image: ErrorScreenImage = .appUpdate
+    let title: GDSLocalisedString = "This is an App Update Error View title"
+    let rightBarButtonTitle: GDSLocalisedString? = nil
+    let backButtonIsHidden: Bool = false
+    
+    var bodyContent: [ScreenBodyItem] = [
+        singleLineRegular,
+        singleParagraph,
+        MockButtonViewModel.textCentered
+    ]
+    
+    var buttonViewModels: [any ButtonViewModel] = [
+        MockButtonViewModel.primary
+    ]
+    
+    func didAppear() {}
     func didDismiss() {}
 }
 
@@ -24,7 +177,6 @@ struct MockErrorViewModelNoIcon: GDSErrorViewModelV2, BaseViewModel {
     let backButtonIsHidden: Bool = false
     
     func didAppear() {}
-    
     func didDismiss() {}
 }
 
@@ -39,6 +191,5 @@ struct MockErrorViewModelWithTertiary: GDSErrorViewModelV2, GDSErrorViewModelWit
     let backButtonIsHidden: Bool = false
     
     func didAppear() {}
-    
     func didDismiss() {}
 }
diff --git a/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockOptionViewModel.swift b/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockOptionViewModel.swift
index c80e64c5..19050245 100644
--- a/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockOptionViewModel.swift
+++ b/GDSCommon-Demo/GDSCommon-Demo/Mocks/MockOptionViewModel.swift
@@ -1,4 +1,5 @@
 import GDSCommon
+import UIKit
 
 struct MockOptionViewModel1: OptionViewModel {
     let title: GDSLocalisedString = "Example title text 1"
@@ -21,4 +22,5 @@ struct MockOptionButtonViewModel: ButtonViewModel {
     let icon: ButtonIconViewModel? = nil
     let shouldLoadOnTap: Bool = true
     let action: () -> Void = { }
+    let overrideContentAlignment: UIControl.ContentHorizontalAlignment = .center
 }
diff --git a/GDSCommon-Demo/GDSCommon-Demo/Mocks/Screens.swift b/GDSCommon-Demo/GDSCommon-Demo/Mocks/Screens.swift
index 1c876f78..e129fda6 100644
--- a/GDSCommon-Demo/GDSCommon-Demo/Mocks/Screens.swift
+++ b/GDSCommon-Demo/GDSCommon-Demo/Mocks/Screens.swift
@@ -13,6 +13,12 @@ import UIKit
 ///             the view as a `UIViewController` to push/present on the navigation stack.
 @MainActor
 enum Screens: String, CaseIterable {
+    case gdsErrorScreenNoButtons = "Design System Error Screen (with no buttons)"
+    case gdsErrorScreen = "Design System Error Screen (with 2 buttons)"
+    case gdsErrorScreen3Buttons = "Design System Error Screen (with 3 buttons)"
+    case gdsErrorScreenModal = "Design System Error Screen (modal)"
+    case gdsErrorScreenWarning = "Design System Error Screen (warning)"
+    case gdsErrorScreenAppUpdate = "Design System Error Screen (app update)"
     case gdsInstructions = "GDS Instructions View"
     case gdsInstructionsWithColouredButton = "GDS Instructions View (with coloured button)"
     case gdsInstructionsWithImage = "GDS Instructions View (with image)"
@@ -44,7 +50,8 @@ enum Screens: String, CaseIterable {
                 .gdsQRCodeScannerModal,
                 .gdsResultsViewModal,
                 .gdsAttributedModalInfoView,
-                .gdsInstructionsWithImageModally:
+                .gdsInstructionsWithImageModally,
+                .gdsErrorScreenModal:
             return true
         default:
             return false
@@ -52,8 +59,10 @@ enum Screens: String, CaseIterable {
     }
     
     private var dialogPresenter: DialogPresenter {
-        DialogView<CheckmarkDialogAccessoryView>(title: "QR Scan Success",
-                                                 isLoading: false)
+        DialogView<CheckmarkDialogAccessoryView>(
+            title: "QR Scan Success",
+            isLoading: false
+        )
     }
     
     // swiftlint:disable function_body_length
@@ -62,37 +71,55 @@ enum Screens: String, CaseIterable {
         case .gdsInstructions:
             return GDSInstructionsViewController(popToRoot: popToRoot, navController: navigationController)
         case .gdsInstructionsWithColouredButton:
-            let viewModel = MockGDSInstructionsViewModel(buttonViewModel: MockColoredButtonViewModel.primary,
-                                                         secondaryButtonViewModel: MockButtonViewModel.secondaryQR,
-                                                         dismissAction: { })
+            let viewModel = MockGDSInstructionsViewModel(
+                buttonViewModel: MockColoredButtonViewModel.primary,
+                secondaryButtonViewModel: MockButtonViewModel.secondaryQR,
+                dismissAction: { }
+            )
             return GDSInstructionsViewController(viewModel: viewModel)
         case .gdsInstructionsWithImage:
-            let viewModel = MockInstructionsWithImageViewModel(warningButtonViewModel: MockButtonViewModel.primary,
-                                                               primaryButtonViewModel: MockButtonViewModel.primary,
-                                                               screenView: { }, dismissAction: { })
+            let viewModel = MockInstructionsWithImageViewModel(
+                warningButtonViewModel: MockButtonViewModel.primary,
+                primaryButtonViewModel: MockButtonViewModel.primary,
+                screenView: { },
+                dismissAction: { }
+            )
             return InstructionsWithImageViewController(viewModel: viewModel)
         case .gdsInstructionsWithImageModally:
-            let viewModel = MockInstructionsWithImageViewModel(warningButtonViewModel: MockButtonViewModel.primary,
-                                                               primaryButtonViewModel: MockButtonViewModel.primary,
-                                                               secondaryButtonViewModel: MockButtonViewModel.secondaryQR,
-                                                               rightBarButtonTitle: "Close",
-                                                               screenView: { }, dismissAction: { })
+            let viewModel = MockInstructionsWithImageViewModel(
+                warningButtonViewModel: MockButtonViewModel.primary,
+                primaryButtonViewModel: MockButtonViewModel.primary,
+                secondaryButtonViewModel: MockButtonViewModel.secondaryQR,
+                rightBarButtonTitle: "Close",
+                screenView: { },
+                dismissAction: { }
+            )
             return InstructionsWithImageViewController(viewModel: viewModel)
         case .gdsModalInfoView:
             let view = ModalInfoViewController(viewModel: MockModalInfoViewModel())
             return view
         case .gdsModalButtonsInfoView:
-            let view = ModalInfoViewController(viewModel: MockModalInfoButtonsViewModel(primaryButtonViewModel: MockButtonViewModel.primary,
-                                                                                        secondaryButtonViewModel: MockButtonViewModel.secondary,
-                                                                                        textButtonViewModel: MockButtonViewModel.secondary))
+            let view = ModalInfoViewController(
+                viewModel: MockModalInfoButtonsViewModel(
+                    primaryButtonViewModel: MockButtonViewModel.primary,
+                    secondaryButtonViewModel: MockButtonViewModel.secondary,
+                    textButtonViewModel: MockButtonViewModel.secondary
+                )
+            )
             return view
         case .gdsAttributedModalInfoView:
             let view = ModalInfoViewController(viewModel: MockAttributedModalInfoViewModel())
             return view
         case .gdsListOptions:
-            return GDSListOptionsViewController(popToRoot: popToRoot, navController: navigationController)
+            return GDSListOptionsViewController(
+                popToRoot: popToRoot,
+                navController: navigationController
+            )
         case .gdsIntroView:
-            let viewModel = MockIntroViewModel(introButtonViewModel: MockButtonViewModel.primary, rightBarButtonTitle: nil)
+            let viewModel = MockIntroViewModel(
+                introButtonViewModel: MockButtonViewModel.primary,
+                rightBarButtonTitle: nil
+            )
             return IntroViewController(viewModel: viewModel)
         case .gdsDatePicker:
             return DatePickerScreenViewController()
@@ -101,10 +128,15 @@ enum Screens: String, CaseIterable {
         case .gdsIconScreen:
             return IconScreenViewController()
         case .gdsQRCodeScanner:
-            let viewModel = MockQRScanningViewModel(dialogPresenter: dialogPresenter) { navigationController.popViewController(animated: true) } dismissAction: { }
+            let viewModel = MockQRScanningViewModel(dialogPresenter: dialogPresenter) { navigationController.popViewController(animated: true)
+            }
+            dismissAction: { }
             return ScanningViewController(viewModel: viewModel)
         case .gdsQRCodeScannerModal:
-            let viewModel = MockQRScanningViewModel(dialogPresenter: dialogPresenter) { navigationController.dismiss(animated: true) } dismissAction: { }
+            let viewModel = MockQRScanningViewModel(
+                dialogPresenter: dialogPresenter
+            ) { navigationController.dismiss(animated: true) }
+            dismissAction: { }
             return ScanningViewController(viewModel: viewModel)
         case .gdsResultsView:
             return ResultsViewController(popToRoot: popToRoot, navController: navigationController)
@@ -124,6 +156,19 @@ enum Screens: String, CaseIterable {
             return GDSCentreAlignedScreen(viewModel: MockGDSCentreAlignedViewModel())
         case .gdsLoadingView:
             return GDSLoadingViewController()
+        
+        case .gdsErrorScreen:
+            return GDSErrorScreen(viewModel: MockErrorViewModelV3WithTwoButtons())
+        case .gdsErrorScreenModal:
+            return GDSErrorScreen(viewModel: MockErrorViewModelV3Modal())
+        case .gdsErrorScreenNoButtons:
+            return GDSErrorScreen(viewModel: MockErrorViewModelV3WithNoButtons())
+        case .gdsErrorScreen3Buttons:
+            return GDSErrorScreen(viewModel: MockErrorViewModelV3WithThreeButtons())
+        case .gdsErrorScreenWarning:
+            return GDSErrorScreen(viewModel: MockErrorViewModelV3Warning())
+        case .gdsErrorScreenAppUpdate:
+            return GDSErrorScreen(viewModel: MockErrorViewModelV3AppUpdate())
         }
     }
     // swiftlint:enable function_body_length
diff --git a/GDSCommon-Demo/GDSCommon-DemoTests/ErrorScreenTests.swift b/GDSCommon-Demo/GDSCommon-DemoTests/ErrorScreenTests.swift
new file mode 100644
index 00000000..5d217176
--- /dev/null
+++ b/GDSCommon-Demo/GDSCommon-DemoTests/ErrorScreenTests.swift
@@ -0,0 +1,313 @@
+@testable import GDSCommon
+@testable import GDSCommon_Demo
+import XCTest
+
+final class GDSErrorScreenTests: XCTestCase {
+    var viewModel: GDSErrorViewModelV3!
+    var sut: GDSErrorScreen!
+    
+    var buttonViewModels: [MockButtonViewModel] = []
+    var secondaryButtonViewModel: MockButtonViewModel!
+    var didTap_primaryButton = false
+    var didTap_secondaryButton = false
+    var didTap_tertiaryButton = false
+    var viewDidAppear = false
+    var viewDidDismiss = false
+    
+    @MainActor
+    override func setUp() {
+        super.setUp()
+        sut = createSUT()
+    }
+    
+    // swiftlint: disable function_body_length
+    @MainActor
+    private func createSUT(
+        image: ErrorScreenImage = .error,
+        buttonsToAdd: Int = 3
+    ) -> GDSErrorScreen {
+        let primaryButtonViewModel = MockButtonViewModel(
+            title: "Primary Action",
+            icon: nil,
+            shouldLoadOnTap: false,
+            voiceOverHint: "Button hint",
+            action: {
+                self.didTap_primaryButton = true
+            }
+        )
+        let secondaryButtonViewModel = MockButtonViewModel(
+            title: "Secondary Action",
+            icon: MockButtonIconViewModel(),
+            shouldLoadOnTap: false,
+            voiceOverHint: "Button hint",
+            action: {
+                self.didTap_secondaryButton = true
+            }
+        )
+        
+        let tertiaryButtonViewModel = MockButtonViewModel(
+            title: "Tertiary Action",
+            icon: MockButtonIconViewModel(),
+            shouldLoadOnTap: false,
+            voiceOverHint: "Button hint",
+            action: {
+                self.didTap_tertiaryButton = true
+            }
+        )
+        
+        switch buttonsToAdd {
+        case 1:
+            self.buttonViewModels = [
+                primaryButtonViewModel
+            ]
+        case 2:
+            self.buttonViewModels = [
+                primaryButtonViewModel,
+                secondaryButtonViewModel
+            ]
+        case 3:
+            self.buttonViewModels = [
+                primaryButtonViewModel,
+                secondaryButtonViewModel,
+                tertiaryButtonViewModel
+            ]
+            
+        default:
+            self.buttonViewModels = []
+        }
+        
+        let viewModel = MockErrorViewModelV3(
+            buttonViewModels: buttonViewModels,
+            image: image,
+            bodyContent: [
+                singleLineRegular,
+                singleParagraph,
+                MockButtonViewModel(
+                    title: "Button",
+                    action: {}
+                )
+            ],
+            appearAction: {
+                self.viewDidAppear = true
+            },
+            dismissAction: {
+                self.viewDidDismiss = true
+            }
+        )
+        
+        self.viewModel = viewModel
+        return GDSErrorScreen(viewModel: viewModel)
+        
+    }
+    // swiftlint: enable function_body_length
+    
+    override func tearDown() {
+        buttonViewModels = []
+        viewModel = nil
+        sut = nil
+        
+        didTap_primaryButton = false
+        didTap_secondaryButton = false
+        didTap_tertiaryButton = false
+        viewDidAppear = false
+        viewDidDismiss = false
+        
+        super.tearDown()
+    }
+}
+
+@MainActor
+extension GDSErrorScreenTests {
+    func test_viewContents_asExpected() throws {
+        // Icon Image
+        XCTAssertNotNil(try sut.image)
+        XCTAssertEqual(try sut.image.tintColor, .gdsPrimary)
+        
+        // Labels
+        XCTAssertEqual(try sut.titleLabel.text, "This is an Error View title")
+        XCTAssertEqual(try sut.titleLabel.font, .largeTitleBold)
+        XCTAssertTrue(try sut.icontTitleStack.accessibilityTraits.contains(.header))
+       
+        // Buttons
+        XCTAssertEqual(try sut.primaryButton.title(for: .normal), "Primary Action")
+        XCTAssertEqual(try sut.primaryButton.accessibilityHint, "Button hint")
+        XCTAssertEqual(try sut.secondaryButton.title(for: .normal), "Secondary Action")
+        XCTAssertEqual(try sut.secondaryButton.accessibilityHint, "Button hint")
+        XCTAssertEqual(try sut.tertiaryButton.title(for: .normal), "Tertiary Action")
+        XCTAssertEqual(try sut.tertiaryButton.accessibilityHint, "Button hint")
+    }
+    
+    
+    func test_primaryButtonNoIcon() throws {
+        XCTAssertNil(try sut.primaryButton.icon)
+    }
+
+    func test_secondaryButtonHasIcon() throws {
+        XCTAssertEqual(try sut.secondaryButton.icon, "arrow.up.right")
+    }
+    
+    func test_tertiaryButtonHasIcon() throws {
+        XCTAssertEqual(try sut.tertiaryButton.icon, "arrow.up.right")
+    }
+    
+    func test_primaryButtonAction() throws {
+        XCTAssertFalse(didTap_primaryButton)
+        try sut.primaryButton.sendActions(for: .touchUpInside)
+        XCTAssertTrue(didTap_primaryButton)
+    }
+    
+    func test_secondaryButtonAction() throws {
+        XCTAssertFalse(didTap_secondaryButton)
+        try sut.secondaryButton.sendActions(for: .touchUpInside)
+        XCTAssertTrue(didTap_secondaryButton)
+    }
+        
+    func test_tertiaryButtonAction() throws {
+        XCTAssertFalse(didTap_tertiaryButton)
+        try sut.tertiaryButton.sendActions(for: .touchUpInside)
+        XCTAssertTrue(didTap_tertiaryButton)
+    }
+    
+    func test_didAppear() throws {
+        XCTAssertFalse(viewDidAppear)
+        sut.beginAppearanceTransition(true, animated: false)
+        sut.endAppearanceTransition()
+        XCTAssertTrue(viewDidAppear)
+    }
+    
+    func test_voiceOverFocusElement() throws {
+        sut.beginAppearanceTransition(true, animated: false)
+        sut.endAppearanceTransition()
+        
+        let screen = try XCTUnwrap(sut as VoiceOverFocus)
+        let view = try XCTUnwrap(screen.initialVoiceOverView as? UILabel)
+        XCTAssertEqual(view.text, "This is an Error View title")
+    }
+    
+    func test_didDismiss() {
+        sut.beginAppearanceTransition(true, animated: false)
+        sut.endAppearanceTransition()
+        
+        XCTAssertFalse(viewDidDismiss)
+        _ = sut.navigationItem.rightBarButtonItem?.target?.perform(sut.navigationItem.rightBarButtonItem?.action)
+        XCTAssertTrue(viewDidDismiss)
+    }
+    
+    func test_imageIsNotHidden() throws {
+        XCTAssertFalse(try sut.image.isHidden)
+    }
+    
+    func test_errorScreenWithNoButtons() throws {
+        // GIVEN we setup the SUT with no buttons
+        sut = createSUT(buttonsToAdd: 0)
+        
+        // THEN all buttons should be nil
+        XCTAssertEqual(try sut.buttonStack.arrangedSubviews.count, 0)
+        XCTAssertNil(sut.view[child: "error-screen-button-0"]) // Primary
+        XCTAssertNil(sut.view[child: "error-screen-button-1"]) // Secondary
+        XCTAssertNil(sut.view[child: "error-screen-button-2"]) // Tertiary
+    }
+    
+    func test_errorScreenWithOneButton() throws {
+        // GIVEN we setup the SUT with 1 button
+        sut = createSUT(buttonsToAdd: 1)
+        
+        // THEN primary should not be nil, but secondary and tertiary should be nil
+        XCTAssertEqual(try sut.buttonStack.arrangedSubviews.count, 1)
+        XCTAssertNotNil(sut.view[child: "error-screen-button-0"]) // Primary
+        XCTAssertNil(sut.view[child: "error-screen-button-1"]) // Secondary
+        XCTAssertNil(sut.view[child: "error-screen-button-2"]) // Tertiary
+    }
+    
+    func test_errorScreenWithTwoButtons() throws {
+        // GIVEN we setup the SUT with 2 buttons
+        sut = createSUT(buttonsToAdd: 2)
+                
+        // THEN primary and secondary should not be nil, but tertiary should be nil
+        XCTAssertEqual(try sut.buttonStack.arrangedSubviews.count, 2)
+        XCTAssertNotNil(sut.view[child: "error-screen-button-0"]) // Primary
+        XCTAssertNotNil(sut.view[child: "error-screen-button-1"]) // Secondary
+        XCTAssertNil(sut.view[child: "error-screen-button-2"]) // Tertiary
+    }
+    
+    func test_errorScreenWithThreeButtons() throws {
+        // GIVEN we setup the SUT with 3 buttons
+        sut = createSUT(buttonsToAdd: 3)
+        
+        // THEN all buttons should be not be nil
+        XCTAssertEqual(try sut.buttonStack.arrangedSubviews.count, 3)
+        XCTAssertNotNil(sut.view[child: "error-screen-button-0"]) // Primary
+        XCTAssertNotNil(sut.view[child: "error-screen-button-1"]) // Secondary
+        XCTAssertNotNil(sut.view[child: "error-screen-button-2"]) // Tertiary
+    }
+    
+    func test_errorContentItems() throws {
+        sut = createSUT()
+        
+        XCTAssertEqual(try sut.bodyContentView.arrangedSubviews.count, 3)
+        let label = try XCTUnwrap(sut.bodyContentView.arrangedSubviews[0] as? UILabel)
+        XCTAssertEqual(label.text, "Body single line (regular)")
+        
+        let paragraphLabel = try XCTUnwrap(sut.bodyContentView.arrangedSubviews[1] as? UILabel)
+        XCTAssertEqual(
+            paragraphLabel.text,
+            """
+            Body single paragraph - Lorem ipsum dolor sit amet consectetur. Purus aliquam mattis vitae enim mauris vestibulum massa tellus.
+            """
+        )
+        
+        let button = try XCTUnwrap(sut.bodyContentView.arrangedSubviews[2] as? SecondaryButton)
+        XCTAssertEqual(button.titleLabel?.text, "Button")
+    }
+    
+}
+
+extension GDSErrorScreen {
+    var image: UIImageView {
+        get throws {
+            try XCTUnwrap(view[child: "error-screen-image"])
+        }
+    }
+    
+    var titleLabel: UILabel {
+        get throws {
+            try XCTUnwrap(view[child: "error-screen-title"])
+        }
+    }
+    
+    var icontTitleStack: UIStackView {
+        get throws {
+            try XCTUnwrap(view[child: "error-screen-icon-title-stack-view"])
+        }
+    }
+    
+    var primaryButton: RoundedButton {
+        get throws {
+            try XCTUnwrap(view[child: "error-screen-button-0"])
+        }
+    }
+    
+    var secondaryButton: SecondaryButton {
+        get throws {
+            try XCTUnwrap(view[child: "error-screen-button-1"])
+        }
+    }
+    
+    var tertiaryButton: SecondaryButton {
+        get throws {
+            try XCTUnwrap(view[child: "error-screen-button-2"])
+        }
+    }
+    
+    var bodyContentView: UIStackView {
+        get throws {
+            try XCTUnwrap(view[child: "error-screen-body-content-stack-view"])
+        }
+    }
+    
+    var buttonStack: UIStackView {
+        get throws {
+            try XCTUnwrap(view[child: "error-screen-button-stack-view"])
+        }
+    }
+}
diff --git a/GDSCommon-Demo/GDSCommon-DemoTests/GDSCentreAlignedScreenTests.swift b/GDSCommon-Demo/GDSCommon-DemoTests/GDSCentreAlignedScreenTests.swift
index b9048f7b..8bdb09a4 100644
--- a/GDSCommon-Demo/GDSCommon-DemoTests/GDSCentreAlignedScreenTests.swift
+++ b/GDSCommon-Demo/GDSCommon-DemoTests/GDSCentreAlignedScreenTests.swift
@@ -70,7 +70,6 @@ extension GDSCentreAlignedScreenTests {
     }
 
     func test_footnoteMovesToScrollView() throws {
-        sut.loadView()
         // When bottom stack height is half of the screen size
         try sut.bottomStack.frame.size.height = UIScreen.main.bounds.height / 2
         sut.viewDidLayoutSubviews()
@@ -80,7 +79,6 @@ extension GDSCentreAlignedScreenTests {
     }
     
     func test_footnoteMovesToBackToStackView() throws {
-        sut.loadView()
         // When bottom stack height is half of the screen size
         try sut.bottomStack.frame.size.height = UIScreen.main.bounds.height / 2
         sut.viewDidLayoutSubviews()
diff --git a/GDSCommon-Demo/GDSCommon-DemoTests/GDSInstructionsViewControllerTests.swift b/GDSCommon-Demo/GDSCommon-DemoTests/GDSInstructionsViewControllerTests.swift
index 655b7680..35a9845d 100644
--- a/GDSCommon-Demo/GDSCommon-DemoTests/GDSInstructionsViewControllerTests.swift
+++ b/GDSCommon-Demo/GDSCommon-DemoTests/GDSInstructionsViewControllerTests.swift
@@ -178,13 +178,22 @@ struct MockButtonViewModel: ButtonViewModel {
     let shouldLoadOnTap: Bool
     let action: () -> Void
     let accessibilityHint: GDSLocalisedString?
-    
-    init(title: GDSLocalisedString, icon: ButtonIconViewModel? = nil, shouldLoadOnTap: Bool = false, voiceOverHint: GDSLocalisedString? = nil, action: @escaping () -> Void) {
+    var contentAlignment: UIControl.ContentHorizontalAlignment?
+    
+    init(
+        title: GDSLocalisedString,
+        icon: ButtonIconViewModel? = nil,
+        shouldLoadOnTap: Bool = false,
+        voiceOverHint: GDSLocalisedString? = nil,
+        contentAlignment: UIControl.ContentHorizontalAlignment? = .center,
+        action: @escaping () -> Void
+    ) {
         self.title = title
         self.icon = icon
         self.shouldLoadOnTap = shouldLoadOnTap
         self.action = action
         self.accessibilityHint = voiceOverHint
+        self.contentAlignment = contentAlignment
     }
 }
 
@@ -192,14 +201,23 @@ struct MockColoredButtonViewModel: ColoredButtonViewModel {
     let title: GDSLocalisedString
     let icon: ButtonIconViewModel?
     let shouldLoadOnTap: Bool
-    let action: () -> Void
     let backgroundColor: UIColor
+    let contentAlignment: UIControl.ContentHorizontalAlignment?
+    let action: () -> Void
     
-    init(title: GDSLocalisedString, icon: ButtonIconViewModel? = nil, shouldLoadOnTap: Bool = false, action: @escaping () -> Void, backgroundColor: UIColor) {
+    init(
+        title: GDSLocalisedString,
+        icon: ButtonIconViewModel? = nil,
+        shouldLoadOnTap: Bool = false,
+        contentAlignment: UIControl.ContentHorizontalAlignment? = .center,
+        action: @escaping () -> Void,
+        backgroundColor: UIColor
+    ) {
         self.title = title
         self.icon = icon
         self.shouldLoadOnTap = shouldLoadOnTap
         self.action = action
         self.backgroundColor = backgroundColor
+        self.contentAlignment = contentAlignment
     }
 }
diff --git a/GDSCommon-Demo/GDSCommon-DemoTests/Mocks/MockGDSErrorViewModelV3.swift b/GDSCommon-Demo/GDSCommon-DemoTests/Mocks/MockGDSErrorViewModelV3.swift
new file mode 100644
index 00000000..b98e32cc
--- /dev/null
+++ b/GDSCommon-Demo/GDSCommon-DemoTests/Mocks/MockGDSErrorViewModelV3.swift
@@ -0,0 +1,38 @@
+@testable import GDSCommon
+import XCTest
+
+struct MockErrorViewModelV3: GDSErrorViewModelV3, BaseViewModel {
+    
+    var image: ErrorScreenImage = .error
+    let title: GDSLocalisedString = "This is an Error View title"
+    
+    let rightBarButtonTitle: GDSLocalisedString? = "Cancel"
+    let backButtonIsHidden: Bool = false
+    let appearAction: () -> Void
+    let dismissAction: () -> Void
+    
+    var bodyContent: [any GDSCommon.ScreenBodyItem]
+    var buttonViewModels: [any ButtonViewModel]
+    
+    public init(
+        buttonViewModels: [any ButtonViewModel],
+        image: ErrorScreenImage,
+        bodyContent: [ScreenBodyItem] = [],
+        appearAction: @escaping () -> Void,
+        dismissAction: @escaping () -> Void
+    ) {
+        self.buttonViewModels = buttonViewModels
+        self.image = image
+        self.bodyContent = bodyContent
+        self.appearAction = appearAction
+        self.dismissAction = dismissAction
+    }
+    
+    func didAppear() {
+        appearAction()
+    }
+    
+    func didDismiss() {
+        dismissAction()
+    }
+}
diff --git a/Sources/GDSCommon/Components/BodyTextViewModel.swift b/Sources/GDSCommon/Components/BodyTextViewModel.swift
new file mode 100644
index 00000000..05630c98
--- /dev/null
+++ b/Sources/GDSCommon/Components/BodyTextViewModel.swift
@@ -0,0 +1,17 @@
+import UIKit
+
+public struct BodyTextViewModel: ScreenBodyItem {
+    var text: String
+    var fontWeight: UIFont.Weight
+    var overridingAlignment: NSTextAlignment?
+    
+    public init(
+        text: String,
+        fontWeight: UIFont.Weight = .regular,
+        overridingAlignment: NSTextAlignment? = nil
+    ) {
+        self.text = text
+        self.fontWeight = fontWeight
+        self.overridingAlignment = overridingAlignment
+    }
+}
diff --git a/Sources/GDSCommon/Components/BulletView/BulletViewModel.swift b/Sources/GDSCommon/Components/BulletView/BulletViewModel.swift
index 1172e248..ac250e76 100644
--- a/Sources/GDSCommon/Components/BulletView/BulletViewModel.swift
+++ b/Sources/GDSCommon/Components/BulletView/BulletViewModel.swift
@@ -2,7 +2,7 @@ import Foundation
 import UIKit
 
 @MainActor
-public protocol BulletViewModel {
+public protocol BulletViewModel: ScreenBodyItem {
     var title: String? { get }
     var titleFont: UIFont? { get }
     var text: [String] { get }
diff --git a/Sources/GDSCommon/Components/Buttons/ButtonViewModel.swift b/Sources/GDSCommon/Components/Buttons/ButtonViewModel.swift
index 0c452483..c58bd26f 100644
--- a/Sources/GDSCommon/Components/Buttons/ButtonViewModel.swift
+++ b/Sources/GDSCommon/Components/Buttons/ButtonViewModel.swift
@@ -1,12 +1,17 @@
 import UIKit
 
 @MainActor
-public protocol ButtonViewModel {
+public protocol ButtonViewModel: ScreenBodyItem {
     var title: GDSLocalisedString { get }
     var icon: ButtonIconViewModel? { get }
     var shouldLoadOnTap: Bool { get }
     var action: () -> Void { get }
     var accessibilityHint: GDSLocalisedString? { get }
+    var overrideContentAlignment: UIControl.ContentHorizontalAlignment { get }
+}
+
+extension ButtonViewModel {
+    public var overrideContentAlignment: UIControl.ContentHorizontalAlignment { .center }
 }
 
 public protocol ColoredButtonViewModel: ButtonViewModel {
diff --git a/Sources/GDSCommon/Components/ScreenBodyItem.swift b/Sources/GDSCommon/Components/ScreenBodyItem.swift
new file mode 100644
index 00000000..22e103c3
--- /dev/null
+++ b/Sources/GDSCommon/Components/ScreenBodyItem.swift
@@ -0,0 +1 @@
+public protocol ScreenBodyItem { }
diff --git a/Sources/GDSCommon/Patterns/GDSError/ErrorScreenImage.swift b/Sources/GDSCommon/Patterns/GDSError/ErrorScreenImage.swift
new file mode 100644
index 00000000..1f3d9d19
--- /dev/null
+++ b/Sources/GDSCommon/Patterns/GDSError/ErrorScreenImage.swift
@@ -0,0 +1,23 @@
+public enum ErrorScreenImage {
+    case error
+    case warning
+    case appUpdate
+    
+    var icon: String {
+        switch self {
+        case .error, .warning:
+            "exclamationmark.circle"
+        case .appUpdate:
+            "exclamationmark.arrow.trianglehead.counterclockwise.rotate.90"
+        }
+    }
+    
+    var voiceoverPrefix: String {
+        switch self {
+        case .error, .appUpdate:
+            NSLocalizedString(key: "GDSCommonVoiceOverErrorPrefix", bundle: .module)
+        case .warning:
+            NSLocalizedString(key: "GDSCommonVoiceOverWarningPrefix", bundle: .module)
+        }
+    }
+}
diff --git a/Sources/GDSCommon/Patterns/GDSError/GDSErrorScreen.swift b/Sources/GDSCommon/Patterns/GDSError/GDSErrorScreen.swift
new file mode 100644
index 00000000..780f66e7
--- /dev/null
+++ b/Sources/GDSCommon/Patterns/GDSError/GDSErrorScreen.swift
@@ -0,0 +1,284 @@
+import UIKit
+
+public class GDSErrorScreen: BaseViewController, TitledViewControllerV2 {
+    
+    public private(set) var viewModel: GDSErrorViewModelV3
+        
+    let defaultSpacing = 16.0 // Use Design system when available
+    
+    private lazy var containerStackView: UIStackView = {
+        let result = UIStackView(
+            views: [
+                scrollView,
+                bottomStackView
+            ],
+            distribution: .fill
+        )
+        result.accessibilityIdentifier = "error-screen-container-stack-view"
+        return result
+    }()
+    
+    private lazy var scrollView: UIScrollView = {
+        let result = UIScrollView()
+        result.contentLayoutGuide.widthAnchor.constraint(equalTo: result.widthAnchor).isActive = true
+        result.addSubview(scrollViewOuterStackView)
+        result.accessibilityIdentifier = "error-screen-container-scrollview"
+        scrollViewOuterStackView.bindToSuperviewEdges()
+        return result
+    }()
+    
+    private lazy var scrollViewOuterStackView: UIStackView = {
+        let result = UIStackView(
+            views: [
+                topSpacer,
+                scrollViewInnerStackView,
+                bottomSpacer
+            ],
+            distribution: .equalSpacing
+        )
+        result.accessibilityIdentifier = "error-screen-outer-stack-view"
+        return result
+    }()
+    
+    private lazy var topSpacer: UIView = {
+        let result = UIView()
+        result.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+        return result
+    }()
+    
+    private lazy var scrollViewInnerStackView: UIStackView = {
+        let result = UIStackView(
+            views: [
+                scrollViewIconTitleStackView,
+                bodyContentStackView
+            ],
+            spacing: defaultSpacing,
+            distribution: .equalSpacing
+        )
+        result.accessibilityIdentifier = "error-screen-inner-stack-view"
+        return result
+    }()
+    
+    private lazy var scrollViewIconTitleStackView: UIStackView = {
+        let result = UIStackView(
+            views: [
+                imageView,
+                titleLabel
+            ],
+            spacing: defaultSpacing,
+            distribution: .equalSpacing
+        )
+        result.accessibilityIdentifier = "error-screen-icon-title-stack-view"
+        result.shouldGroupAccessibilityChildren = true
+        result.accessibilityTraits = [.header]
+        result.isAccessibilityElement = true
+        result.accessibilityLabel = "\(viewModel.image.voiceoverPrefix): \(viewModel.title) :"
+        return result
+    }()
+    
+    private lazy var bottomSpacer: UIView = {
+        let result = UIView()
+        result.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+        return result
+    }()
+    
+    private lazy var imageView: UIImageView = {
+        let result = UIImageView()
+        let font = UIFont(style: .largeTitle, weight: .regular)
+        let configuration = UIImage.SymbolConfiguration(font: font, scale: .large)
+        
+        result.image = UIImage(
+            systemName: viewModel.image.icon,
+            withConfiguration: configuration
+        )
+        result.tintColor = .gdsPrimary
+        result.contentMode = .scaleAspectFit
+        result.adjustsImageSizeForAccessibilityContentSizeCategory = true
+        NSLayoutConstraint.activate([
+            result.heightAnchor.constraint(greaterThanOrEqualToConstant: 107)
+        ])
+        result.accessibilityIdentifier = "error-screen-image"
+        return result
+    }()
+    
+    lazy var titleLabel: UILabel = {
+        let result = UILabel()
+        result.font = UIFont(
+            style: .largeTitle,
+            weight: .bold,
+            design: .default
+        )
+        result.text = viewModel.title.value
+        result.accessibilityIdentifier = "error-screen-title"
+        result.adjustsFontForContentSizeCategory = true
+        result.textAlignment = .center
+        result.numberOfLines = 0
+        return result
+    }()
+    
+    private lazy var bodyContentStackView: UIStackView = {
+        let result = UIStackView(
+            views: bodyContentViews,
+            spacing: defaultSpacing,
+            distribution: .equalSpacing
+        )
+        result.accessibilityIdentifier = "error-screen-body-content-stack-view"
+        return result
+    }()
+    
+    private lazy var bottomStackView: UIStackView = {
+        let hasButtons = !viewModel.buttonViewModels.isEmpty
+        let result = UIStackView(
+            views: [
+                buttonStackView
+            ],
+            spacing: defaultSpacing,
+            distribution: hasButtons ? .fillProportionally : .equalCentering
+        )
+        result.accessibilityIdentifier = "error-screen-bottom-stack-view"
+        return result
+    }()
+    
+    private lazy var buttonStackView: UIStackView = {
+        let result = UIStackView(
+            views: buttonViews,
+            spacing: defaultSpacing
+        )
+        result.axis = .vertical
+        result.spacing = defaultSpacing
+        result.accessibilityIdentifier = "error-screen-button-stack-view"
+        return result
+    }()
+    
+    private var buttonViews: [UIView] {
+        var views: [UIView] = []
+        for (index, buttonViewModel) in viewModel.buttonViewModels.enumerated() {
+            views.append(buttonForButtonViewModel(
+                buttonViewModel,
+                index
+            ))
+        }
+        return views
+    }
+    
+    public init(
+        viewModel: GDSErrorViewModelV3
+    ) {
+        self.viewModel = viewModel
+        super.init(
+            viewModel: viewModel as? BaseViewModel,
+            nibName: nil,
+            bundle: Bundle.module
+        )
+    }
+    
+    public override func viewDidLoad() {
+        super.viewDidLoad()
+        setup()
+    }
+    
+    private func buttonForButtonViewModel(
+        _ buttonViewModel: ButtonViewModel,
+        _ index: Int
+    ) -> UIButton {
+        let isPrimaryButton = index == 0
+        let result = isPrimaryButton ? RoundedButton() : SecondaryButton()
+        
+        result.setTitle(buttonViewModel.title.value, for: .normal)
+        result.accessibilityHint = buttonViewModel.accessibilityHint?.value
+        if let icon = buttonViewModel.icon {
+            result.symbolPosition = icon.symbolPosition
+            result.icon = icon.iconName
+        }
+        
+        result.accessibilityIdentifier = "error-screen-button-\(index)"
+        
+        if !isPrimaryButton {
+            result.titleLabel?.textColor = .accent
+        }
+        
+        result.addAction {
+            buttonViewModel.action()
+        }
+        return result
+    }
+    
+    private func setup() {
+        self.view.addSubview(containerStackView)
+        containerStackView.bindToSuperviewSafeArea(
+            insetBy: UIEdgeInsets(
+                top: 0,
+                left: defaultSpacing,
+                bottom: 0,
+                right: defaultSpacing
+            )
+        )
+        self.view.backgroundColor = .systemBackground
+        addRelativeViewConstraints()
+    }
+    
+    private func addRelativeViewConstraints() {
+        scrollViewOuterStackView.heightAnchor.constraint(
+            greaterThanOrEqualTo: scrollView.heightAnchor
+        ).isActive = true
+    }
+    
+    private lazy var bodyContentViews: [UIView] = {
+        var result: [UIView] = []
+        result.append(
+            contentsOf:
+                viewModel.bodyContent.compactMap { screenBodyItem in
+                    createViewForBodyContentItem(screenBodyItem)
+                }
+        )
+        return result
+    }()
+    
+    private func createViewForBodyContentItem(
+        _ contentItem: ScreenBodyItem
+    ) -> UIView? {
+        
+        if let buttonViewModel = contentItem as? ButtonViewModel {
+            let result = SecondaryButton()
+            result.contentHorizontalAlignment = buttonViewModel.overrideContentAlignment
+            result.setTitle(buttonViewModel.title, for: .normal)
+            result.titleLabel?.textColor = .accent
+            result.symbolPosition = buttonViewModel.icon?.symbolPosition ?? .afterTitle
+            result.icon = buttonViewModel.icon?.iconName
+            result.accessibilityHint = buttonViewModel.accessibilityHint?.value
+            result.addAction {
+                buttonViewModel.action()
+            }
+            return result
+        }
+        
+        if let bodyTextViewModel = contentItem as? BodyTextViewModel {
+            let result = UILabel()
+            result.font = UIFont(
+                style: .body,
+                weight: bodyTextViewModel.fontWeight
+            )
+            result.text = bodyTextViewModel.text
+            result.adjustsFontForContentSizeCategory = true
+            result.lineBreakMode = .byTruncatingTail
+            result.textAlignment = .center
+            result.numberOfLines = 0
+            return result
+        }
+        
+        if let bulletViewModel = contentItem as? BulletViewModel {
+            let result = BulletView(
+                title: bulletViewModel.title,
+                text: bulletViewModel.text,
+                titleFont: bulletViewModel.titleFont ?? UIFont(
+                    style: .title2,
+                    weight: .bold
+                )
+            )
+            return result
+        }
+        
+        return nil
+    }
+    
+}
diff --git a/Sources/GDSCommon/Patterns/GDSError/GDSErrorViewController.swift b/Sources/GDSCommon/Patterns/GDSError/GDSErrorViewController.swift
index c0f83c5f..1a1350d3 100644
--- a/Sources/GDSCommon/Patterns/GDSError/GDSErrorViewController.swift
+++ b/Sources/GDSCommon/Patterns/GDSError/GDSErrorViewController.swift
@@ -1,5 +1,6 @@
 import UIKit
 
+@available(*, deprecated, message: "Update errors to use new DesignSystem GDSErrorScreen with GDSErrorViewModelV3") // Deprecated 24/03/2025
 private struct ErrorViewModelInitialiser: GDSErrorViewModel {
     let image: String
     let title: GDSLocalisedString
@@ -27,6 +28,7 @@ private struct ErrorViewModelInitialiser: GDSErrorViewModel {
 ///     - `primaryButton`  (type: ``RoundedButton`` inherits from ``SecondaryButton``)
 ///     - `secondaryButton`  (type: ``SecondaryButton`` inherits from ``UIButton``)
 ///     - `tertiaryButton` (type: ``SecondaryButton`` inherits from ``UIButton``)
+@available(*, deprecated, renamed: "GDSErrorScreen", message: "Update errors to use new DesignSystem GDSErrorScreen with GDSErrorViewModelV3") // Deprecated 24/03/2025
 public final class GDSErrorViewController: BaseViewController, TitledViewController {
     public override var nibName: String? { "GDSError" }
     
diff --git a/Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift b/Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
index 7e0886b0..ce53d6f1 100644
--- a/Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
+++ b/Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
@@ -1,10 +1,20 @@
 import UIKit
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var bodyContent: [ScreenBodyItem] { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var image: ErrorScreenImage { get }
+}
+
 /// Deprecated alias to allow initialisation of ``GDSErrorViewController`` combining the below two protocols
 @available(*, deprecated, renamed: "GDSErrorViewModelV2", message: "Should also conform to GDSErrorViewModelWithImage if image is required")
 public typealias GDSErrorViewModel = GDSErrorViewModelV2 & GDSErrorViewModelWithImage
 
 /// Protocol for the view model required to initilise ``GDSErrorViewController``
+@available(*, deprecated, renamed: "GDSErrorViewModelV3", message: "Update errors to use new DesignSystem GDSErrorScreen with GDSErrorViewModelV3") // Deprecated 24/03/2025
 @MainActor
 public protocol GDSErrorViewModelV2 {
     var title: GDSLocalisedString { get }
@@ -15,11 +25,13 @@ public protocol GDSErrorViewModelV2 {
 
 /// Conform view models that inherit from ``GDSErrorViewModelV2`` to this protocol to set a image icon
 @MainActor
+@available(*, deprecated, message: "Update errors to use new DesignSystem GDSErrorScreen with GDSErrorViewModelV3") // Deprecated 24/03/2025
 public protocol GDSErrorViewModelWithImage {
     var image: String { get }
 }
 
 /// Conform view models that inherit from ``GDSErrorViewModelV2`` to this protocol to set a tertiary button
+@available(*, deprecated, message: "Update errors to use new DesignSystem GDSErrorScreen with GDSErrorViewModelV3") // Deprecated 24/03/2025
 @MainActor
 public protocol GDSScreenWithTertiaryButtonViewModel {
     var tertiaryButtonViewModel: ButtonViewModel { get }
diff --git a/Sources/GDSCommon/Patterns/GDSInformation/GDSCentreAlignedScreen.swift b/Sources/GDSCommon/Patterns/GDSInformation/GDSCentreAlignedScreen.swift
index c94da0c2..1be8389c 100644
--- a/Sources/GDSCommon/Patterns/GDSInformation/GDSCentreAlignedScreen.swift
+++ b/Sources/GDSCommon/Patterns/GDSInformation/GDSCentreAlignedScreen.swift
@@ -29,9 +29,9 @@ public final class GDSCentreAlignedScreen: BaseViewController, TitledViewControl
             bottomStack.accessibilityIdentifier = "centre-aligned-screen-bottom-stack-view"
         }
     }
-
+    
     internal var isFootnoteInScrollView = false
- 
+    
     public override func viewDidLayoutSubviews() {
         super.viewDidLayoutSubviews()
         
@@ -46,10 +46,10 @@ public final class GDSCentreAlignedScreen: BaseViewController, TitledViewControl
         // if bottom stack covers more than 1/3 of screen
         if bottomStackHeight >= screenHeight / 3,
            !(isFootnoteInScrollView) {
-                moveFootnoteToScrollView()
+            moveFootnoteToScrollView()
         } else if (bottomStackHeight + footnoteHeight) < screenHeight / 3,
                   isFootnoteInScrollView {
-                moveFootnoteToBottomStackView()
+            moveFootnoteToBottomStackView()
         }
     }
     
@@ -131,7 +131,7 @@ public final class GDSCentreAlignedScreen: BaseViewController, TitledViewControl
             bodyLabel.accessibilityIdentifier = "centre-aligned-screen-body"
         }
     }
-
+    
     /// Stack View: `UIStackView`. Any `UIView` which is on the `GDSInformationViewModelWithChildView` view model's `childView` property.
     /// This will be added to the `stackView` below the existing `bodyLabel`
     @IBOutlet private var stackView: UIStackView! {
@@ -161,7 +161,7 @@ public final class GDSCentreAlignedScreen: BaseViewController, TitledViewControl
     @IBOutlet private var primaryButton: RoundedButton! {
         didSet {
             if let buttonViewModel = viewModel as? GDSInformationViewModelWithOptionalPrimaryButton,
-                let button = buttonViewModel.primaryButtonViewModel {
+               let button = buttonViewModel.primaryButtonViewModel {
                 primaryButton.setTitle(button.title.value, for: .normal)
             } else if let buttonViewModel = viewModel as? GDSCentreAlignedViewModelWithPrimaryButton {
                 primaryButton.setTitle(buttonViewModel.primaryButtonViewModel.title.value, for: .normal)
@@ -211,7 +211,7 @@ public final class GDSCentreAlignedScreen: BaseViewController, TitledViewControl
             secondaryButton.accessibilityIdentifier = "centre-aligned-screen-secondary-button"
         }
     }
-
+    
     @IBAction private func secondaryButtonAction(_ sender: Any) {
         if let buttonViewModel = viewModel as? GDSInformationViewModelWithOptionalSecondaryButton {
             buttonViewModel.secondaryButtonViewModel?.action()
diff --git a/Sources/GDSCommon/Patterns/README.md b/Sources/GDSCommon/Patterns/README.md
index 43166f59..db1ff44d 100644
--- a/Sources/GDSCommon/Patterns/README.md
+++ b/Sources/GDSCommon/Patterns/README.md
@@ -243,17 +243,19 @@ This screen includes an title, subtitle and button. These views a situated withi
 The content on the screen is set from the `viewModel`, which must conform to the `OptionViewModel` protocol.
 
 
-## GDSError
-This screen is typically used as an error screen, consisting of an optional alert icon, a title, body and the option of one, two or three buttons.
-A `UIStackView` holds the `errorImageView` and encases a second `UIStackView` which holds the `errorTitle` and `errorBody`. These views are placed within a `ScrollView`.
-The `primaryButton`, `secondaryButton` and `tertiaryButton` are placed in a `UIStackView`, below the `ScrollView`.
+## GDSErrorScreen
+This screen is typically used as an error screen, consisting of an alert icon, a title, body and the option of no, one, two or three buttons.
+A container `UIStackView` holds the `scrollView` and the `bottomStackView`.
 
-`GDSErrorViewController` inherits from `BaseViewController`, so a navigation back button and right bar button can be configured. If this screen should be presented as a modal view, this should be done at the call site.
+The `bottomStackView` holds a nested `UIStackView` which contains the action buttons. 
+
+The `ScrollView` contains another `UIStackView` containing spacers and another `UIStackView` containing the Icon (`imageView`) and title (`titleLabel`), and another `UIStackView` to contain the array of body content views generate from the `[ScreenBodyItem]` array passed into the view model.
+
+`GDSErrorScreen` inherits from `BaseViewController`, so a navigation back button and right bar button can be configured. If this screen should be presented as a modal view, this should be done at the call site.
 
 A navigation item can be configured:
-- The `primaryButton`'s action is set from the ``primaryButtonViewModel`` in the ``GDSErrorViewModel`` protocol.
-- The `secondaryButton`'s action is set from the ``secondaryButtonViewModel`` in the ``GDSErrorViewModel`` protocol.
-- The `tertiaryButton`'s action is set from the ``tertiaryButtonViewModel` in the ``GDSTertiaryButtonViewModel`` protocol.
+- An array of `ButtonViewModel` passed into the view model `GDSErrorViewModelV3`, then presents either none, one, two three buttons. 
+- The first in the array is always treated as the `PrimaryButton` and all others as `SecondaryButton`
 
 If the viewModel conforms to BaseViewModel:
 - A back button can be set via the `hideBackButton` boolean property on the view controller
@@ -264,25 +266,38 @@ If the viewModel conforms to BaseViewModel:
 ### Example:
 
 ```swift
-struct MockErrorViewModel: GDSErrorViewModelV2, GDSErrorViewModelWithImage, BaseViewModel {
-    let image: String? = "exclamationmark.circle"
+struct MockErrorViewModelV3: GDSErrorViewModelV3, BaseViewModel {
+    var image: ErrorScreenImage = .error
     let title: GDSLocalisedString = "This is an Error View title"
-    let body: GDSLocalisedString = "This is an Error View body This is an Error View body"
-    let primaryButtonViewModel: ButtonViewModel 
-    let secondaryButtonViewModel: ButtonViewModel? = nil
-    let rightBarButtonTitle: GDSLocalisedString?
+    let rightBarButtonTitle: GDSLocalisedString? = nil
     let backButtonIsHidden: Bool = false
     
-    init(action: @escaping () -> Void) {
-        self.primaryButtonViewModel = ButtonViewModel(titleKey: "Try again") { 
-            action() 
-        } 
-    }
+    var bodyContent: [ScreenBodyItem] = [
+        BodyTextViewModel(
+            text: "Body single line (regular)"
+        ),
+        BodyTextViewModel(
+        text:
+        """
+            Body multiple paragraphs - Lorem ipsum dolor sit amet consectetur.
+            
+            Purus aliquam mattis vitae enim mauris vestibulum massa tellus.
+        """
+        ),
+        MockButtonViewModel.textCentered
+    ]
     
-    func didAppear() {}
+    var buttonViewModels: [any ButtonViewModel] = [
+        MockButtonViewModel.primary,
+        MockButtonViewModel.secondary
+    ]
     
+    func didAppear() {}
     func didDismiss() {}
 }
+
+
+
 ```
 
 
diff --git a/Sources/GDSCommon/Patterns/VoiceOverFocus.swift b/Sources/GDSCommon/Patterns/VoiceOverFocus.swift
index e25237c0..93c51be6 100644
--- a/Sources/GDSCommon/Patterns/VoiceOverFocus.swift
+++ b/Sources/GDSCommon/Patterns/VoiceOverFocus.swift
@@ -21,3 +21,14 @@ extension TitledViewController {
         titleLabel
     }
 }
+
+@MainActor
+protocol TitledViewControllerV2: VoiceOverFocus {
+    var titleLabel: UILabel { get }
+}
+
+extension TitledViewControllerV2 {
+    public var initialVoiceOverView: UIView {
+        titleLabel
+    }
+}
diff --git a/Sources/GDSCommon/Styles/Strings/cy.lproj/Localizable.strings b/Sources/GDSCommon/Styles/Strings/cy.lproj/Localizable.strings
index c1250d4d..20769e1e 100644
--- a/Sources/GDSCommon/Styles/Strings/cy.lproj/Localizable.strings
+++ b/Sources/GDSCommon/Styles/Strings/cy.lproj/Localizable.strings
@@ -1,2 +1,4 @@
 // MARK: Generic Strings
 "GDSCommonBackButton" = "Yn ôl";
+"GDSCommonVoiceOverErrorPrefix" = "Gwall";
+"GDSCommonVoiceOverWarningPrefix" = "Rhybudd";
diff --git a/Sources/GDSCommon/Styles/Strings/en.lproj/Localizable.strings b/Sources/GDSCommon/Styles/Strings/en.lproj/Localizable.strings
index 7476f6be..e8e01117 100644
--- a/Sources/GDSCommon/Styles/Strings/en.lproj/Localizable.strings
+++ b/Sources/GDSCommon/Styles/Strings/en.lproj/Localizable.strings
@@ -1,2 +1,4 @@
 // MARK: Generic Strings
 "GDSCommonBackButton" = "Back";
+"GDSCommonVoiceOverErrorPrefix" = "Error";
+"GDSCommonVoiceOverWarningPrefix" = "Warning";
diff --git a/Sources/GDSCommon/Utilities/Extensions/UIKit/UIControl+Extensions.swift b/Sources/GDSCommon/Utilities/Extensions/UIKit/UIControl+Extensions.swift
new file mode 100644
index 00000000..4b94b52e
--- /dev/null
+++ b/Sources/GDSCommon/Utilities/Extensions/UIKit/UIControl+Extensions.swift
@@ -0,0 +1,41 @@
+import Foundation
+import UIKit
+
+public extension UIControl {
+    func addAction(
+        for controlEvents: UIControl.Event = .primaryActionTriggered,
+        action: @escaping () -> Void
+    ) {
+        let sleeve = UIControl.ClosureSleeve(
+            attached: action,
+            to: self
+        )
+        
+        addTarget(
+            sleeve,
+            action: #selector(UIControl.ClosureSleeve.invoke),
+            for: controlEvents
+        )
+    }
+    
+    class ClosureSleeve {
+        let closure: () -> Void
+        init(
+            attached closure: @escaping () -> Void,
+            to object: AnyObject
+        ) {
+            self.closure = closure
+            objc_setAssociatedObject(
+                object,
+                "[\(UInt64.random(in: 0..<UInt64.max))]",
+                self,
+                .OBJC_ASSOCIATION_RETAIN
+            )
+        }
+        
+        @objc
+        func invoke() {
+            closure()
+        }
+    }
+}
diff --git a/Sources/GDSCommon/Utilities/Extensions/UIKit/UIStackView+Extensions.swift b/Sources/GDSCommon/Utilities/Extensions/UIKit/UIStackView+Extensions.swift
index 9ae8e95a..8a0b071d 100644
--- a/Sources/GDSCommon/Utilities/Extensions/UIKit/UIStackView+Extensions.swift
+++ b/Sources/GDSCommon/Utilities/Extensions/UIKit/UIStackView+Extensions.swift
@@ -1,13 +1,29 @@
 import UIKit
 
 extension UIStackView {
-    public convenience init(views: UIView...,
-                            axis: NSLayoutConstraint.Axis = .vertical,
-                            spacing: CGFloat = 24,
-                            alignment: UIStackView.Alignment = .leading) {
+    public convenience init(
+        views: UIView...,
+        axis: NSLayoutConstraint.Axis = .vertical,
+        spacing: CGFloat = 24,
+        alignment: UIStackView.Alignment = .leading
+    ) {
         self.init(arrangedSubviews: views)
         self.axis = axis
         self.spacing = spacing
         self.alignment = alignment
     }
+    
+    public convenience init(
+        views: [UIView],
+        axis: NSLayoutConstraint.Axis = .vertical,
+        spacing: CGFloat = 24,
+        alignment: UIStackView.Alignment = .fill,
+        distribution: UIStackView.Distribution = .fillEqually
+    ) {
+        self.init(arrangedSubviews: views)
+        self.axis = axis
+        self.spacing = spacing
+        self.alignment = alignment
+        self.distribution = distribution
+    }
 }
diff --git a/Sources/GDSCommon/Utilities/Extensions/UIKit/UIView+Extensions.swift b/Sources/GDSCommon/Utilities/Extensions/UIKit/UIView+Extensions.swift
index 7019f454..13553258 100644
--- a/Sources/GDSCommon/Utilities/Extensions/UIKit/UIView+Extensions.swift
+++ b/Sources/GDSCommon/Utilities/Extensions/UIKit/UIView+Extensions.swift
@@ -1,14 +1,90 @@
 import UIKit
 
 extension UIView {
-    public func addSubview(_ view: UIView, insetBy insets: UIEdgeInsets) {
+    public func addSubview(
+        _ view: UIView,
+        insetBy insets: UIEdgeInsets
+    ) {
         addSubview(view)
         view.translatesAutoresizingMaskIntoConstraints = false
         NSLayoutConstraint.activate([
-            view.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: insets.top),
-            view.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: insets.left),
-            view.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -insets.right),
-            view.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -insets.bottom)
+            view.topAnchor.constraint(
+                equalTo: safeAreaLayoutGuide.topAnchor,
+                constant: insets.top
+            ),
+            view.leadingAnchor.constraint(
+                equalTo: safeAreaLayoutGuide.leadingAnchor,
+                constant: insets.left
+            ),
+            view.trailingAnchor.constraint(
+                equalTo: safeAreaLayoutGuide.trailingAnchor,
+                constant: -insets.right
+            ),
+            view.bottomAnchor.constraint(
+                equalTo: safeAreaLayoutGuide.bottomAnchor,
+                constant: -insets.bottom
+            )
         ])
     }
+    
+    public func bindToSuperviewEdges(
+        padding: CGFloat = 0
+    ) {
+        guard let superview = self.superview else {
+            print("Error! `superview` was nil, call `addSubview(view: UIView)` before calling `bindToSuperviewEdges()` to fix this.")
+            return
+        }
+        
+        self.translatesAutoresizingMaskIntoConstraints = false
+        
+        NSLayoutConstraint.activate([
+            self.leadingAnchor.constraint(
+                equalTo: superview.leadingAnchor,
+                constant: padding
+            ),
+            self.trailingAnchor.constraint(
+                equalTo: superview.trailingAnchor,
+                constant: -padding
+            ),
+            self.topAnchor.constraint(
+                equalTo: superview.topAnchor,
+                constant: padding
+            ),
+            self.bottomAnchor.constraint(
+                equalTo: superview.bottomAnchor,
+                constant: -padding
+            )
+        ])
+    }
+    
+    public func bindToSuperviewSafeArea(
+        insetBy insets: UIEdgeInsets
+    ) {
+        guard let superview = self.superview else {
+            print("Error! `superview` was nil, call `addSubview(view: UIView)` before calling `bindToSuperviewSafeArea()` to fix this.")
+            return
+        }
+        
+        self.translatesAutoresizingMaskIntoConstraints = false
+        
+        NSLayoutConstraint.activate([
+            self.leadingAnchor.constraint(
+                equalTo: superview.safeAreaLayoutGuide.leadingAnchor,
+                constant: insets.left
+            ),
+            self.trailingAnchor.constraint(
+                equalTo: superview.safeAreaLayoutGuide.trailingAnchor,
+                constant: -insets.right
+            ),
+            self.topAnchor.constraint(
+                equalTo: superview.safeAreaLayoutGuide.topAnchor,
+                constant: insets.top
+            ),
+            self.bottomAnchor.constraint(
+                equalTo: superview.safeAreaLayoutGuide.bottomAnchor,
+                constant: -insets.bottom
+            )
+        ])
+        
+    }
 }
diff --git a/Tests/GDSCommonTests/ButtonViewModelTests.swift b/Tests/GDSCommonTests/ButtonViewModelTests.swift
index 9c2a6ab5..bf132778 100644
--- a/Tests/GDSCommonTests/ButtonViewModelTests.swift
+++ b/Tests/GDSCommonTests/ButtonViewModelTests.swift
@@ -36,6 +36,7 @@ struct TestButtonViewModel: ButtonViewModel {
     let title: GDSLocalisedString
     let icon: ButtonIconViewModel? = nil
     let shouldLoadOnTap: Bool = false
+    var contentAlignment: UIControl.ContentHorizontalAlignment? = .center
     let action: () -> Void
     let accessibilityHint: GDSLocalisedString?
 }
diff --git a/Tests/GDSCommonTests/ComponentTests/Mocks/MockButtonViewModel.swift b/Tests/GDSCommonTests/ComponentTests/Mocks/MockButtonViewModel.swift
index a7aeef06..c63583b9 100644
--- a/Tests/GDSCommonTests/ComponentTests/Mocks/MockButtonViewModel.swift
+++ b/Tests/GDSCommonTests/ComponentTests/Mocks/MockButtonViewModel.swift
@@ -1,12 +1,20 @@
 import GDSCommon
+import UIKit
 
 internal struct MockButtonViewModel: ButtonViewModel {
     let title: GDSLocalisedString
     let icon: ButtonIconViewModel?
     let shouldLoadOnTap: Bool
+    var contentAlignment: UIControl.ContentHorizontalAlignment?
     let action: () -> Void
     
-    init(title: GDSLocalisedString, icon: ButtonIconViewModel? = nil, shouldLoadOnTap: Bool = false, action: @escaping () -> Void) {
+    init(
+        title: GDSLocalisedString,
+        icon: ButtonIconViewModel? = nil,
+        shouldLoadOnTap: Bool = false,
+        contentAlignment: UIControl.ContentHorizontalAlignment? = .center,
+        action: @escaping () -> Void
+    ) {
         self.title = title
         self.icon = icon
         self.shouldLoadOnTap = shouldLoadOnTap


Reviews:
user: login: robinphillips
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T15:08:54Z
user: login: robinphillips
body: Overall, this looks really good. Just a few comments. Maybe worth having a wider discussion about the child view points.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T15:40:01Z
user: login: krissbennett
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-21T15:44:51Z
user: login: krissbennett
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-21T15:53:28Z
user: login: robinphillips
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T15:55:10Z
user: login: krissbennett
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-21T15:56:20Z
user: login: robinphillips
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T16:00:03Z
user: login: robinphillips
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T16:01:23Z
user: login: robinphillips
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T16:14:19Z
user: login: dylanjames-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T16:20:15Z
user: login: krissbennett
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-21T16:29:14Z
user: login: dylanjames-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T16:36:42Z
user: login: robinphillips
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T16:41:44Z
user: login: krissbennett
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-21T16:54:55Z
user: login: krissbennett
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-21T16:57:02Z
user: login: robinphillips
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T17:20:24Z
user: login: robinphillips
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T17:46:25Z
user: login: obinns-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T09:29:36Z
user: login: dylanjames-dd
body: 🙌 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T16:27:10Z
user: login: robinphillips
body: Great work @krissbennett 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T16:28:42Z
user: login: benmckillop-dd
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T16:30:47Z

Review comments:
diff_hunk: @@ -46,6 +46,18 @@ extension MockButtonViewModel {
                             action: {})
     }
     
+    static var tertiary: MockButtonViewModel {
path: GDSCommon-Demo/GDSCommon-Demo/Mocks/MockButtonViewModel.swift
user: login: robinphillips
body: I don't think we need this - we don't have a tertiary button style. I wonder tbh whether all these other existing static vars are actually needed. We could make them more configurable with default arguments in an init.

Would be good to understand from others about why we have so many here rather than one or two that are optionally configurable.
created_at: 2025-03-21T15:08:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,223 @@
+import UIKit
+
+public class GDSErrorScreen: BaseViewController, TitledViewControllerV2 {
+    
+    public private(set) var viewModel: GDSErrorViewModelV3
+    
+    var isFootnoteInScrollView = false
+    
+    let defaultSpacing = 16.0 // Use Design system when available
+    
+    lazy var titleLabel: UILabel = {
+        let result = UILabel()
+        result.font = UIFont(
+            style: .largeTitle,
+            weight: .bold,
+            design: .default
+        )
+        result.text = viewModel.title.value
+        result.accessibilityIdentifier = "error-screen-title"
+        result.accessibilityTraits = [.header]
+        result.accessibilityLabel = "\(viewModel.errorDefaults.voiceOverPrefix): \(viewModel.title) :"
+        result.textAlignment = .center
+        result.lineBreakMode = .byTruncatingTail
+        result.numberOfLines = 0
+        return result
+    }()
+    
+    private lazy var bodyLabel: UILabel = {
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorScreen.swift
user: login: robinphillips
body: Reading the spec on figma, design see the child view as a series of blocks of 'body' content.

If we match that (feels like we should) then the body label would be part of that.

I wonder if for this error screen, the child view is always a stack view and we can initialise the view model with a simplified API that allows for creation of label, bullet and button.

For example, we have a protocol something like this:
`protocol ScreenBodyItem { }`

Then conform a series of types to that:
```
extension GDSLocalizedString: ScreenBodyItem { }

extension BulletViewModel: ScreenBodyItem { }

extension ButtonViewModel: ScreenBodyItem { }

etc ...
```

The error view model v3 something protocol would be adjusted to something like:
```
public protocol GDSErrorViewModelV3 {
    var image: VoiceOverImage { get }
    var title: GDSLocalisedString { get }
    var bodyContent: [ ScreenBodyItem ]  { get }
    var buttonViewModels: [ ButtonViewModel ] { get }
}
```
created_at: 2025-03-21T15:32:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,223 @@
+import UIKit
+
+public class GDSErrorScreen: BaseViewController, TitledViewControllerV2 {
+    
+    public private(set) var viewModel: GDSErrorViewModelV3
+    
+    var isFootnoteInScrollView = false
+    
+    let defaultSpacing = 16.0 // Use Design system when available
+    
+    lazy var titleLabel: UILabel = {
+        let result = UILabel()
+        result.font = UIFont(
+            style: .largeTitle,
+            weight: .bold,
+            design: .default
+        )
+        result.text = viewModel.title.value
+        result.accessibilityIdentifier = "error-screen-title"
+        result.accessibilityTraits = [.header]
+        result.accessibilityLabel = "\(viewModel.errorDefaults.voiceOverPrefix): \(viewModel.title) :"
+        result.textAlignment = .center
+        result.lineBreakMode = .byTruncatingTail
+        result.numberOfLines = 0
+        return result
+    }()
+    
+    private lazy var bodyLabel: UILabel = {
+        let result = UILabel()
+        if let bodyContent = viewModel.body {
+            result.text = bodyContent.value
+        } else {
+            result.isHidden = true
+        }
+        result.font = UIFont(style: .body)
+        result.adjustsFontForContentSizeCategory = true
+        result.accessibilityIdentifier = "error-screen-body"
+        result.textAlignment = .center
+        result.lineBreakMode = .byTruncatingTail
+        result.numberOfLines = 0
+        return result
+    }()
+    
+    private lazy var containerStackView: UIStackView = {
+        let result = UIStackView(
+            views: [
+                scrollView,
+                bottomStackView
+            ],
+            distribution: .fill
+        )
+        result.accessibilityIdentifier = "error-screen-container-stack-view"
+        return result
+    }()
+    
+    private lazy var scrollView: UIScrollView = {
+        let result = UIScrollView()
+        result.contentLayoutGuide.widthAnchor.constraint(equalTo: result.widthAnchor).isActive = true
+        result.addSubview(scrollViewOuterStackView)
+        result.accessibilityIdentifier = "error-screen-container-scrollview"
+        scrollViewOuterStackView.bindToSuperviewEdges()
+        return result
+    }()
+    
+    private lazy var scrollViewOuterStackView: UIStackView = {
+        let result = UIStackView(
+            views: [
+                topSpacer,
+                scrollViewInnerStackView,
+                bottomSpacer
+            ],
+            distribution: .equalSpacing
+        )
+        result.accessibilityIdentifier = "error-screen-outer-stack-view"
+        return result
+    }()
+    
+    private lazy var topSpacer: UIView = {
+        let result = UIView()
+        result.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+        return result
+    }()
+    
+    private lazy var bottomSpacer: UIView = {
+        let result = UIView()
+        result.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+        return result
+    }()
+    
+    private lazy var scrollViewInnerStackView: UIStackView = {
+        let result = UIStackView(
+            views: [
+                imageView,
+                titleLabel,
+                bodyLabel
+            ],
+            spacing: defaultSpacing,
+            distribution: .equalSpacing
+        )
+        
+        if let childView = viewModel.childView {
+            childView.accessibilityIdentifier = "error-screen-child-view"
+            result.addArrangedSubview(childView)
+        }
+        result.accessibilityIdentifier = "error-screen-inner-stack-view"
+        return result
+    }()
+    
+    private lazy var imageView: UIImageView = {
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorScreen.swift
user: login: robinphillips
body: Please could we arrange the file in the order the elements appear on screen?
created_at: 2025-03-21T15:33:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,223 @@
+import UIKit
+
+public class GDSErrorScreen: BaseViewController, TitledViewControllerV2 {
+    
+    public private(set) var viewModel: GDSErrorViewModelV3
+    
+    var isFootnoteInScrollView = false
+    
+    let defaultSpacing = 16.0 // Use Design system when available
+    
+    lazy var titleLabel: UILabel = {
+        let result = UILabel()
+        result.font = UIFont(
+            style: .largeTitle,
+            weight: .bold,
+            design: .default
+        )
+        result.text = viewModel.title.value
+        result.accessibilityIdentifier = "error-screen-title"
+        result.accessibilityTraits = [.header]
+        result.accessibilityLabel = "\(viewModel.errorDefaults.voiceOverPrefix): \(viewModel.title) :"
+        result.textAlignment = .center
+        result.lineBreakMode = .byTruncatingTail
+        result.numberOfLines = 0
+        return result
+    }()
+    
+    private lazy var bodyLabel: UILabel = {
+        let result = UILabel()
+        if let bodyContent = viewModel.body {
+            result.text = bodyContent.value
+        } else {
+            result.isHidden = true
+        }
+        result.font = UIFont(style: .body)
+        result.adjustsFontForContentSizeCategory = true
+        result.accessibilityIdentifier = "error-screen-body"
+        result.textAlignment = .center
+        result.lineBreakMode = .byTruncatingTail
+        result.numberOfLines = 0
+        return result
+    }()
+    
+    private lazy var containerStackView: UIStackView = {
+        let result = UIStackView(
+            views: [
+                scrollView,
+                bottomStackView
+            ],
+            distribution: .fill
+        )
+        result.accessibilityIdentifier = "error-screen-container-stack-view"
+        return result
+    }()
+    
+    private lazy var scrollView: UIScrollView = {
+        let result = UIScrollView()
+        result.contentLayoutGuide.widthAnchor.constraint(equalTo: result.widthAnchor).isActive = true
+        result.addSubview(scrollViewOuterStackView)
+        result.accessibilityIdentifier = "error-screen-container-scrollview"
+        scrollViewOuterStackView.bindToSuperviewEdges()
+        return result
+    }()
+    
+    private lazy var scrollViewOuterStackView: UIStackView = {
+        let result = UIStackView(
+            views: [
+                topSpacer,
+                scrollViewInnerStackView,
+                bottomSpacer
+            ],
+            distribution: .equalSpacing
+        )
+        result.accessibilityIdentifier = "error-screen-outer-stack-view"
+        return result
+    }()
+    
+    private lazy var topSpacer: UIView = {
+        let result = UIView()
+        result.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+        return result
+    }()
+    
+    private lazy var bottomSpacer: UIView = {
+        let result = UIView()
+        result.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+        return result
+    }()
+    
+    private lazy var scrollViewInnerStackView: UIStackView = {
+        let result = UIStackView(
+            views: [
+                imageView,
+                titleLabel,
+                bodyLabel
+            ],
+            spacing: defaultSpacing,
+            distribution: .equalSpacing
+        )
+        
+        if let childView = viewModel.childView {
+            childView.accessibilityIdentifier = "error-screen-child-view"
+            result.addArrangedSubview(childView)
+        }
+        result.accessibilityIdentifier = "error-screen-inner-stack-view"
+        return result
+    }()
+    
+    private lazy var imageView: UIImageView = {
+        let result = UIImageView()
+        let font = UIFont(style: .largeTitle, weight: .regular)
+        let configuration = UIImage.SymbolConfiguration(font: font, scale: .large)
+        
+        result.image = UIImage(
+            systemName: viewModel.errorDefaults.image,
+            withConfiguration: configuration
+        )
+        result.tintColor = .gdsPrimary
+        result.contentMode = .scaleAspectFit
+        NSLayoutConstraint.activate([
+            result.heightAnchor.constraint(greaterThanOrEqualToConstant: 107)
+        ])
+        result.accessibilityIdentifier = "error-screen-image"
+        return result
+    }()
+    
+    private lazy var bottomStackView: UIStackView = {
+        let hasButtons = !viewModel.buttonViewModels.isEmpty
+        let result = UIStackView(
+            views: [
+                buttonStackView
+            ],
+            spacing: defaultSpacing,
+            distribution: hasButtons ? .fillProportionally : .equalCentering
+        )
+        result.accessibilityIdentifier = "error-screen-bottom-stack-view"
+        return result
+    }()
+    
+    private lazy var buttonStackView: UIStackView = {
+        let result = UIStackView(
+            views: buttonViews,
+            spacing: defaultSpacing
+        )
+        result.axis = .vertical
+        result.spacing = defaultSpacing
+        result.accessibilityIdentifier = "error-screen-button-stack-view"
+        return result
+    }()
+    
+    private var buttonViews: [UIView] {
+        var views: [UIView] = []
+        for (index, buttonViewModel) in viewModel.buttonViewModels.enumerated() {
+            views.append(buttonForButtonViewModel(
+                buttonViewModel,
+                index
+            ))
+        }
+        return views
+    }
+    
+    private func buttonForButtonViewModel(
+        _ buttonViewModel: ButtonViewModel,
+        _ index: Int
+    ) -> UIButton {
+        let isPrimaryButton = index == 0
+        let result = isPrimaryButton ? RoundedButton() : SecondaryButton()
+        
+        result.setTitle(buttonViewModel.title.value, for: .normal)
+        result.accessibilityHint = buttonViewModel.accessibilityHint?.value
+        if let icon = buttonViewModel.icon {
+            result.symbolPosition = icon.symbolPosition
+            result.icon = icon.iconName
+        }
+        
+        result.accessibilityIdentifier = "error-screen-button-\(index)"
+        
+        if !isPrimaryButton {
+            result.titleLabel?.textColor = .gdsGreen
+        }
+        
+        result.addAction {
+            buttonViewModel.action()
+        }
+        return result
+    }
+    
+    private func setup() {
+        self.view.addSubview(containerStackView)
+        containerStackView.bindToSuperviewSafeArea(
+            insetBy: UIEdgeInsets(
+                top: 0,
+                left: defaultSpacing,
+                bottom: 0,
+                right: defaultSpacing
+            )
+        )
+        self.view.backgroundColor = .systemBackground
+        addRelativeViewConstraints()
+    }
+    
+    private func addRelativeViewConstraints() {
+        scrollViewOuterStackView.heightAnchor.constraint(
+            greaterThanOrEqualTo: scrollView.heightAnchor
+        ).isActive = true
+    }
+    
+    public init(
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorScreen.swift
user: login: robinphillips
body: please move the init above the methods
created_at: 2025-03-21T15:35:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: robinphillips
body: I think this should be called something like `iconImage` and should depend on a protocol rather than a concrete type. Protocol would be something like `VoiceOverIcon`
created_at: 2025-03-21T15:36:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
+}
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: robinphillips
body: How feasible would it be to make a protocol extension method that initialises a concrete `GenericErrorViewModelV3` from a V2 view model? Or perhaps a method on V2.
created_at: 2025-03-21T15:38:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,6 +46,18 @@ extension MockButtonViewModel {
                             action: {})
     }
     
+    static var tertiary: MockButtonViewModel {
path: GDSCommon-Demo/GDSCommon-Demo/Mocks/MockButtonViewModel.swift
user: login: krissbennett
body: Sure, I was just following existing pattern of what was there
created_at: 2025-03-21T15:44:51Z
author_association: NONE
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: krissbennett
body: Im not convinced the icon should even be in here, as its not just for Voice over, as its part of the screen. 
Im tempted to just have them as seperate properties on the viewmodel instead
created_at: 2025-03-21T15:53:28Z
author_association: NONE
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: robinphillips
body: Maybe I've misunderstood - I thought the VoiceOver string was relating to the image / icon?
created_at: 2025-03-21T15:55:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
+}
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: krissbennett
body: We could, I know we discussed this but then I thought to use it we'd have to update code anyway so in that instance why not just change to V3 instance rather than create it from a V2?
created_at: 2025-03-21T15:56:20Z
author_association: NONE
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
+}
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: robinphillips
body: If we can do it (and I think we can) then we could have a convenience init on the new screen that accepts a V2 view model. That could greatly simplify adoption of the new screen.
created_at: 2025-03-21T16:00:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
+}
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: robinphillips
body: In fact, just do that mapping there rather than in an extension on the view model types
created_at: 2025-03-21T16:01:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,12 @@ import UIKit
 ///             the view as a `UIViewController` to push/present on the navigation stack.
 @MainActor
 enum Screens: String, CaseIterable {
+    case gdsErrorScreenNoButtons = "New Error Screen (with no buttons)"
+    case gdsErrorScreen = "New Error Screen (with 2 buttons)"
+    case gdsErrorScreen3Buttons = "New Error Screen (with 3 buttons)"
+    case gdsErrorScreenModal = "New Error Screen (modal)"
+    case gdsErrorScreenWarning = "New Error Screen (warning)"
+    case gdsErrorScreenAppUpdate = "New Error Screen (app update)"
path: GDSCommon-Demo/GDSCommon-Demo/Mocks/Screens.swift
user: login: robinphillips
body: Maybe we can call it Design System Error Screen rather than New?
created_at: 2025-03-21T16:14:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,223 @@
+import UIKit
+
+public class GDSErrorScreen: BaseViewController, TitledViewControllerV2 {
+    
+    public private(set) var viewModel: GDSErrorViewModelV3
+    
+    var isFootnoteInScrollView = false
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorScreen.swift
user: login: dylanjames-dd
body: Doesn't look like this is being used currently?
created_at: 2025-03-21T16:20:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: krissbennett
body: the string is relating to the title not the icon, 
created_at: 2025-03-21T16:29:13Z
author_association: NONE
diff_hunk: @@ -0,0 +1,14 @@
+import UIKit
+
+public struct ErrorDefaults {
+    var image: String
+    var voiceOverPrefix: String
path: Sources/GDSCommon/Patterns/GDSError/ErrorDefaults.swift
user: login: dylanjames-dd
body: Should these be constants?
created_at: 2025-03-21T16:36:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: robinphillips
body: Ah then yeah, then that does make sense. I guess if the icon were an enum as you'd suggested then we could switch on it inside the view controller to get the correct hint string. Then we aren't coupling them together necessarily. Seems like that might be better than having another property to initialise? Maybe this is what you originally suggested, if so, sorry :) 
created_at: 2025-03-21T16:41:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,14 @@
+import UIKit
+
+public struct ErrorDefaults {
+    var image: String
+    var voiceOverPrefix: String
path: Sources/GDSCommon/Patterns/GDSError/ErrorDefaults.swift
user: login: krissbennett
body: yeah going to look at reworking this Monday as dont think we need this struct at all really
created_at: 2025-03-21T16:54:55Z
author_association: NONE
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
+}
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: krissbennett
body: Also I don't think we'll be able to do that if we introduce the more rigid ScreenBodyItem rather than just passing in the UIView?
created_at: 2025-03-21T16:57:02Z
author_association: NONE
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
+}
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: robinphillips
body: Let's chat on Monday. I don't see why not?
My thinking is that this screen should be fairly limited in what can be added to that container.
I think the view on the view controller would still be a `UIView` - thinking ahead to a future where this then becomes an updated CentreAligned screen and then inherits from that, we can handle the mapping in the initialiser of the ErrorScreen.
created_at: 2025-03-21T17:20:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,16 @@ public protocol GDSErrorViewModelV2 {
     var secondaryButtonViewModel: ButtonViewModel? { get }
 }
 
+/// Protocol for the view model required to initilise ``GDSErrorScreen``
+@MainActor
+public protocol GDSErrorViewModelV3 {
+    var title: GDSLocalisedString { get }
+    var body: GDSLocalisedString? { get }
+    var childView: UIView? { get }
+    var buttonViewModels: [ButtonViewModel] { get }
+    var errorDefaults: ErrorDefaults { get }
+}
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorViewModel.swift
user: login: robinphillips
body: The other advantage of depending on a collection of what are essentially sub-view models is that when constructing them, we don't need to import `UIKit`. It also means that if we ever wanted to use the same view models for a SwiftUI screen, then we can construct SwiftUI views instead of needing to display a UIView
created_at: 2025-03-21T17:46:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,223 @@
+import UIKit
+
+public class GDSErrorScreen: BaseViewController, TitledViewControllerV2 {
+    
+    public private(set) var viewModel: GDSErrorViewModelV3
+    
+    var isFootnoteInScrollView = false
+    
+    let defaultSpacing = 16.0 // Use Design system when available
+    
+    lazy var titleLabel: UILabel = {
+        let result = UILabel()
+        result.font = UIFont(
+            style: .largeTitle,
+            weight: .bold,
+            design: .default
+        )
+        result.text = viewModel.title.value
+        result.accessibilityIdentifier = "error-screen-title"
+        result.accessibilityTraits = [.header]
+        result.accessibilityLabel = "\(viewModel.errorDefaults.voiceOverPrefix): \(viewModel.title) :"
+        result.textAlignment = .center
+        result.lineBreakMode = .byTruncatingTail
+        result.numberOfLines = 0
+        return result
+    }()
+    
+    private lazy var bodyLabel: UILabel = {
path: Sources/GDSCommon/Patterns/GDSError/GDSErrorScreen.swift
user: login: obinns-dd
body: Agree 🙂 another nice thing about this is it aligns with the Android implementation:
https://github.com/govuk-one-login/mobile-android-ui/blob/7993c5685842b5c67b526b93913d6ba49c1651ab/patterns/src/main/java/uk/gov/android/ui/patterns/leftalignedscreen/LeftAlignedScreenContent.kt#L36
created_at: 2025-03-24T09:29:36Z
author_association: CONTRIBUTOR

PR comments:
user: login: robinphillips
created_at: 2025-03-21T16:11:25Z
body: There's a few dynamic type issues. The body button is getting squished when dynamic type size is increased and then compressed.

Also note that the label in the child view isn't scaling properly.


![Simulator Screenshot - iPhone 16 Pro - 2025-03-21 at 16 09 13](https://github.com/user-attachments/assets/9bce3de2-3f90-4069-b4f6-bdfb2408d2fa)


author_association: CONTRIBUTOR
user: login: krissbennett
created_at: 2025-03-21T16:35:08Z
body: > There's a few dynamic type issues. The body button is getting squished when dynamic type size is increased and then compressed.
> 
> Also note that the label in the child view isn't scaling properly.
> 
> ![Simulator Screenshot - iPhone 16 Pro - 2025-03-21 at 16 09 13](https://private-user-images.githubusercontent.com/104073334/425558589-9bce3de2-3f90-4069-b4f6-bdfb2408d2fa.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDI1NzQ3ODUsIm5iZiI6MTc0MjU3NDQ4NSwicGF0aCI6Ii8xMDQwNzMzMzQvNDI1NTU4NTg5LTliY2UzZGUyLTNmOTAtNDA2OS1iNGY2LWJkZmIyNDA4ZDJmYS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwMzIxJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDMyMVQxNjI4MDVaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1kMjgyYjBhMDA0MGQxNzk1NTFhZjQyZWUwMWQ0MjcyZjlhODNlZmY3ZWZlNzZmODFkZTZkZTZiNDViNWI1MDE3JlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.oHqBWHAreVMP9_-5yKIc7aKQZ0PPiercQERMqOyky68)

Thats odd, its scaliing fine for me: what iOS ver and device was that on?

![image](https://github.com/user-attachments/assets/f28d78b4-2e17-4ac3-bc98-fa0a758d45f5)

author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-03-24T16:08:05Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=di-mobile-ios-common&pullRequest=121) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=di-mobile-ios-common&pullRequest=121&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=di-mobile-ios-common&pullRequest=121&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=di-mobile-ios-common&pullRequest=121&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [93.9% Coverage on New Code](https://sonarcloud.io/component_measures?id=di-mobile-ios-common&pullRequest=121&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=di-mobile-ios-common&pullRequest=121&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=di-mobile-ios-common&pullRequest=121)


author_association: NONE
user: login: co-eoin
created_at: 2025-03-24T16:23:44Z
body: QA Reviewed & Approved @krissbennett - nice work!
author_association: NONE