android-midscene-automation 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+
3
+ export type FlowBranch = 'yes' | 'no';
4
+
5
+ export type FlowClipboard = {
6
+ steps: AppiumRecordedStep[];
7
+ rootIds: string[];
8
+ };
9
+
10
+ type PasteTarget = {
11
+ afterIndex: number;
12
+ branch?: FlowBranch;
13
+ };
14
+
15
+ const targetKeys = [
16
+ 'yesTargetId',
17
+ 'noTargetId',
18
+ 'successTargetId',
19
+ 'failureTargetId',
20
+ ] as const;
21
+
22
+ function clone<T>(value: T): T {
23
+ return structuredClone(value);
24
+ }
25
+
26
+ function scopeKey(step: AppiumRecordedStep) {
27
+ return step.flow?.parentConditionId
28
+ ? `${step.flow.parentConditionId}:${step.flow.parentBranch || ''}`
29
+ : 'main';
30
+ }
31
+
32
+ function collectDescendantIds(steps: AppiumRecordedStep[], rootIds: Iterable<string>) {
33
+ const ids = new Set(rootIds);
34
+ let changed = true;
35
+ while (changed) {
36
+ changed = false;
37
+ for (const step of steps) {
38
+ if (step.flow?.parentConditionId && ids.has(step.flow.parentConditionId) && !ids.has(step.id)) {
39
+ ids.add(step.id);
40
+ changed = true;
41
+ }
42
+ }
43
+ }
44
+ return ids;
45
+ }
46
+
47
+ function setFlowTarget(step: AppiumRecordedStep, key: typeof targetKeys[number], targetId: string) {
48
+ const flow = { ...(step.flow || {}) };
49
+ if (targetId) flow[key] = targetId;
50
+ else delete flow[key];
51
+ step.flow = Object.keys(flow).length ? flow : undefined;
52
+ }
53
+
54
+ function wireContinuation(step: AppiumRecordedStep, targetId: string) {
55
+ setFlowTarget(step, 'successTargetId', targetId);
56
+ if (step.flow?.nodeKind !== 'condition' || !targetId) return;
57
+ if (!step.flow.yesTargetId) setFlowTarget(step, 'yesTargetId', targetId);
58
+ if (!step.flow.noTargetId) setFlowTarget(step, 'noTargetId', targetId);
59
+ }
60
+
61
+ export function createFlowClipboard(steps: AppiumRecordedStep[], indexes: number[]): FlowClipboard {
62
+ const selectedIndexes = [...new Set(indexes)].sort((left, right) => left - right);
63
+ const selectedSteps = selectedIndexes.map((index) => steps[index]).filter(Boolean);
64
+ const selectedIds = new Set(selectedSteps.map((step) => step.id));
65
+ const roots = selectedSteps.filter((step) => {
66
+ let parentId = step.flow?.parentConditionId;
67
+ while (parentId) {
68
+ if (selectedIds.has(parentId)) return false;
69
+ parentId = steps.find((item) => item.id === parentId)?.flow?.parentConditionId;
70
+ }
71
+ return true;
72
+ });
73
+ if (!roots.length) throw new Error('请选择要复制的节点');
74
+ const isDisconnected = roots.some((step, index) => {
75
+ if (!index) return false;
76
+ const previous = roots[index - 1];
77
+ if (scopeKey(previous) !== scopeKey(step)) {
78
+ return previous.flow?.successTargetId !== step.id;
79
+ }
80
+ const siblings = steps.filter((item) => scopeKey(item) === scopeKey(step));
81
+ return siblings.findIndex((item) => item.id === step.id)
82
+ !== siblings.findIndex((item) => item.id === previous.id) + 1;
83
+ });
84
+ if (isDisconnected) {
85
+ throw new Error('批量复制只能选择连续节点');
86
+ }
87
+
88
+ const rootIds = roots.map((step) => step.id);
89
+ const copiedIds = collectDescendantIds(steps, rootIds);
90
+ const copiedSteps = steps.filter((step) => copiedIds.has(step.id));
91
+ if (copiedSteps.some((step) => step.type === 'launchApp')) {
92
+ throw new Error('启动 APP 节点不能复制');
93
+ }
94
+ return { steps: clone(copiedSteps), rootIds };
95
+ }
96
+
97
+ export function pasteFlowClipboard(
98
+ steps: AppiumRecordedStep[],
99
+ clipboard: FlowClipboard,
100
+ target: PasteTarget,
101
+ createId: () => string = () => `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
102
+ ) {
103
+ if (!clipboard.steps.length || !clipboard.rootIds.length) throw new Error('复制板中没有节点');
104
+
105
+ const idMap = new Map(clipboard.steps.map((step) => [step.id, createId()]));
106
+ const rootIdSet = new Set(clipboard.rootIds);
107
+ const condition = target.branch ? steps[target.afterIndex] : undefined;
108
+ if (target.branch && (!condition || condition.flow?.nodeKind !== 'condition')) {
109
+ throw new Error('目标分支不存在');
110
+ }
111
+
112
+ const copies = clipboard.steps.map((source) => {
113
+ const step = clone(source);
114
+ step.id = idMap.get(source.id) || createId();
115
+ const flow = { ...(step.flow || {}) };
116
+ for (const key of targetKeys) {
117
+ const mappedId = flow[key] ? idMap.get(flow[key] || '') : '';
118
+ if (mappedId) flow[key] = mappedId;
119
+ else delete flow[key];
120
+ }
121
+ if (flow.parentConditionId) {
122
+ const mappedParentId = idMap.get(flow.parentConditionId);
123
+ if (mappedParentId) flow.parentConditionId = mappedParentId;
124
+ else {
125
+ delete flow.parentConditionId;
126
+ delete flow.parentBranch;
127
+ }
128
+ }
129
+ if (rootIdSet.has(source.id)) {
130
+ if (target.branch && condition) {
131
+ flow.parentConditionId = condition.id;
132
+ flow.parentBranch = target.branch;
133
+ } else {
134
+ delete flow.parentConditionId;
135
+ delete flow.parentBranch;
136
+ }
137
+ }
138
+ step.flow = Object.keys(flow).length ? flow : undefined;
139
+ return step;
140
+ });
141
+
142
+ const copiedBySourceId = new Map(clipboard.steps.map((step, index) => [step.id, copies[index]]));
143
+ const copiedRoots = clipboard.rootIds
144
+ .map((id) => copiedBySourceId.get(id))
145
+ .filter((step): step is AppiumRecordedStep => Boolean(step));
146
+ const firstRoot = copiedRoots[0];
147
+ const lastRoot = copiedRoots[copiedRoots.length - 1];
148
+ if (!firstRoot || !lastRoot) throw new Error('复制节点数据无效');
149
+
150
+ const nextSteps = [...steps];
151
+ let insertAfterIndex = target.afterIndex;
152
+ let continuationId = '';
153
+
154
+ if (target.branch && condition) {
155
+ const branchRoots = steps.filter((step) => (
156
+ step.flow?.parentConditionId === condition.id
157
+ && step.flow.parentBranch === target.branch
158
+ ));
159
+ if (branchRoots.length) {
160
+ const ownedIds = collectDescendantIds(steps, branchRoots.map((step) => step.id));
161
+ insertAfterIndex = Math.max(...steps.flatMap((step, index) => (ownedIds.has(step.id) ? [index] : [])));
162
+ const lastExistingRoot = branchRoots[branchRoots.length - 1];
163
+ continuationId = lastExistingRoot.flow?.successTargetId || condition.flow?.successTargetId || '';
164
+ const lastExistingIndex = nextSteps.findIndex((step) => step.id === lastExistingRoot.id);
165
+ const updatedLastExisting = clone(nextSteps[lastExistingIndex]);
166
+ setFlowTarget(updatedLastExisting, 'successTargetId', firstRoot.id);
167
+ nextSteps[lastExistingIndex] = updatedLastExisting;
168
+ } else {
169
+ const targetKey = target.branch === 'yes' ? 'yesTargetId' : 'noTargetId';
170
+ const currentTargetId = condition.flow?.[targetKey] || '';
171
+ const currentTarget = steps.find((step) => step.id === currentTargetId);
172
+ continuationId = currentTarget && !currentTarget.flow?.parentConditionId
173
+ ? currentTargetId
174
+ : condition.flow?.successTargetId || '';
175
+ const updatedCondition = clone(nextSteps[target.afterIndex]);
176
+ setFlowTarget(updatedCondition, targetKey, firstRoot.id);
177
+ nextSteps[target.afterIndex] = updatedCondition;
178
+ }
179
+ } else {
180
+ const previous = target.afterIndex >= 0 ? nextSteps[target.afterIndex] : undefined;
181
+ const nextMainStep = steps.slice(target.afterIndex + 1).find((step) => !step.flow?.parentConditionId);
182
+ continuationId = previous?.flow?.successTargetId || nextMainStep?.id || '';
183
+ if (previous?.flow?.successTargetId) {
184
+ const updatedPrevious = clone(previous);
185
+ setFlowTarget(updatedPrevious, 'successTargetId', firstRoot.id);
186
+ nextSteps[target.afterIndex] = updatedPrevious;
187
+ }
188
+ }
189
+
190
+ wireContinuation(lastRoot, continuationId);
191
+ nextSteps.splice(insertAfterIndex + 1, 0, ...copies);
192
+ return nextSteps;
193
+ }
@@ -0,0 +1,88 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+
3
+ export const FLOW_NODE_WIDTH = 420;
4
+ export const FLOW_BRANCH_GAP = 16;
5
+
6
+ export type ConditionLayout = {
7
+ yesWidth: number;
8
+ noWidth: number;
9
+ totalWidth: number;
10
+ yesAxis: number;
11
+ noAxis: number;
12
+ };
13
+
14
+ export type ConditionPlacement = {
15
+ left: number;
16
+ startAxis: number;
17
+ yesAxis: number;
18
+ noAxis: number;
19
+ };
20
+
21
+ export function placeConditionLayout(
22
+ layout: ConditionLayout,
23
+ canvasWidth: number,
24
+ parentAxis: number,
25
+ ): ConditionPlacement {
26
+ const parentCenter = (parentAxis / 100) * canvasWidth;
27
+ const left = Math.min(
28
+ Math.max(parentCenter - layout.totalWidth / 2, 0),
29
+ Math.max(canvasWidth - layout.totalWidth, 0),
30
+ );
31
+ const yesCenter = left + layout.yesWidth / 2;
32
+ const noCenter = left + layout.yesWidth + FLOW_BRANCH_GAP + layout.noWidth / 2;
33
+ return {
34
+ left,
35
+ startAxis: ((parentCenter - left) / layout.totalWidth) * 100,
36
+ yesAxis: (yesCenter / canvasWidth) * 100,
37
+ noAxis: (noCenter / canvasWidth) * 100,
38
+ };
39
+ }
40
+
41
+ export function buildConditionLayouts(steps: AppiumRecordedStep[]) {
42
+ const layouts = new Map<string, ConditionLayout>();
43
+ const visiting = new Set<string>();
44
+
45
+ function conditionLayout(conditionId: string): ConditionLayout {
46
+ const cached = layouts.get(conditionId);
47
+ if (cached) return cached;
48
+ if (visiting.has(conditionId)) {
49
+ return {
50
+ yesWidth: FLOW_NODE_WIDTH,
51
+ noWidth: FLOW_NODE_WIDTH,
52
+ totalWidth: FLOW_NODE_WIDTH * 2 + FLOW_BRANCH_GAP,
53
+ yesAxis: 25,
54
+ noAxis: 75,
55
+ };
56
+ }
57
+
58
+ visiting.add(conditionId);
59
+ const branchWidth = (branch: 'yes' | 'no') => Math.max(
60
+ FLOW_NODE_WIDTH,
61
+ ...steps
62
+ .filter((step) => (
63
+ step.flow?.parentConditionId === conditionId
64
+ && step.flow.parentBranch === branch
65
+ && step.flow.nodeKind === 'condition'
66
+ ))
67
+ .map((step) => conditionLayout(step.id).totalWidth),
68
+ );
69
+ const yesWidth = branchWidth('yes');
70
+ const noWidth = branchWidth('no');
71
+ const totalWidth = yesWidth + FLOW_BRANCH_GAP + noWidth;
72
+ const layout = {
73
+ yesWidth,
74
+ noWidth,
75
+ totalWidth,
76
+ yesAxis: (yesWidth / 2 / totalWidth) * 100,
77
+ noAxis: ((yesWidth + FLOW_BRANCH_GAP + noWidth / 2) / totalWidth) * 100,
78
+ };
79
+ visiting.delete(conditionId);
80
+ layouts.set(conditionId, layout);
81
+ return layout;
82
+ }
83
+
84
+ steps
85
+ .filter((step) => step.flow?.nodeKind === 'condition')
86
+ .forEach((step) => conditionLayout(step.id));
87
+ return layouts;
88
+ }
package/src/style.css CHANGED
@@ -192,7 +192,7 @@ select {
192
192
  .appium-recorder-layout {
193
193
  display: grid;
194
194
  flex: 1 1 auto;
195
- grid-template-columns: minmax(0, 3fr) minmax(320px, 3fr) minmax(0, 4fr);
195
+ grid-template-columns: minmax(0, 3fr) minmax(320px, 2.8fr) minmax(0, 4.2fr);
196
196
  gap: 12px;
197
197
  align-items: stretch;
198
198
  height: auto;
@@ -662,10 +662,22 @@ select {
662
662
 
663
663
  .appium-flow-toolbar {
664
664
  display: flex;
665
+ align-items: center;
665
666
  justify-content: flex-end;
667
+ gap: 6px;
666
668
  margin-bottom: 6px;
667
669
  }
668
670
 
671
+ .appium-flow-toolbar .el-button + .el-button {
672
+ margin-left: 0;
673
+ }
674
+
675
+ .appium-flow-toolbar__status {
676
+ margin-right: 2px;
677
+ color: #64748b;
678
+ font-size: 12px;
679
+ }
680
+
669
681
  .appium-flow-canvas {
670
682
  position: relative;
671
683
  height: 460px;
@@ -695,7 +707,8 @@ select {
695
707
  display: grid;
696
708
  gap: 8px;
697
709
  align-content: start;
698
- width: max(760px, 100%);
710
+ width: max-content;
711
+ min-width: max(980px, 100%);
699
712
  min-width: 0;
700
713
  padding: 10px 12px 14px;
701
714
  transform-origin: 0 0;
@@ -725,7 +738,8 @@ select {
725
738
  }
726
739
 
727
740
  .appium-flow-content--overview {
728
- width: min(980px, 100%);
741
+ width: max-content;
742
+ min-width: max(980px, 100%);
729
743
  margin: 0 auto;
730
744
  transform-origin: 0 0;
731
745
  }
@@ -836,8 +850,7 @@ select {
836
850
 
837
851
  .appium-flow-node__toggle {
838
852
  display: grid;
839
- grid-template-columns: 24px minmax(0, 1fr);
840
- gap: 6px;
853
+ grid-template-columns: minmax(0, 1fr);
841
854
  align-items: center;
842
855
  min-width: 0;
843
856
  padding: 0;
@@ -852,7 +865,8 @@ select {
852
865
  .appium-flow-item > .appium-flow-node,
853
866
  .appium-flow-item > .appium-flow-editor {
854
867
  box-sizing: border-box;
855
- width: min(440px, 100%);
868
+ width: 100%;
869
+ max-width: 420px;
856
870
  justify-self: center;
857
871
  left: calc(var(--flow-axis) - 50%);
858
872
  }
@@ -872,16 +886,14 @@ select {
872
886
  border-color: #409eff;
873
887
  }
874
888
 
875
- .appium-flow-node__index {
876
- display: grid;
877
- place-items: center;
878
- width: 22px;
879
- height: 22px;
880
- border-radius: 50%;
881
- background: #eef2ff;
882
- color: #475569;
883
- font-size: 11px;
884
- font-weight: 700;
889
+ .appium-flow-node--copying {
890
+ cursor: copy;
891
+ }
892
+
893
+ .appium-flow-node--selected {
894
+ border-color: #337ecc;
895
+ box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.32);
896
+ background: #d9ecff;
885
897
  }
886
898
 
887
899
  .appium-flow-node__body {
@@ -931,17 +943,36 @@ select {
931
943
  position: relative;
932
944
  z-index: 1;
933
945
  display: grid;
934
- grid-template-columns: repeat(2, minmax(300px, 1fr));
935
- gap: 0;
946
+ grid-template-columns: repeat(2, minmax(420px, 1fr));
947
+ column-gap: 16px;
948
+ width: max-content;
949
+ min-width: 100%;
950
+ justify-self: center;
936
951
  margin-left: 0;
937
952
  margin-right: 0;
938
953
  padding-top: 30px;
939
954
  }
940
955
 
956
+ .appium-flow-nested-condition {
957
+ position: relative;
958
+ z-index: 1;
959
+ width: max-content;
960
+ min-width: 856px;
961
+ justify-self: center;
962
+ background:
963
+ radial-gradient(circle at 1px 1px, #e7edf6 1px, transparent 0) 0 0 / 18px 18px,
964
+ #fff;
965
+ }
966
+
967
+ .appium-flow-nested-condition > .appium-flow-branches {
968
+ width: 100%;
969
+ }
970
+
941
971
  .appium-flow-item--condition > .appium-flow-editor {
942
972
  order: 1;
943
973
  box-sizing: border-box;
944
- width: min(440px, 100%);
974
+ width: 100%;
975
+ max-width: 420px;
945
976
  justify-self: center;
946
977
  }
947
978
 
@@ -978,8 +1009,9 @@ select {
978
1009
  align-content: start;
979
1010
  justify-self: center;
980
1011
  box-sizing: border-box;
981
- width: calc(100% - 32px);
982
- min-width: 0;
1012
+ width: 100%;
1013
+ max-width: none;
1014
+ min-width: 420px;
983
1015
  padding: 0;
984
1016
  color: #64748b;
985
1017
  font-size: 11px;
@@ -1033,14 +1065,23 @@ select {
1033
1065
  gap: 10px;
1034
1066
  justify-self: center;
1035
1067
  width: 100%;
1036
- min-width: 0;
1068
+ max-width: none;
1069
+ min-width: 420px;
1037
1070
  }
1038
1071
 
1039
1072
  .appium-flow-branch-step-group {
1040
1073
  display: grid;
1041
1074
  gap: 6px;
1042
1075
  width: 100%;
1043
- min-width: 0;
1076
+ max-width: none;
1077
+ min-width: 420px;
1078
+ }
1079
+
1080
+ .appium-flow-branch-step-group > .appium-flow-node,
1081
+ .appium-flow-branch-step-group > .appium-flow-editor {
1082
+ box-sizing: border-box;
1083
+ width: 420px;
1084
+ justify-self: center;
1044
1085
  }
1045
1086
 
1046
1087
  .appium-flow-branch-step {
@@ -1051,6 +1092,7 @@ select {
1051
1092
  gap: 6px;
1052
1093
  align-items: center;
1053
1094
  width: 100%;
1095
+ max-width: 420px;
1054
1096
  min-width: 0;
1055
1097
  padding: 6px 8px;
1056
1098
  border: 1px solid #d8e0ea;
@@ -1079,6 +1121,19 @@ select {
1079
1121
  justify-content: center;
1080
1122
  }
1081
1123
 
1124
+ .appium-flow-branch:has(
1125
+ .appium-flow-branch-step-group:last-child > .appium-flow-nested-condition
1126
+ ) > .appium-flow-branch__actions {
1127
+ background: #fff;
1128
+ box-shadow: 0 -10px 0 #fff;
1129
+ }
1130
+
1131
+ .appium-flow-branch:has(
1132
+ .appium-flow-branch-step-group:last-child > .appium-flow-nested-condition
1133
+ ) > .appium-flow-branch__actions > .el-dropdown {
1134
+ display: none;
1135
+ }
1136
+
1082
1137
  .appium-flow-branch__connect {
1083
1138
  height: 24px;
1084
1139
  padding: 0 8px;
@@ -1140,11 +1195,11 @@ select {
1140
1195
  stroke: var(--appium-flow-line-color);
1141
1196
  }
1142
1197
 
1143
- .appium-flow-branch--yes .appium-flow-branch-step {
1198
+ .appium-flow-branch--yes .appium-flow-branch-step:not(.appium-flow-node--selected) {
1144
1199
  border-color: #b3e19d;
1145
1200
  }
1146
1201
 
1147
- .appium-flow-branch--no .appium-flow-branch-step {
1202
+ .appium-flow-branch--no .appium-flow-branch-step:not(.appium-flow-node--selected) {
1148
1203
  border-color: #fab6b6;
1149
1204
  }
1150
1205
 
@@ -1164,13 +1219,6 @@ select {
1164
1219
  gap: 8px;
1165
1220
  }
1166
1221
 
1167
- .appium-flow-editor__actions {
1168
- display: flex;
1169
- flex-wrap: wrap;
1170
- gap: 8px;
1171
- justify-content: flex-end;
1172
- }
1173
-
1174
1222
  .appium-flow-insert {
1175
1223
  position: relative;
1176
1224
  z-index: 1;
@@ -1246,8 +1294,78 @@ select {
1246
1294
  }
1247
1295
 
1248
1296
  .appium-replay-output--dialog {
1249
- height: 72vh;
1250
- max-height: 72vh;
1297
+ box-sizing: border-box;
1298
+ height: 100%;
1299
+ min-height: 0;
1300
+ max-height: none;
1301
+ }
1302
+
1303
+ .appium-replay-dialog.el-dialog {
1304
+ display: flex;
1305
+ width: min(820px, calc(100vw - 32px)) !important;
1306
+ height: min(620px, calc(100vh - 64px));
1307
+ min-width: 420px;
1308
+ min-height: 280px;
1309
+ max-width: calc(100vw - 32px);
1310
+ max-height: calc(100vh - 32px);
1311
+ flex-direction: column;
1312
+ overflow: hidden;
1313
+ resize: both;
1314
+ }
1315
+
1316
+ .appium-replay-dialog > .el-dialog__header,
1317
+ .appium-replay-dialog > .el-dialog__footer {
1318
+ flex: 0 0 auto;
1319
+ }
1320
+
1321
+ .appium-replay-dialog > .el-dialog__body {
1322
+ flex: 1 1 auto;
1323
+ min-height: 0;
1324
+ overflow: hidden;
1325
+ padding-top: 8px;
1326
+ padding-bottom: 8px;
1327
+ }
1328
+
1329
+ .appium-replay-dialog .appium-replay-output {
1330
+ border-color: #263244;
1331
+ background: #101722;
1332
+ color: #d7e0ea;
1333
+ scrollbar-color: #526174 #101722;
1334
+ }
1335
+
1336
+ .appium-replay-dialog .appium-replay-output::-webkit-scrollbar {
1337
+ width: 10px;
1338
+ height: 10px;
1339
+ }
1340
+
1341
+ .appium-replay-dialog .appium-replay-output::-webkit-scrollbar-track {
1342
+ background: #101722;
1343
+ }
1344
+
1345
+ .appium-replay-dialog .appium-replay-output::-webkit-scrollbar-thumb {
1346
+ border: 2px solid #101722;
1347
+ border-radius: 6px;
1348
+ background: #526174;
1349
+ }
1350
+
1351
+ .appium-replay-fab {
1352
+ position: fixed;
1353
+ right: 28px;
1354
+ bottom: 28px;
1355
+ z-index: 50;
1356
+ }
1357
+
1358
+ .appium-replay-fab .el-button {
1359
+ width: 52px;
1360
+ height: 52px;
1361
+ box-shadow: 0 8px 24px rgba(64, 158, 255, 0.28);
1362
+ font-size: 20px;
1363
+ }
1364
+
1365
+ .appium-replay-fab__button--running {
1366
+ box-shadow:
1367
+ 0 0 0 5px rgba(230, 162, 60, 0.18),
1368
+ 0 8px 24px rgba(64, 158, 255, 0.28) !important;
1251
1369
  }
1252
1370
 
1253
1371
  .appium-navigation-dialog {
@@ -2628,6 +2746,17 @@ select {
2628
2746
  }
2629
2747
  }
2630
2748
 
2749
+ @media (max-width: 520px) {
2750
+ .appium-replay-dialog.el-dialog {
2751
+ min-width: calc(100vw - 24px);
2752
+ }
2753
+
2754
+ .appium-replay-fab {
2755
+ right: 16px;
2756
+ bottom: 16px;
2757
+ }
2758
+ }
2759
+
2631
2760
  @media (max-width: 1200px) {
2632
2761
  .layout {
2633
2762
  grid-template-columns: 1fr;