qube_rails 0.2.0

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.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +161 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +967 -0
  5. data/app/assets/javascripts/qube_rails/question.js +457 -0
  6. data/app/assets/stylesheets/qube_rails.css +1115 -0
  7. data/app/controllers/concerns/qube_rails/run_authorization.rb +37 -0
  8. data/app/controllers/qube_rails/connections_controller.rb +141 -0
  9. data/app/controllers/qube_rails/questions_controller.rb +132 -0
  10. data/app/controllers/qube_rails/runs_controller.rb +54 -0
  11. data/app/controllers/qube_rails/storyboard_controller.rb +50 -0
  12. data/app/controllers/qube_rails/webhooks_controller.rb +99 -0
  13. data/app/jobs/qube_rails/prune_runs_job.rb +18 -0
  14. data/app/jobs/qube_rails/refresh_job.rb +18 -0
  15. data/app/jobs/qube_rails/webhook_job.rb +43 -0
  16. data/app/models/qube_rails/application_record.rb +17 -0
  17. data/app/models/qube_rails/connection.rb +303 -0
  18. data/app/models/qube_rails/notification.rb +12 -0
  19. data/app/models/qube_rails/queued_request.rb +66 -0
  20. data/app/models/qube_rails/quickbooks_record.rb +36 -0
  21. data/app/models/qube_rails/request_delivery.rb +12 -0
  22. data/app/models/qube_rails/run.rb +187 -0
  23. data/app/models/qube_rails/sync_mark.rb +21 -0
  24. data/app/views/qube_rails/_error.html.erb +13 -0
  25. data/app/views/qube_rails/_question.html.erb +21 -0
  26. data/app/views/qube_rails/_run_status.html.erb +22 -0
  27. data/app/views/qube_rails/question/_choice.html.erb +20 -0
  28. data/app/views/qube_rails/question/_details.html.erb +6 -0
  29. data/app/views/qube_rails/question/_field.html.erb +15 -0
  30. data/app/views/qube_rails/question/_footer.html.erb +7 -0
  31. data/app/views/qube_rails/question/_message.html.erb +2 -0
  32. data/app/views/qube_rails/question/_pick.html.erb +19 -0
  33. data/app/views/qube_rails/question/_picks.html.erb +6 -0
  34. data/app/views/qube_rails/question/_question.html.erb +24 -0
  35. data/app/views/qube_rails/question/fields/_boolean.html.erb +7 -0
  36. data/app/views/qube_rails/question/fields/_number.html.erb +7 -0
  37. data/app/views/qube_rails/question/fields/_rows.html.erb +23 -0
  38. data/app/views/qube_rails/question/fields/_search.html.erb +23 -0
  39. data/app/views/qube_rails/question/fields/_select.html.erb +8 -0
  40. data/app/views/qube_rails/question/fields/_text.html.erb +7 -0
  41. data/app/views/qube_rails/question/rows/_cell.html.erb +24 -0
  42. data/app/views/qube_rails/question/rows/_row.html.erb +22 -0
  43. data/app/views/qube_rails/question/rows/_segmented.html.erb +14 -0
  44. data/app/views/qube_rails/run_status/_detail.html.erb +7 -0
  45. data/app/views/qube_rails/run_status/_redeliver.html.erb +5 -0
  46. data/app/views/qube_rails/run_status/_retry.html.erb +4 -0
  47. data/app/views/qube_rails/run_status/_stall.html.erb +3 -0
  48. data/app/views/qube_rails/run_status/_status.html.erb +21 -0
  49. data/app/views/qube_rails/run_status/_summary.html.erb +2 -0
  50. data/app/views/qube_rails/storyboard/show.html.erb +86 -0
  51. data/config/locales/en.yml +46 -0
  52. data/config/routes.rb +35 -0
  53. data/db/migrate/20261025000000_create_qube_tables.rb +83 -0
  54. data/db/migrate/20261026230000_add_qube_sync_status_indexes.rb +13 -0
  55. data/db/migrate/20261027000000_create_qube_sync_marks.rb +17 -0
  56. data/db/migrate/20261028000000_create_qube_requests.rb +38 -0
  57. data/lib/generators/qube_rails/install_generator.rb +47 -0
  58. data/lib/generators/qube_rails/templates/connection_events.rb.tt +38 -0
  59. data/lib/generators/qube_rails/templates/initializer.rb.tt +82 -0
  60. data/lib/generators/qube_rails/workflows_generator.rb +77 -0
  61. data/lib/qube_rails/backend.rb +70 -0
  62. data/lib/qube_rails/cache_dedupe.rb +34 -0
  63. data/lib/qube_rails/configuration.rb +319 -0
  64. data/lib/qube_rails/connection_events.rb +167 -0
  65. data/lib/qube_rails/database_dedupe.rb +28 -0
  66. data/lib/qube_rails/engine.rb +33 -0
  67. data/lib/qube_rails/json_coder.rb +43 -0
  68. data/lib/qube_rails/operation.rb +464 -0
  69. data/lib/qube_rails/question_view.rb +219 -0
  70. data/lib/qube_rails/request.rb +289 -0
  71. data/lib/qube_rails/rows_view.rb +146 -0
  72. data/lib/qube_rails/run_status_view.rb +71 -0
  73. data/lib/qube_rails/storyboard/ask_about_add.json +60 -0
  74. data/lib/qube_rails/storyboard/choose.json +51 -0
  75. data/lib/qube_rails/storyboard/enum_select.json +56 -0
  76. data/lib/qube_rails/storyboard/missing_record.json +141 -0
  77. data/lib/qube_rails/storyboard/name_clash.json +59 -0
  78. data/lib/qube_rails/storyboard/near_match.json +77 -0
  79. data/lib/qube_rails/storyboard/number_boolean.json +54 -0
  80. data/lib/qube_rails/storyboard/record_search.json +90 -0
  81. data/lib/qube_rails/storyboard/refusal.json +53 -0
  82. data/lib/qube_rails/storyboard/rows.json +359 -0
  83. data/lib/qube_rails/storyboard.rb +165 -0
  84. data/lib/qube_rails/sync_status.rb +124 -0
  85. data/lib/qube_rails/version.rb +5 -0
  86. data/lib/qube_rails/view_helpers.rb +29 -0
  87. data/lib/qube_rails.rb +201 -0
  88. metadata +180 -0
@@ -0,0 +1,1115 @@
1
+ /* qube_rails: opt-in looks for the QuickBooks screens (a question, a record's status, the
2
+ storyboard). Every selector is inside :where(), so it has no specificity and any rule of your
3
+ app's wins. Theme it with the --qube-* variables (README, "Styling"), on :root or any element
4
+ around the screens. No reset, no global styles: only qube- elements are touched.
5
+ Dark mode follows the page's color-scheme: with `color-scheme: light dark` on your page, the
6
+ accent, status colours and surfaces switch with prefers-color-scheme. */
7
+
8
+ :where(.qube-question, .qube-run-status, .qube-error, .qube-storyboard) {
9
+ --qube-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
10
+ --qube-accent-color: var(--qube-accent, #2563eb);
11
+ --qube-accent-text-color: var(--qube-accent-text, #fff);
12
+ --qube-border-color: var(--qube-border, color-mix(in srgb, currentColor 14%, transparent));
13
+ --qube-control-border-color: var(--qube-control-border, color-mix(in srgb, currentColor 24%, transparent));
14
+ --qube-tint-color: color-mix(in srgb, currentColor 4%, transparent);
15
+ --qube-radius-size: var(--qube-radius, 8px);
16
+ --qube-control-radius: calc(var(--qube-radius-size) - 2px);
17
+ --qube-gap-size: var(--qube-gap, 16px);
18
+ --qube-muted-color: var(--qube-muted, color-mix(in srgb, currentColor 62%, transparent));
19
+ --qube-error-color: var(--qube-error, #c2410c);
20
+ --qube-success-color: var(--qube-success, #15803d);
21
+ --qube-warning-color: var(--qube-warning, #d97706);
22
+ --qube-surface-color: var(--qube-surface, transparent);
23
+ --qube-solid-color: var(--qube-surface, Canvas);
24
+ font-family: var(--qube-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
25
+ font-size: var(--qube-font-size, 0.875rem);
26
+ line-height: 1.5;
27
+ }
28
+
29
+ @supports (color: light-dark(#000, #fff)) {
30
+ :where(.qube-question, .qube-run-status, .qube-error, .qube-storyboard) {
31
+ --qube-accent-color: var(--qube-accent, light-dark(#2563eb, #3b82f6));
32
+ --qube-error-color: var(--qube-error, light-dark(#c2410c, #fb923c));
33
+ --qube-success-color: var(--qube-success, light-dark(#15803d, #4ade80));
34
+ --qube-warning-color: var(--qube-warning, light-dark(#d97706, #fbbf24));
35
+ }
36
+ }
37
+
38
+ /* ---- The question card: the message first, then what it's about, then the choices */
39
+
40
+ :where(.qube-question) {
41
+ display: grid;
42
+ gap: var(--qube-gap-size);
43
+ max-width: var(--qube-max-width, 52rem);
44
+ padding: clamp(16px, 3vw, 24px);
45
+ border: 1px solid var(--qube-border-color);
46
+ border-radius: var(--qube-radius-size);
47
+ background: var(--qube-surface-color);
48
+ box-sizing: border-box;
49
+ }
50
+
51
+ :where(.qube-question-message) {
52
+ max-width: 65ch;
53
+ margin: 0;
54
+ font-size: 1.1429em;
55
+ font-weight: 600;
56
+ line-height: 1.45;
57
+ text-wrap: pretty;
58
+ }
59
+
60
+ /* What it's about: a quiet two-column key/value list */
61
+ :where(.qube-question-details) {
62
+ justify-self: start;
63
+ border-collapse: collapse;
64
+ font-size: 0.9286em;
65
+ }
66
+
67
+ :where(.qube-question-details th) {
68
+ padding: 2px 24px 2px 0;
69
+ font-weight: 400;
70
+ text-align: left;
71
+ vertical-align: top;
72
+ white-space: nowrap;
73
+ color: var(--qube-muted-color);
74
+ }
75
+
76
+ :where(.qube-question-details td) {
77
+ padding: 2px 0;
78
+ font-variant-numeric: tabular-nums;
79
+ }
80
+
81
+ /* ---- Choices, by kind: picks as option rows, each form choice its own section ("or" between),
82
+ exits in a footer row. At most one filled button: a form's submit, or in a plain question the
83
+ first choice. */
84
+
85
+ :where(.qube-choices) {
86
+ display: grid;
87
+ gap: 16px;
88
+ padding-top: var(--qube-gap-size);
89
+ border-top: 1px solid var(--qube-border-color);
90
+ }
91
+
92
+ :where(.qube-choice) {
93
+ display: grid;
94
+ gap: 12px;
95
+ margin: 0;
96
+ }
97
+
98
+ /* A pick: a quiet, full-width option row that says what clicking it does */
99
+ :where(.qube-picks) {
100
+ display: grid;
101
+ gap: 8px;
102
+ }
103
+
104
+ :where(.qube-pick) {
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: space-between;
108
+ gap: 16px;
109
+ width: 100%;
110
+ min-height: 48px;
111
+ padding: 10px 14px;
112
+ font: inherit;
113
+ text-align: left;
114
+ color: inherit;
115
+ background: var(--qube-solid-color);
116
+ border: 1px solid var(--qube-control-border-color);
117
+ border-radius: var(--qube-control-radius);
118
+ cursor: pointer;
119
+ user-select: none;
120
+ touch-action: manipulation;
121
+ -webkit-tap-highlight-color: transparent;
122
+ transition: transform 160ms var(--qube-ease-out), background-color 150ms ease, border-color 150ms ease;
123
+ }
124
+
125
+ :where(.qube-pick-action) {
126
+ flex-shrink: 0;
127
+ font-size: 0.9286em;
128
+ font-weight: 500;
129
+ color: var(--qube-accent-color);
130
+ white-space: nowrap;
131
+ }
132
+
133
+ :where(.qube-pick:active) {
134
+ transform: scale(0.99);
135
+ }
136
+
137
+ :where(.qube-pick-text) {
138
+ display: flex;
139
+ flex-wrap: wrap;
140
+ align-items: baseline;
141
+ gap: 2px 10px;
142
+ min-width: 0;
143
+ }
144
+
145
+ :where(.qube-pick-label) {
146
+ font-weight: 500;
147
+ }
148
+
149
+ :where(.qube-pick-detail) {
150
+ color: var(--qube-muted-color);
151
+ }
152
+
153
+ :where(.qube-pick-description) {
154
+ flex-basis: 100%;
155
+ font-size: 0.9286em;
156
+ color: var(--qube-muted-color);
157
+ }
158
+
159
+ @media (hover: hover) and (pointer: fine) {
160
+ :where(.qube-pick:hover) {
161
+ background: color-mix(in srgb, currentColor 4%, var(--qube-solid-color));
162
+ border-color: color-mix(in srgb, currentColor 34%, transparent);
163
+ }
164
+ }
165
+
166
+ /* "or" between picks and a form, or between forms */
167
+ :where(.qube-choice-divider) {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 12px;
171
+ font-size: 0.8571em;
172
+ color: var(--qube-muted-color);
173
+ }
174
+
175
+ :where(.qube-choice-divider)::before,
176
+ :where(.qube-choice-divider)::after {
177
+ content: "";
178
+ flex: 1;
179
+ border-top: 1px solid var(--qube-border-color);
180
+ }
181
+
182
+ /* A form choice: its field's label leads, its submit under it */
183
+ :where(.qube-choice-form .qube-label) {
184
+ font-size: 1em;
185
+ }
186
+
187
+ /* The exits: one row under a hairline (no hairline when they're all there is) */
188
+ :where(.qube-choice-footer) {
189
+ display: flex;
190
+ flex-wrap: wrap;
191
+ align-items: flex-start;
192
+ gap: 8px;
193
+ }
194
+
195
+ :where(.qube-picks, .qube-choice-form) + :where(.qube-choice-footer) {
196
+ padding-top: 16px;
197
+ border-top: 1px solid var(--qube-border-color);
198
+ }
199
+
200
+ :where(.qube-choice-footer .qube-choice:has(.qube-choice-description)) {
201
+ flex-basis: 100%;
202
+ }
203
+
204
+ :where(.qube-choice-button, .qube-retry-start, .qube-redeliver, .qube-set-all-button, .qube-storyboard-copy) {
205
+ display: inline-flex;
206
+ align-items: center;
207
+ justify-self: start;
208
+ gap: 8px;
209
+ min-height: 40px;
210
+ padding: 0 14px;
211
+ font: inherit;
212
+ font-weight: 500;
213
+ line-height: 1.25;
214
+ text-align: left;
215
+ color: inherit;
216
+ background: var(--qube-solid-color);
217
+ border: 1px solid var(--qube-control-border-color);
218
+ border-radius: var(--qube-control-radius);
219
+ cursor: pointer;
220
+ user-select: none;
221
+ touch-action: manipulation;
222
+ -webkit-tap-highlight-color: transparent;
223
+ transition:
224
+ transform 160ms var(--qube-ease-out),
225
+ background-color 150ms ease,
226
+ border-color 150ms ease,
227
+ color 150ms ease,
228
+ opacity 150ms ease;
229
+ }
230
+
231
+ /* Pressing gives way a little */
232
+ :where(.qube-choice-button, .qube-retry-start, .qube-redeliver, .qube-set-all-button, .qube-storyboard-copy):where(:active:not(:disabled)) {
233
+ transform: scale(0.97);
234
+ }
235
+
236
+ :where(.qube-choice-button, .qube-retry-start, .qube-redeliver, .qube-set-all-button):where(:disabled) {
237
+ opacity: 0.45;
238
+ cursor: not-allowed;
239
+ }
240
+
241
+ /* The one primary action, and the retry */
242
+ :where(.qube-choice-button[data-qube-button="primary"], .qube-retry-start) {
243
+ color: var(--qube-accent-text-color);
244
+ background: var(--qube-accent-color);
245
+ border-color: var(--qube-accent-color);
246
+ }
247
+
248
+ /* Cancel: no box until hovered */
249
+ :where(.qube-choice-button[data-qube-button="ghost"]) {
250
+ color: var(--qube-muted-color);
251
+ background: transparent;
252
+ border-color: transparent;
253
+ }
254
+
255
+ @media (hover: hover) and (pointer: fine) {
256
+ :where(.qube-choice-button, .qube-redeliver, .qube-set-all-button, .qube-storyboard-copy):where(:hover:not(:disabled)) {
257
+ background: color-mix(in srgb, currentColor 6%, var(--qube-solid-color));
258
+ }
259
+
260
+ :where(.qube-choice-button[data-qube-button="primary"], .qube-retry-start):where(:hover:not(:disabled)) {
261
+ background: color-mix(in srgb, var(--qube-accent-color) 88%, black);
262
+ border-color: color-mix(in srgb, var(--qube-accent-color) 88%, black);
263
+ }
264
+
265
+ :where(.qube-choice-button[data-qube-button="ghost"]):where(:hover) {
266
+ color: inherit;
267
+ background: var(--qube-tint-color);
268
+ }
269
+ }
270
+
271
+ :where(.qube-choice-detail) {
272
+ font-weight: 400;
273
+ opacity: 0.72;
274
+ }
275
+
276
+ :where(.qube-choice-description) {
277
+ max-width: 65ch;
278
+ margin: -4px 0 0;
279
+ font-size: 0.9286em;
280
+ color: var(--qube-muted-color);
281
+ }
282
+
283
+ /* ---- Focus: always visible from the keyboard, never removed */
284
+
285
+ :where(.qube-question, .qube-run-status, .qube-storyboard) :where(:focus-visible) {
286
+ outline: 2px solid var(--qube-accent-color);
287
+ outline-offset: 2px;
288
+ }
289
+
290
+ /* ---- Fields */
291
+
292
+ :where(.qube-field) {
293
+ display: grid;
294
+ gap: 6px;
295
+ }
296
+
297
+ :where(.qube-field-boolean) {
298
+ display: flex;
299
+ align-items: center;
300
+ gap: 10px;
301
+ min-height: 40px;
302
+ }
303
+
304
+ :where(.qube-label, .qube-rows-label) {
305
+ margin: 0;
306
+ font-size: 0.9286em;
307
+ font-weight: 500;
308
+ }
309
+
310
+ :where(.qube-field-boolean .qube-label) {
311
+ font-size: 1em;
312
+ font-weight: 400;
313
+ cursor: pointer;
314
+ }
315
+
316
+ :where(.qube-input, .qube-select, .qube-search-input) {
317
+ box-sizing: border-box;
318
+ width: 100%;
319
+ max-width: 28rem;
320
+ min-width: 8rem;
321
+ min-height: 38px;
322
+ padding: 0 10px;
323
+ font: inherit;
324
+ color: inherit;
325
+ background: var(--qube-solid-color);
326
+ border: 1px solid var(--qube-control-border-color);
327
+ border-radius: var(--qube-control-radius);
328
+ transition: border-color 150ms ease;
329
+ }
330
+
331
+ :where(.qube-input, .qube-select, .qube-search-input):where(:focus) {
332
+ border-color: var(--qube-accent-color);
333
+ }
334
+
335
+ :where(.qube-input, .qube-select, .qube-search-input):where(:user-invalid) {
336
+ border-color: var(--qube-error-color);
337
+ }
338
+
339
+ :where(.qube-input, .qube-select, .qube-search-input):where(:disabled) {
340
+ opacity: 0.5;
341
+ }
342
+
343
+ :where(.qube-checkbox) {
344
+ width: 16px;
345
+ height: 16px;
346
+ margin: 0;
347
+ accent-color: var(--qube-accent-color);
348
+ cursor: pointer;
349
+ }
350
+
351
+ /* ---- The search box: a combobox whose matches drop down from the input */
352
+
353
+ :where(.qube-search) {
354
+ position: relative;
355
+ max-width: 28rem;
356
+ }
357
+
358
+ :where(.qube-search-results) {
359
+ position: absolute;
360
+ top: calc(100% + 4px);
361
+ left: 0;
362
+ right: 0;
363
+ z-index: 20;
364
+ display: grid;
365
+ gap: 1px;
366
+ max-height: 16rem;
367
+ overflow-y: auto;
368
+ padding: 4px;
369
+ background: var(--qube-solid-color);
370
+ border: 1px solid var(--qube-border-color);
371
+ border-radius: var(--qube-radius-size);
372
+ box-shadow: 0 12px 28px -12px rgb(0 0 0 / 0.25), 0 2px 6px -2px rgb(0 0 0 / 0.08);
373
+ transform-origin: top center;
374
+ transition: opacity 150ms var(--qube-ease-out), transform 150ms var(--qube-ease-out);
375
+ }
376
+
377
+ /* Enters from the input; closes at once */
378
+ @starting-style {
379
+ :where(.qube-search-results) {
380
+ opacity: 0;
381
+ transform: translateY(-4px) scale(0.98);
382
+ }
383
+ }
384
+
385
+ :where(.qube-search-results:empty) {
386
+ display: none;
387
+ }
388
+
389
+ :where(.qube-search-result) {
390
+ display: grid;
391
+ gap: 2px;
392
+ min-height: 36px;
393
+ padding: 7px 10px;
394
+ font: inherit;
395
+ text-align: left;
396
+ color: inherit;
397
+ background: transparent;
398
+ border: 0;
399
+ border-radius: calc(var(--qube-radius-size) - 4px);
400
+ cursor: pointer;
401
+ }
402
+
403
+ /* The highlighted match: instant, from the keyboard or the pointer */
404
+ :where(.qube-search-result[aria-selected="true"]) {
405
+ background: color-mix(in srgb, var(--qube-accent-color) 12%, transparent);
406
+ }
407
+
408
+ :where(.qube-search-result [data-qube-search-detail]) {
409
+ overflow-wrap: anywhere;
410
+ font-size: 0.9286em;
411
+ color: var(--qube-muted-color);
412
+ }
413
+
414
+ :where(.qube-search-results [data-qube-search-note]) {
415
+ padding: 7px 10px;
416
+ color: var(--qube-muted-color);
417
+ }
418
+
419
+ /* ---- A rows field: a block per record, its fields stacked. Nothing scrolls sideways: each
420
+ record's name sits beside its fields when there's room, above them when there isn't. */
421
+
422
+ :where(.qube-rows-field) {
423
+ gap: 12px;
424
+ }
425
+
426
+ :where(.qube-rows-set-all) {
427
+ display: flex;
428
+ flex-wrap: wrap;
429
+ align-items: center;
430
+ gap: 8px;
431
+ font-size: 0.9286em;
432
+ }
433
+
434
+ :where(.qube-rows-set-all-label) {
435
+ color: var(--qube-muted-color);
436
+ }
437
+
438
+ :where(.qube-set-all-button) {
439
+ min-height: 36px;
440
+ padding: 0 12px;
441
+ }
442
+
443
+ :where(.qube-rows) {
444
+ display: grid;
445
+ container-type: inline-size;
446
+ border-top: 1px solid var(--qube-border-color);
447
+ }
448
+
449
+ :where(.qube-row) {
450
+ display: grid;
451
+ gap: 12px;
452
+ padding: 16px 0;
453
+ border-bottom: 1px solid var(--qube-border-color);
454
+ }
455
+
456
+ :where(.qube-row-header) {
457
+ display: flex;
458
+ flex-wrap: wrap;
459
+ align-items: baseline;
460
+ gap: 2px 8px;
461
+ min-width: 0;
462
+ }
463
+
464
+ :where(.qube-row-label) {
465
+ font-weight: 500;
466
+ }
467
+
468
+ :where(.qube-row-detail) {
469
+ color: var(--qube-muted-color);
470
+ }
471
+
472
+ :where(.qube-row-body) {
473
+ display: grid;
474
+ gap: 12px;
475
+ min-width: 0;
476
+ }
477
+
478
+ :where(.qube-row-fields) {
479
+ display: grid;
480
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr));
481
+ gap: 12px 16px;
482
+ }
483
+
484
+ /* Nothing to show yet (no choice made): no gap for it */
485
+ :where(.qube-row-fields:not(:has(> .qube-row-cell:not([hidden])))) {
486
+ display: none;
487
+ }
488
+
489
+ @container (min-width: 36rem) {
490
+ :where(.qube-row) {
491
+ grid-template-columns: 12rem minmax(0, 1fr);
492
+ gap: 12px 24px;
493
+ align-items: start;
494
+ }
495
+
496
+ :where(.qube-row-header) {
497
+ flex-direction: column;
498
+ padding-top: 9px;
499
+ }
500
+ }
501
+
502
+ :where(.qube-row .qube-label) {
503
+ font-size: 0.9286em;
504
+ }
505
+
506
+ :where(.qube-row-fields) :where(.qube-input, .qube-select, .qube-search-input, .qube-search) {
507
+ max-width: none;
508
+ min-width: 0;
509
+ }
510
+
511
+ /* A field that starts to apply fades in; one that stops is gone at once (hidden) */
512
+ :where(.qube-row-cell) {
513
+ min-width: 0;
514
+ transition: opacity 150ms var(--qube-ease-out);
515
+ }
516
+
517
+ @starting-style {
518
+ :where(.qube-row-cell) {
519
+ opacity: 0;
520
+ }
521
+ }
522
+
523
+ /* What the record already has: read-only */
524
+ :where(.qube-row-preset) {
525
+ gap: 6px;
526
+ }
527
+
528
+ :where(.qube-row-preset-value) {
529
+ display: flex;
530
+ align-items: center;
531
+ min-height: 38px;
532
+ }
533
+
534
+ /* The record's choice (create it, or use one QuickBooks has): a segmented control */
535
+ :where(.qube-segmented-field) {
536
+ min-width: 0;
537
+ margin: 0;
538
+ padding: 0;
539
+ border: 0;
540
+ }
541
+
542
+ :where(.qube-visually-hidden) {
543
+ position: absolute;
544
+ width: 1px;
545
+ height: 1px;
546
+ overflow: hidden;
547
+ clip-path: inset(50%);
548
+ white-space: nowrap;
549
+ }
550
+
551
+ :where(.qube-segmented) {
552
+ display: flex;
553
+ flex-wrap: wrap;
554
+ gap: 2px;
555
+ width: fit-content;
556
+ max-width: 100%;
557
+ padding: 3px;
558
+ border-radius: calc(var(--qube-control-radius) + 2px);
559
+ background: color-mix(in srgb, currentColor 7%, transparent);
560
+ box-sizing: border-box;
561
+ }
562
+
563
+ :where(.qube-segment) {
564
+ position: relative;
565
+ display: inline-flex;
566
+ flex: 1 1 auto;
567
+ align-items: center;
568
+ justify-content: center;
569
+ min-height: 36px;
570
+ padding: 0 14px;
571
+ font-weight: 500;
572
+ color: var(--qube-muted-color);
573
+ border-radius: var(--qube-control-radius);
574
+ cursor: pointer;
575
+ user-select: none;
576
+ -webkit-tap-highlight-color: transparent;
577
+ transition:
578
+ transform 160ms var(--qube-ease-out),
579
+ background-color 150ms ease,
580
+ color 150ms ease,
581
+ box-shadow 150ms ease;
582
+ }
583
+
584
+ :where(.qube-segment-input) {
585
+ position: absolute;
586
+ inset: 0;
587
+ width: 100%;
588
+ height: 100%;
589
+ margin: 0;
590
+ opacity: 0;
591
+ cursor: pointer;
592
+ }
593
+
594
+ :where(.qube-segment:has(:checked)) {
595
+ color: inherit;
596
+ background: var(--qube-solid-color);
597
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.12), 0 0 0 1px var(--qube-border-color);
598
+ }
599
+
600
+ :where(.qube-segment:active) {
601
+ transform: scale(0.97);
602
+ }
603
+
604
+ :where(.qube-segment:has(:focus-visible)) {
605
+ outline: 2px solid var(--qube-accent-color);
606
+ outline-offset: 2px;
607
+ }
608
+
609
+ :where(.qube-segment:has(:disabled)) {
610
+ opacity: 0.5;
611
+ cursor: not-allowed;
612
+ }
613
+
614
+ @media (hover: hover) and (pointer: fine) {
615
+ :where(.qube-segment:hover) {
616
+ color: inherit;
617
+ }
618
+ }
619
+
620
+ @container (max-width: 24rem) {
621
+ :where(.qube-segmented) {
622
+ width: 100%;
623
+ }
624
+ }
625
+
626
+ /* "Use for all N": a text button under a filled field */
627
+ :where(.qube-use-for-all) {
628
+ display: inline-flex;
629
+ align-items: center;
630
+ min-height: 32px;
631
+ margin-top: 2px;
632
+ padding: 0;
633
+ font: inherit;
634
+ font-size: 0.9286em;
635
+ font-weight: 500;
636
+ color: var(--qube-accent-color);
637
+ background: none;
638
+ border: 0;
639
+ cursor: pointer;
640
+ transition: transform 160ms var(--qube-ease-out);
641
+ }
642
+
643
+ :where(.qube-use-for-all[hidden]) {
644
+ display: none;
645
+ }
646
+
647
+ :where(.qube-use-for-all:active) {
648
+ transform: scale(0.97);
649
+ }
650
+
651
+ @media (hover: hover) and (pointer: fine) {
652
+ :where(.qube-use-for-all:hover) {
653
+ text-decoration: underline;
654
+ text-underline-offset: 3px;
655
+ }
656
+ }
657
+
658
+ /* What "Set all to" or "Use for all" just filled: a tint that fades out */
659
+ @keyframes qube-filled {
660
+ from {
661
+ background-color: color-mix(in srgb, var(--qube-accent-color) 22%, var(--qube-solid-color));
662
+ }
663
+ }
664
+
665
+ :where([data-qube-filled]) :where(.qube-input, .qube-select, .qube-search-input, .qube-segmented) {
666
+ animation: qube-filled 600ms ease-out;
667
+ }
668
+
669
+ /* ---- Status: a compact pill with a coloured dot */
670
+
671
+ :where(.qube-run-status) {
672
+ display: grid;
673
+ justify-items: start;
674
+ gap: 8px;
675
+ }
676
+
677
+ :where(.qube-run-summary) {
678
+ --qube-dot-color: var(--qube-accent-color);
679
+ display: inline-flex;
680
+ align-items: flex-start;
681
+ gap: 8px;
682
+ max-width: 100%;
683
+ margin: 0;
684
+ padding: 3px 12px 3px 10px;
685
+ font-weight: 500;
686
+ border: 1px solid var(--qube-border-color);
687
+ border-radius: 14px;
688
+ box-sizing: border-box;
689
+ background: var(--qube-tint-color);
690
+ }
691
+
692
+ :where(.qube-run-summary)::before {
693
+ content: "";
694
+ flex-shrink: 0;
695
+ width: 8px;
696
+ height: 8px;
697
+ margin-top: calc(0.75em - 4px);
698
+ border-radius: 50%;
699
+ background: var(--qube-dot-color);
700
+ }
701
+
702
+ :where(.qube-sync-needs_answer .qube-run-summary, .qube-connection-stalled .qube-run-summary) {
703
+ --qube-dot-color: var(--qube-warning-color);
704
+ }
705
+
706
+ :where(.qube-sync-in_quickbooks .qube-run-summary, .qube-sync-done .qube-run-summary) {
707
+ --qube-dot-color: var(--qube-success-color);
708
+ }
709
+
710
+ :where(.qube-sync-failed .qube-run-summary) {
711
+ --qube-dot-color: var(--qube-error-color);
712
+ }
713
+
714
+ :where(.qube-sync-cancelled .qube-run-summary) {
715
+ --qube-dot-color: var(--qube-muted-color);
716
+ }
717
+
718
+ :where(.qube-run-reason) {
719
+ max-width: 65ch;
720
+ margin: 0;
721
+ }
722
+
723
+ :where(.qube-run-error-detail, .qube-run-quiet) {
724
+ max-width: 65ch;
725
+ margin: 0;
726
+ color: var(--qube-muted-color);
727
+ }
728
+
729
+ /* Why it waits: the connection stalled. Calm, like an error, in the warning colour */
730
+ :where(.qube-run-stall) {
731
+ max-width: 65ch;
732
+ margin: 0;
733
+ padding: 10px 14px;
734
+ border-radius: var(--qube-control-radius);
735
+ background: color-mix(in srgb, var(--qube-warning-color) 9%, transparent);
736
+ box-shadow: inset 3px 0 0 var(--qube-warning-color);
737
+ }
738
+
739
+ :where(.qube-retry-start-form, .qube-redeliver-form) {
740
+ margin: 0;
741
+ }
742
+
743
+ /* ---- A one-shot error: calm, inline, a coloured edge */
744
+
745
+ :where(.qube-error) {
746
+ max-width: 65ch;
747
+ margin: 0;
748
+ padding: 10px 14px;
749
+ border-radius: var(--qube-control-radius);
750
+ background: color-mix(in srgb, var(--qube-error-color) 8%, transparent);
751
+ box-shadow: inset 3px 0 0 var(--qube-error-color);
752
+ transition: opacity 200ms var(--qube-ease-out), transform 200ms var(--qube-ease-out);
753
+ }
754
+
755
+ @starting-style {
756
+ :where(.qube-error) {
757
+ opacity: 0;
758
+ transform: translateY(-4px);
759
+ }
760
+ }
761
+
762
+ /* ---- Narrow screens: choices stack, full width */
763
+
764
+ @media (max-width: 30rem) {
765
+ :where(.qube-choice-footer > .qube-choice) {
766
+ flex-basis: 100%;
767
+ }
768
+
769
+ :where(.qube-choice-button) {
770
+ justify-self: stretch;
771
+ }
772
+ }
773
+
774
+ /* ---- Reduced motion: fades stay, movement goes */
775
+
776
+ @media (prefers-reduced-motion: reduce) {
777
+ :where(.qube-choice-button, .qube-retry-start, .qube-redeliver, .qube-set-all-button, .qube-storyboard-copy, .qube-pick):where(:active) {
778
+ transform: none;
779
+ }
780
+
781
+ @starting-style {
782
+ :where(.qube-search-results, .qube-error) {
783
+ transform: none;
784
+ }
785
+ }
786
+
787
+ :where(.qube-segment, .qube-use-for-all):where(:active) {
788
+ transform: none;
789
+ }
790
+ }
791
+
792
+ /* ---- The storyboard (GET qube/storyboard): a gallery of every screen */
793
+
794
+ :where(.qube-storyboard) {
795
+ display: grid;
796
+ gap: 24px;
797
+ max-width: 76rem;
798
+ margin: 0 auto;
799
+ padding: 24px 16px 64px;
800
+ box-sizing: border-box;
801
+ }
802
+
803
+ :where(.qube-storyboard-header) {
804
+ display: flex;
805
+ flex-wrap: wrap;
806
+ align-items: flex-end;
807
+ justify-content: space-between;
808
+ gap: 16px;
809
+ }
810
+
811
+ :where(.qube-storyboard-header h1) {
812
+ margin: 0 0 4px;
813
+ font-size: 1.5rem;
814
+ font-weight: 600;
815
+ line-height: 1.25;
816
+ letter-spacing: -0.01em;
817
+ }
818
+
819
+ :where(.qube-storyboard-note) {
820
+ max-width: 70ch;
821
+ margin: 0;
822
+ color: var(--qube-muted-color);
823
+ }
824
+
825
+ :where(.qube-storyboard-notice) {
826
+ margin: 0;
827
+ padding: 10px 14px;
828
+ border-radius: var(--qube-control-radius);
829
+ background: color-mix(in srgb, var(--qube-success-color) 10%, transparent);
830
+ box-shadow: inset 3px 0 0 var(--qube-success-color);
831
+ }
832
+
833
+ /* The canvas theme: App (as your page is), Light or Dark */
834
+ :where(.qube-storyboard-theme) {
835
+ display: inline-flex;
836
+ gap: 2px;
837
+ margin: 0;
838
+ padding: 2px;
839
+ border: 1px solid var(--qube-border-color);
840
+ border-radius: var(--qube-radius-size);
841
+ }
842
+
843
+ :where(.qube-storyboard-theme legend) {
844
+ position: absolute;
845
+ width: 1px;
846
+ height: 1px;
847
+ overflow: hidden;
848
+ clip-path: inset(50%);
849
+ }
850
+
851
+ :where(.qube-storyboard-theme label) {
852
+ position: relative;
853
+ display: inline-flex;
854
+ align-items: center;
855
+ min-height: 32px;
856
+ padding: 0 12px;
857
+ font-size: 0.9286em;
858
+ font-weight: 500;
859
+ color: var(--qube-muted-color);
860
+ border-radius: var(--qube-control-radius);
861
+ cursor: pointer;
862
+ transition: background-color 150ms ease, color 150ms ease;
863
+ }
864
+
865
+ :where(.qube-storyboard-theme input) {
866
+ position: absolute;
867
+ inset: 0;
868
+ margin: 0;
869
+ opacity: 0;
870
+ cursor: pointer;
871
+ }
872
+
873
+ :where(.qube-storyboard-theme label:has(:checked)) {
874
+ color: inherit;
875
+ background: color-mix(in srgb, currentColor 9%, transparent);
876
+ }
877
+
878
+ :where(.qube-storyboard-theme label:has(:focus-visible)) {
879
+ outline: 2px solid var(--qube-accent-color);
880
+ outline-offset: 2px;
881
+ }
882
+
883
+ :where(.qube-storyboard-body) {
884
+ display: grid;
885
+ gap: 24px;
886
+ }
887
+
888
+ /* The screens' list: a sticky strip on a narrow screen, a sticky sidebar on a wide one */
889
+ :where(.qube-storyboard-nav) {
890
+ position: sticky;
891
+ top: 0;
892
+ z-index: 5;
893
+ display: flex;
894
+ gap: 16px;
895
+ margin: 0 -16px;
896
+ padding: 8px 16px;
897
+ overflow-x: auto;
898
+ background: var(--qube-solid-color);
899
+ border-bottom: 1px solid var(--qube-border-color);
900
+ }
901
+
902
+ :where(.qube-storyboard-nav-group) {
903
+ display: flex;
904
+ align-items: center;
905
+ gap: 4px;
906
+ }
907
+
908
+ :where(.qube-storyboard-nav-title) {
909
+ margin: 0 4px 0 0;
910
+ font-size: 0.8571em;
911
+ font-weight: 600;
912
+ white-space: nowrap;
913
+ color: var(--qube-muted-color);
914
+ }
915
+
916
+ :where(.qube-storyboard-nav ul) {
917
+ display: flex;
918
+ gap: 2px;
919
+ margin: 0;
920
+ padding: 0;
921
+ list-style: none;
922
+ }
923
+
924
+ :where(.qube-storyboard-nav a) {
925
+ display: block;
926
+ padding: 6px 10px;
927
+ white-space: nowrap;
928
+ color: inherit;
929
+ text-decoration: none;
930
+ border-radius: var(--qube-control-radius);
931
+ }
932
+
933
+ @media (hover: hover) and (pointer: fine) {
934
+ :where(.qube-storyboard-nav a:hover) {
935
+ background: color-mix(in srgb, currentColor 6%, transparent);
936
+ }
937
+ }
938
+
939
+ :where(.qube-storyboard-nav a[aria-current]) {
940
+ font-weight: 500;
941
+ background: color-mix(in srgb, currentColor 8%, transparent);
942
+ }
943
+
944
+ @media (min-width: 60rem) {
945
+ :where(.qube-storyboard-body) {
946
+ grid-template-columns: 13rem minmax(0, 1fr);
947
+ gap: 40px;
948
+ align-items: start;
949
+ }
950
+
951
+ :where(.qube-storyboard-nav) {
952
+ top: 16px;
953
+ flex-direction: column;
954
+ gap: 20px;
955
+ max-height: calc(100vh - 32px);
956
+ margin: 0;
957
+ padding: 0;
958
+ overflow-x: hidden;
959
+ overflow-y: auto;
960
+ scrollbar-width: thin;
961
+ background: none;
962
+ border: 0;
963
+ }
964
+
965
+ :where(.qube-storyboard-nav-group) {
966
+ flex-direction: column;
967
+ align-items: stretch;
968
+ }
969
+
970
+ :where(.qube-storyboard-nav-title) {
971
+ margin: 0 0 4px 10px;
972
+ }
973
+
974
+ :where(.qube-storyboard-nav ul) {
975
+ flex-direction: column;
976
+ }
977
+
978
+ :where(.qube-storyboard-nav a) {
979
+ white-space: normal;
980
+ }
981
+ }
982
+
983
+ :where(.qube-storyboard-screens) {
984
+ display: grid;
985
+ gap: 24px;
986
+ min-width: 0;
987
+ }
988
+
989
+ :where(.qube-storyboard-panel) {
990
+ display: grid;
991
+ gap: 12px;
992
+ min-width: 0;
993
+ padding-top: 24px;
994
+ border-top: 1px solid var(--qube-border-color);
995
+ scroll-margin-top: 72px;
996
+ }
997
+
998
+ :where(.qube-storyboard-panel:first-child) {
999
+ padding-top: 0;
1000
+ border-top: 0;
1001
+ }
1002
+
1003
+ :where(.qube-storyboard-panel-head) {
1004
+ display: flex;
1005
+ flex-wrap: wrap;
1006
+ align-items: baseline;
1007
+ gap: 4px 12px;
1008
+ }
1009
+
1010
+ :where(.qube-storyboard-panel h2) {
1011
+ margin: 0;
1012
+ font-size: 1.1429em;
1013
+ font-weight: 600;
1014
+ }
1015
+
1016
+ :where(.qube-storyboard-panel h2 a) {
1017
+ color: inherit;
1018
+ text-decoration: none;
1019
+ }
1020
+
1021
+ :where(.qube-storyboard-panel h2 a)::after {
1022
+ content: " #";
1023
+ color: var(--qube-muted-color);
1024
+ opacity: 0;
1025
+ transition: opacity 150ms ease;
1026
+ }
1027
+
1028
+ :where(.qube-storyboard-panel h2 a:hover, .qube-storyboard-panel h2 a:focus-visible)::after {
1029
+ opacity: 1;
1030
+ }
1031
+
1032
+ :where(.qube-storyboard-group) {
1033
+ font-size: 0.8571em;
1034
+ color: var(--qube-muted-color);
1035
+ }
1036
+
1037
+ :where(.qube-storyboard-open) {
1038
+ margin-left: auto;
1039
+ font-size: 0.9286em;
1040
+ color: var(--qube-muted-color);
1041
+ }
1042
+
1043
+ :where(.qube-storyboard-panel > .qube-storyboard-note) {
1044
+ margin-top: -6px;
1045
+ }
1046
+
1047
+ /* Each screen on a neutral canvas */
1048
+ :where(.qube-storyboard-canvas) {
1049
+ display: grid;
1050
+ padding: clamp(20px, 5vw, 48px) clamp(16px, 5vw, 48px);
1051
+ color: inherit;
1052
+ background: color-mix(in srgb, currentColor 2.5%, transparent);
1053
+ }
1054
+
1055
+ :where(.qube-storyboard:has([data-qube-canvas-theme="light"]:checked) .qube-storyboard-canvas) {
1056
+ color-scheme: light;
1057
+ color: #18181b;
1058
+ background: #f6f6f7;
1059
+ }
1060
+
1061
+ :where(.qube-storyboard:has([data-qube-canvas-theme="dark"]:checked) .qube-storyboard-canvas) {
1062
+ color-scheme: dark;
1063
+ color: #ececee;
1064
+ background: #111113;
1065
+ }
1066
+
1067
+ :where(.qube-storyboard-empty) {
1068
+ margin: 0;
1069
+ color: var(--qube-muted-color);
1070
+ }
1071
+
1072
+ :where(.qube-storyboard-files) {
1073
+ font-size: 0.9286em;
1074
+ }
1075
+
1076
+ :where(.qube-storyboard-files summary) {
1077
+ width: fit-content;
1078
+ padding: 6px 0;
1079
+ color: var(--qube-muted-color);
1080
+ cursor: pointer;
1081
+ }
1082
+
1083
+ :where(.qube-storyboard-files ul) {
1084
+ display: grid;
1085
+ gap: 4px;
1086
+ margin: 4px 0 0;
1087
+ padding: 0;
1088
+ list-style: none;
1089
+ }
1090
+
1091
+ :where(.qube-storyboard-files li) {
1092
+ display: flex;
1093
+ align-items: center;
1094
+ gap: 8px;
1095
+ min-width: 0;
1096
+ }
1097
+
1098
+ :where(.qube-storyboard-files code) {
1099
+ min-width: 0;
1100
+ overflow-x: auto;
1101
+ padding: 4px 8px;
1102
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1103
+ font-size: 0.9231em;
1104
+ white-space: nowrap;
1105
+ border-radius: var(--qube-control-radius);
1106
+ background: var(--qube-tint-color);
1107
+ user-select: all;
1108
+ }
1109
+
1110
+ :where(.qube-storyboard-copy) {
1111
+ flex-shrink: 0;
1112
+ min-height: 28px;
1113
+ padding: 0 10px;
1114
+ font-size: 0.9231em;
1115
+ }