@specbook/ui 0.1.0 → 0.3.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.
- package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
- package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
- package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
- package/dist-viewer/assets/mount-DfwluJy4.js +2 -0
- package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
- package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
- package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
- package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
- package/dist-viewer/index.html +4 -4
- package/dist-viewer/specbook.js +1 -1
- package/package.json +7 -5
- package/src/app.tsx +71 -13
- package/src/branding.ts +12 -4
- package/src/components/Catalog.tsx +128 -51
- package/src/components/EnvBadge.tsx +24 -4
- package/src/components/EnvironmentMenu.tsx +237 -127
- package/src/components/Fields.tsx +18 -1
- package/src/components/NewEnvironmentDialog.tsx +118 -0
- package/src/components/Palette.tsx +7 -2
- package/src/custom-environments.ts +63 -0
- package/src/embed/panel.tsx +287 -44
- package/src/embed/styles.ts +2 -0
- package/src/embed/target.ts +82 -0
- package/src/embed/theme.ts +75 -0
- package/src/embed.ts +44 -15
- package/src/engine.ts +416 -32
- package/src/environment-list.ts +16 -0
- package/src/main.tsx +14 -1
- package/src/nav.ts +12 -1
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +195 -0
- package/src/panel/Body.tsx +27 -4
- package/src/panel/Checks.tsx +79 -0
- package/src/panel/CodeSample.tsx +16 -8
- package/src/panel/Combo.tsx +52 -13
- package/src/panel/ControlsGrid.tsx +54 -9
- package/src/panel/ErrorBoundary.tsx +36 -0
- package/src/panel/Host.tsx +14 -2
- package/src/panel/OAuthSignIn.tsx +167 -0
- package/src/panel/Panel.tsx +50 -13
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +93 -36
- package/src/panel/RequestView.tsx +2 -1
- package/src/panel/ResponseView.tsx +25 -14
- package/src/panel/SecretsBar.tsx +43 -9
- package/src/panel/SequenceDocs.tsx +7 -6
- package/src/panel/SequencePanel.tsx +124 -32
- package/src/panel/announce.ts +17 -0
- package/src/panel/icons.tsx +9 -0
- package/src/panel/short.ts +24 -0
- package/src/routing.ts +0 -9
- package/src/runtime.ts +19 -1
- package/src/secrets.ts +4 -2
- package/src/storage.ts +58 -0
- package/src/store.ts +10 -0
- package/src/styles/app.css +69 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +277 -2
- package/src/viewer/embed.ts +5 -0
- package/src/viewer/main.tsx +5 -1
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/protocol.ts +9 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +48 -3
- package/src/viewer/worker.ts +20 -7
- package/src/virtual.d.ts +8 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
- package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
- package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
- package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
- package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
- package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
- package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
- package/dist-viewer/assets/worker-Dw3Ps3hk.js +0 -181
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* How the panel sits on someone else's page. Unlayered on purpose: it must beat the app's layered rules (the full-screen
|
|
3
|
+
* phone sheet, the sheet from the right) which this presentation replaces.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* the browser's top layer when it has one (so no host z-index can cover the panel), else a very high fixed box */
|
|
7
|
+
.sb-top {
|
|
8
|
+
position: fixed;
|
|
9
|
+
inset: 0;
|
|
10
|
+
width: 100%;
|
|
11
|
+
height: 100%;
|
|
12
|
+
max-width: none;
|
|
13
|
+
max-height: none;
|
|
14
|
+
margin: 0;
|
|
15
|
+
padding: 0;
|
|
16
|
+
border: 0;
|
|
17
|
+
overflow: visible;
|
|
18
|
+
background: transparent;
|
|
19
|
+
color: inherit;
|
|
20
|
+
}
|
|
21
|
+
.sb-top.sb-fallback {
|
|
22
|
+
z-index: 2147483000;
|
|
23
|
+
}
|
|
24
|
+
.sb-top[hidden] {
|
|
25
|
+
display: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.panel-backdrop {
|
|
29
|
+
backdrop-filter: blur(2px);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* desktop: a centered dialog */
|
|
33
|
+
.panel.popover {
|
|
34
|
+
position: fixed;
|
|
35
|
+
inset: 0;
|
|
36
|
+
margin: auto;
|
|
37
|
+
width: min(880px, calc(100vw - 48px));
|
|
38
|
+
min-width: 0;
|
|
39
|
+
height: min(86vh, 780px);
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
border: 1px solid var(--sb-border);
|
|
42
|
+
border-radius: 14px;
|
|
43
|
+
box-shadow:
|
|
44
|
+
0 24px 64px color-mix(in srgb, black 35%, transparent),
|
|
45
|
+
0 2px 8px color-mix(in srgb, black 20%, transparent);
|
|
46
|
+
animation: sb-popover-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
47
|
+
}
|
|
48
|
+
@keyframes sb-popover-in {
|
|
49
|
+
from {
|
|
50
|
+
opacity: 0;
|
|
51
|
+
transform: translateY(8px) scale(0.985);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
@keyframes sb-sheet-in {
|
|
55
|
+
from {
|
|
56
|
+
opacity: 0;
|
|
57
|
+
transform: translateY(24px);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
@media (prefers-reduced-motion: reduce) {
|
|
61
|
+
.panel.popover {
|
|
62
|
+
animation: none;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* phone: a sheet from the bottom, with a handle, clear of the notch and the home bar */
|
|
67
|
+
@media (max-width: 820px) {
|
|
68
|
+
.panel.popover {
|
|
69
|
+
inset: auto 0 0 0;
|
|
70
|
+
margin: 0;
|
|
71
|
+
width: 100%;
|
|
72
|
+
height: min(92dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 12px));
|
|
73
|
+
border-width: 1px 0 0;
|
|
74
|
+
border-radius: 16px 16px 0 0;
|
|
75
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
76
|
+
animation-name: sb-sheet-in;
|
|
77
|
+
}
|
|
78
|
+
.panel.popover::before {
|
|
79
|
+
content: "";
|
|
80
|
+
flex: none;
|
|
81
|
+
width: 36px;
|
|
82
|
+
height: 4px;
|
|
83
|
+
margin: 8px auto 0;
|
|
84
|
+
border-radius: 2px;
|
|
85
|
+
background: var(--sb-border);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* a finger, not a pointer: the controls of the header are comfortable targets */
|
|
90
|
+
@media (pointer: coarse) {
|
|
91
|
+
.panel.popover .panel-head .icon {
|
|
92
|
+
min-width: 44px;
|
|
93
|
+
min-height: 44px;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.embed-problem {
|
|
98
|
+
margin: var(--sb-space-4);
|
|
99
|
+
color: var(--sb-text);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* what the page's attributes asked for that could not be done, over the foot of the panel */
|
|
103
|
+
.embed-banner {
|
|
104
|
+
position: fixed;
|
|
105
|
+
left: 50%;
|
|
106
|
+
bottom: 12px;
|
|
107
|
+
transform: translateX(-50%);
|
|
108
|
+
z-index: 22;
|
|
109
|
+
max-width: min(640px, calc(100vw - 24px));
|
|
110
|
+
padding: 6px 12px;
|
|
111
|
+
border: 1px solid var(--sb-border);
|
|
112
|
+
border-radius: 8px;
|
|
113
|
+
background: var(--sb-surface-raised, var(--sb-surface));
|
|
114
|
+
color: var(--sb-text);
|
|
115
|
+
font-size: 13px;
|
|
116
|
+
}
|
package/src/styles/panel.css
CHANGED
|
@@ -377,6 +377,46 @@
|
|
|
377
377
|
gap: var(--sb-space-2);
|
|
378
378
|
padding: 8px;
|
|
379
379
|
}
|
|
380
|
+
.panel-error {
|
|
381
|
+
margin: var(--sb-space-4);
|
|
382
|
+
padding: var(--sb-space-4);
|
|
383
|
+
border: 1px solid var(--sb-border);
|
|
384
|
+
border-left: 3px solid var(--sb-danger, #dc2626);
|
|
385
|
+
border-radius: var(--sb-radius-card);
|
|
386
|
+
background: var(--sb-surface);
|
|
387
|
+
}
|
|
388
|
+
.panel-error pre {
|
|
389
|
+
margin: var(--sb-space-2) 0 var(--sb-space-3);
|
|
390
|
+
white-space: pre-wrap;
|
|
391
|
+
overflow-wrap: anywhere;
|
|
392
|
+
font: 12px var(--sb-font-mono);
|
|
393
|
+
color: var(--sb-text-muted);
|
|
394
|
+
}
|
|
395
|
+
button.flows-title {
|
|
396
|
+
width: 100%;
|
|
397
|
+
border: 0;
|
|
398
|
+
background: none;
|
|
399
|
+
color: inherit;
|
|
400
|
+
font: inherit;
|
|
401
|
+
text-align: left;
|
|
402
|
+
cursor: pointer;
|
|
403
|
+
}
|
|
404
|
+
@media (pointer: coarse) {
|
|
405
|
+
button.flows-title {
|
|
406
|
+
min-height: 44px;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
button.flows-title:hover {
|
|
410
|
+
background: var(--sb-surface-raised);
|
|
411
|
+
}
|
|
412
|
+
button.flows-title:focus-visible {
|
|
413
|
+
outline: 2px solid var(--sb-focus-ring);
|
|
414
|
+
outline-offset: -2px;
|
|
415
|
+
}
|
|
416
|
+
.flows-title .caret {
|
|
417
|
+
width: 1em;
|
|
418
|
+
color: var(--sb-text-muted);
|
|
419
|
+
}
|
|
380
420
|
.flows-title .count {
|
|
381
421
|
margin-left: auto;
|
|
382
422
|
font-size: 12px;
|
|
@@ -390,6 +430,20 @@
|
|
|
390
430
|
.flows .op-row:last-child {
|
|
391
431
|
border-bottom: 0;
|
|
392
432
|
}
|
|
433
|
+
/* saved requests: the same block as flows, in the neutral border colour, so flows stay the one accent */
|
|
434
|
+
.flows.requests {
|
|
435
|
+
border-color: var(--sb-border);
|
|
436
|
+
border-left-color: var(--sb-text-muted);
|
|
437
|
+
background: var(--sb-surface-raised);
|
|
438
|
+
}
|
|
439
|
+
.method.req {
|
|
440
|
+
color: var(--sb-text-muted);
|
|
441
|
+
}
|
|
442
|
+
.req-tag {
|
|
443
|
+
margin-left: 6px;
|
|
444
|
+
font-size: 11px;
|
|
445
|
+
font-weight: 500;
|
|
446
|
+
}
|
|
393
447
|
.flow-play {
|
|
394
448
|
font-size: 10px;
|
|
395
449
|
text-align: center;
|
|
@@ -715,13 +769,53 @@
|
|
|
715
769
|
}
|
|
716
770
|
.needs-var {
|
|
717
771
|
display: inline-flex;
|
|
772
|
+
flex-wrap: wrap;
|
|
718
773
|
align-items: center;
|
|
719
774
|
gap: 6px;
|
|
775
|
+
min-width: 0;
|
|
776
|
+
max-width: 100%;
|
|
720
777
|
}
|
|
721
778
|
.needs-var input {
|
|
722
779
|
width: 130px;
|
|
723
780
|
min-width: 0;
|
|
724
781
|
}
|
|
782
|
+
/* the pasted value looks like the client id field beside it */
|
|
783
|
+
.needs-var input[type="password"] {
|
|
784
|
+
padding: 5px 8px;
|
|
785
|
+
border: 1px solid var(--sb-border);
|
|
786
|
+
border-radius: var(--sb-radius-control);
|
|
787
|
+
background: var(--sb-surface);
|
|
788
|
+
}
|
|
789
|
+
/* Sign in: its own row inside the credential, so the client id, the button, the scopes and the status
|
|
790
|
+
wrap under each other on a narrow screen instead of running off the edge */
|
|
791
|
+
.oauth-signin {
|
|
792
|
+
flex: 1 1 100%;
|
|
793
|
+
}
|
|
794
|
+
.oauth-signin input[type="text"] {
|
|
795
|
+
flex: 1 1 140px;
|
|
796
|
+
width: auto;
|
|
797
|
+
max-width: 360px;
|
|
798
|
+
}
|
|
799
|
+
.oauth-signin details {
|
|
800
|
+
flex: 1 1 100%;
|
|
801
|
+
min-width: 0;
|
|
802
|
+
}
|
|
803
|
+
.oauth-signin input[type="checkbox"] {
|
|
804
|
+
flex: 0 0 auto;
|
|
805
|
+
width: auto;
|
|
806
|
+
}
|
|
807
|
+
.oauth-signin details label {
|
|
808
|
+
display: flex;
|
|
809
|
+
align-items: center;
|
|
810
|
+
gap: 6px;
|
|
811
|
+
min-height: 28px;
|
|
812
|
+
overflow-wrap: anywhere;
|
|
813
|
+
}
|
|
814
|
+
.oauth-status {
|
|
815
|
+
flex: 1 1 100%;
|
|
816
|
+
min-width: 0;
|
|
817
|
+
overflow-wrap: anywhere;
|
|
818
|
+
}
|
|
725
819
|
.rq-meta {
|
|
726
820
|
display: flex;
|
|
727
821
|
align-items: center;
|
|
@@ -930,6 +1024,99 @@
|
|
|
930
1024
|
border: 0;
|
|
931
1025
|
}
|
|
932
1026
|
|
|
1027
|
+
/* ---- a failed check: what was expected, what came back, as a colored diff */
|
|
1028
|
+
.visually-hidden {
|
|
1029
|
+
position: absolute;
|
|
1030
|
+
width: 1px;
|
|
1031
|
+
height: 1px;
|
|
1032
|
+
overflow: hidden;
|
|
1033
|
+
clip-path: inset(50%);
|
|
1034
|
+
white-space: nowrap;
|
|
1035
|
+
}
|
|
1036
|
+
.checks .bad {
|
|
1037
|
+
margin-top: var(--sb-space-2);
|
|
1038
|
+
color: inherit;
|
|
1039
|
+
}
|
|
1040
|
+
.check-fail {
|
|
1041
|
+
display: grid;
|
|
1042
|
+
gap: var(--sb-space-2);
|
|
1043
|
+
margin: 0;
|
|
1044
|
+
min-width: 0;
|
|
1045
|
+
padding: var(--sb-space-3);
|
|
1046
|
+
border: 1px solid color-mix(in srgb, var(--sb-danger) 35%, var(--sb-border));
|
|
1047
|
+
border-left: 3px solid var(--sb-danger);
|
|
1048
|
+
border-radius: var(--sb-radius-control);
|
|
1049
|
+
background: color-mix(in srgb, var(--sb-danger) 5%, var(--sb-surface));
|
|
1050
|
+
}
|
|
1051
|
+
.check-head {
|
|
1052
|
+
display: flex;
|
|
1053
|
+
flex-wrap: wrap;
|
|
1054
|
+
align-items: center;
|
|
1055
|
+
gap: var(--sb-space-2);
|
|
1056
|
+
color: var(--sb-danger);
|
|
1057
|
+
}
|
|
1058
|
+
.check-head strong {
|
|
1059
|
+
min-width: 0;
|
|
1060
|
+
overflow-wrap: anywhere;
|
|
1061
|
+
}
|
|
1062
|
+
.diff-legend {
|
|
1063
|
+
display: flex;
|
|
1064
|
+
gap: var(--sb-space-3);
|
|
1065
|
+
font-size: 12px;
|
|
1066
|
+
font-weight: 600;
|
|
1067
|
+
}
|
|
1068
|
+
.diff-key.diff-expected,
|
|
1069
|
+
.diff-line.diff-expected {
|
|
1070
|
+
color: var(--sb-success);
|
|
1071
|
+
}
|
|
1072
|
+
.diff-key.diff-received,
|
|
1073
|
+
.diff-line.diff-received {
|
|
1074
|
+
color: var(--sb-danger);
|
|
1075
|
+
}
|
|
1076
|
+
.diff {
|
|
1077
|
+
margin: 0;
|
|
1078
|
+
padding: var(--sb-space-2) 0;
|
|
1079
|
+
white-space: pre;
|
|
1080
|
+
max-height: 18em;
|
|
1081
|
+
overflow: auto;
|
|
1082
|
+
border-radius: var(--sb-radius-control);
|
|
1083
|
+
background: var(--sb-surface);
|
|
1084
|
+
border: 1px solid var(--sb-border);
|
|
1085
|
+
font: 12.5px / 1.5 var(--sb-font-mono);
|
|
1086
|
+
}
|
|
1087
|
+
.diff-line {
|
|
1088
|
+
display: flex;
|
|
1089
|
+
min-width: max-content;
|
|
1090
|
+
padding: 0 var(--sb-space-3) 0 0;
|
|
1091
|
+
}
|
|
1092
|
+
.diff-line.diff-expected {
|
|
1093
|
+
background: var(--sb-success-subtle);
|
|
1094
|
+
}
|
|
1095
|
+
.diff-line.diff-received {
|
|
1096
|
+
background: var(--sb-danger-subtle);
|
|
1097
|
+
}
|
|
1098
|
+
.diff-line.diff-same {
|
|
1099
|
+
color: var(--sb-text-muted);
|
|
1100
|
+
}
|
|
1101
|
+
.diff-line.diff-skip {
|
|
1102
|
+
color: var(--sb-text-muted);
|
|
1103
|
+
font-style: italic;
|
|
1104
|
+
}
|
|
1105
|
+
.diff-gutter {
|
|
1106
|
+
flex: none;
|
|
1107
|
+
width: 2em;
|
|
1108
|
+
text-align: center;
|
|
1109
|
+
user-select: none;
|
|
1110
|
+
}
|
|
1111
|
+
.diff-text {
|
|
1112
|
+
white-space: pre;
|
|
1113
|
+
}
|
|
1114
|
+
.check-where {
|
|
1115
|
+
font: 12px var(--sb-font-mono);
|
|
1116
|
+
color: var(--sb-text-muted);
|
|
1117
|
+
overflow-wrap: anywhere;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
933
1120
|
/* ---- shared small controls */
|
|
934
1121
|
.link {
|
|
935
1122
|
background: none;
|
|
@@ -1422,6 +1609,66 @@
|
|
|
1422
1609
|
width: 60%;
|
|
1423
1610
|
}
|
|
1424
1611
|
}
|
|
1612
|
+
.controls-head {
|
|
1613
|
+
display: flex;
|
|
1614
|
+
align-items: center;
|
|
1615
|
+
justify-content: space-between;
|
|
1616
|
+
gap: var(--sb-space-2);
|
|
1617
|
+
}
|
|
1618
|
+
.controls-head .gen-all {
|
|
1619
|
+
display: inline-flex;
|
|
1620
|
+
align-items: center;
|
|
1621
|
+
gap: 6px;
|
|
1622
|
+
text-transform: none;
|
|
1623
|
+
letter-spacing: 0;
|
|
1624
|
+
font-size: 12px;
|
|
1625
|
+
padding: 4px 10px;
|
|
1626
|
+
}
|
|
1627
|
+
/* a value with a generator: the cell and the button that makes another, never wider than the column */
|
|
1628
|
+
.cell-gen {
|
|
1629
|
+
display: flex;
|
|
1630
|
+
align-items: center;
|
|
1631
|
+
gap: 4px;
|
|
1632
|
+
min-width: 0;
|
|
1633
|
+
}
|
|
1634
|
+
.cell-gen > :first-child {
|
|
1635
|
+
flex: 1 1 auto;
|
|
1636
|
+
min-width: 0;
|
|
1637
|
+
}
|
|
1638
|
+
.cell-gen .cell {
|
|
1639
|
+
width: 100%;
|
|
1640
|
+
min-width: 0;
|
|
1641
|
+
}
|
|
1642
|
+
.gen {
|
|
1643
|
+
flex: none;
|
|
1644
|
+
display: inline-flex;
|
|
1645
|
+
align-items: center;
|
|
1646
|
+
justify-content: center;
|
|
1647
|
+
width: 28px;
|
|
1648
|
+
height: 28px;
|
|
1649
|
+
margin-right: 4px;
|
|
1650
|
+
padding: 0;
|
|
1651
|
+
color: var(--sb-text-muted);
|
|
1652
|
+
background: transparent;
|
|
1653
|
+
border: 1px solid var(--sb-border);
|
|
1654
|
+
border-radius: var(--sb-radius-control);
|
|
1655
|
+
cursor: pointer;
|
|
1656
|
+
}
|
|
1657
|
+
.gen:hover,
|
|
1658
|
+
.gen:focus-visible {
|
|
1659
|
+
color: var(--sb-accent);
|
|
1660
|
+
border-color: var(--sb-accent);
|
|
1661
|
+
}
|
|
1662
|
+
/* a finger, not a pointer: the button is a comfortable target */
|
|
1663
|
+
@media (pointer: coarse) {
|
|
1664
|
+
.gen {
|
|
1665
|
+
width: 44px;
|
|
1666
|
+
height: 44px;
|
|
1667
|
+
}
|
|
1668
|
+
.controls-head .gen-all {
|
|
1669
|
+
min-height: 44px;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1425
1672
|
.multi-cell {
|
|
1426
1673
|
display: flex;
|
|
1427
1674
|
flex-wrap: wrap;
|
|
@@ -1538,6 +1785,8 @@
|
|
|
1538
1785
|
max-height: min(70vh, 640px);
|
|
1539
1786
|
overflow: auto;
|
|
1540
1787
|
display: grid;
|
|
1788
|
+
/* one column that cannot outgrow the popover: Safari sizes an auto track to its widest field */
|
|
1789
|
+
grid-template-columns: minmax(0, 1fr);
|
|
1541
1790
|
gap: var(--sb-space-3);
|
|
1542
1791
|
padding: var(--sb-space-3) var(--sb-space-4);
|
|
1543
1792
|
background: var(--sb-surface);
|
|
@@ -1611,6 +1860,23 @@
|
|
|
1611
1860
|
background: var(--sb-danger-subtle);
|
|
1612
1861
|
color: var(--sb-danger);
|
|
1613
1862
|
}
|
|
1863
|
+
/* a variable's value, where it comes from */
|
|
1864
|
+
.envrow .src {
|
|
1865
|
+
flex: none;
|
|
1866
|
+
padding: 0 8px;
|
|
1867
|
+
border-radius: 999px;
|
|
1868
|
+
font-size: 11px;
|
|
1869
|
+
font-weight: 600;
|
|
1870
|
+
}
|
|
1871
|
+
.src.inherited {
|
|
1872
|
+
background: var(--sb-surface-raised);
|
|
1873
|
+
color: var(--sb-text-muted);
|
|
1874
|
+
}
|
|
1875
|
+
.envuse {
|
|
1876
|
+
margin: -2px 0 var(--sb-space-2);
|
|
1877
|
+
padding-left: 2px;
|
|
1878
|
+
overflow-wrap: anywhere;
|
|
1879
|
+
}
|
|
1614
1880
|
.op-lock {
|
|
1615
1881
|
display: inline-block;
|
|
1616
1882
|
margin-left: 6px;
|
|
@@ -1765,6 +2031,13 @@
|
|
|
1765
2031
|
.envrow {
|
|
1766
2032
|
flex-wrap: wrap;
|
|
1767
2033
|
}
|
|
2034
|
+
/* a variable: its name above, then the value (an address needs the room) beside its source */
|
|
2035
|
+
.envrow code {
|
|
2036
|
+
flex: 1 0 100%;
|
|
2037
|
+
}
|
|
2038
|
+
.envrow input {
|
|
2039
|
+
flex: 1 1 0;
|
|
2040
|
+
}
|
|
1768
2041
|
.envrow input,
|
|
1769
2042
|
.envrow select {
|
|
1770
2043
|
min-width: 0;
|
|
@@ -1787,8 +2060,10 @@
|
|
|
1787
2060
|
flex-wrap: wrap;
|
|
1788
2061
|
padding-inline: var(--sb-space-3);
|
|
1789
2062
|
}
|
|
1790
|
-
/* the credentials note is in the chip's tooltip; not worth a line of a phone screen
|
|
1791
|
-
|
|
2063
|
+
/* the credentials note is in the chip's tooltip; not worth a line of a phone screen. The redirect
|
|
2064
|
+
address to register is a desktop job too; a problem or a wait is still shown */
|
|
2065
|
+
.secrets-note,
|
|
2066
|
+
.oauth-hint {
|
|
1792
2067
|
display: none;
|
|
1793
2068
|
}
|
|
1794
2069
|
.secrets-folded,
|
package/src/viewer/embed.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* The script takes the page over (it owns the document, as `/docs` does). `spec` may be relative.
|
|
10
10
|
* `baseUrl` gives the API address when the document has none, `target` names another container.
|
|
11
11
|
*/
|
|
12
|
+
import { handleOAuthCallback } from '../oauth-callback'
|
|
12
13
|
import { SpecWorker } from './client'
|
|
13
14
|
import { mountOpened } from './mount'
|
|
14
15
|
|
|
@@ -46,6 +47,10 @@ const say = (el: HTMLElement, text: string, error = false) => {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
async function start() {
|
|
50
|
+
// A sign-in's popup comes back to this page, so the viewer is what has to hand the code on. The address
|
|
51
|
+
// is left alone unless a viewer tab claims it: the page is the host's, and its `?code=&state=` may be
|
|
52
|
+
// the host application's own sign-in.
|
|
53
|
+
if (await handleOAuthCallback({ stripWhenUnclaimed: false })) return
|
|
49
54
|
const cfg = readConfig()
|
|
50
55
|
const target = container(cfg.target)
|
|
51
56
|
if (!cfg.spec)
|
package/src/viewer/main.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render } from 'preact'
|
|
2
2
|
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { handleOAuthCallback } from '../oauth-callback'
|
|
3
4
|
import { applyTheme } from '../theme'
|
|
4
5
|
import { type OpenFailure, SpecWorker } from './client'
|
|
5
6
|
import { mountOpened } from './mount'
|
|
@@ -282,4 +283,7 @@ function BasePrompt({
|
|
|
282
283
|
)
|
|
283
284
|
}
|
|
284
285
|
|
|
285
|
-
|
|
286
|
+
// a sign-in's return leg lands on this page too: hand it to the tab that started it, draw nothing here
|
|
287
|
+
void handleOAuthCallback().then((handled) => {
|
|
288
|
+
if (!handled) render(<Picker initial={{ kind: 'idle' }} />, root)
|
|
289
|
+
})
|
package/src/viewer/protocol.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ServerEntry } from '@specbook/core'
|
|
1
2
|
import type { AuthProfileLike } from './registry-data'
|
|
2
3
|
|
|
3
4
|
/** Where a spec comes from. */
|
|
@@ -24,8 +25,16 @@ export interface OpenResult {
|
|
|
24
25
|
auth: Record<string, AuthProfileLike>
|
|
25
26
|
secrets: Record<string, Record<string, never>>
|
|
26
27
|
defaultProfile?: string
|
|
28
|
+
/**
|
|
29
|
+
* Sign-in addresses the document gives relative to its servers, as variables: the values for `live`,
|
|
30
|
+
* and per other environment. The profiles in `auth` read them as `{{name}}`.
|
|
31
|
+
*/
|
|
32
|
+
authVars: Record<string, string>
|
|
33
|
+
authEnvironmentVars: Record<string, Record<string, string>>
|
|
27
34
|
/** absolute API address, from `servers` (resolved against the spec URL) or `host` */
|
|
28
35
|
baseUrl?: string
|
|
36
|
+
/** every usable server the document lists, as environments; the first is `live` */
|
|
37
|
+
servers: ServerEntry[]
|
|
29
38
|
/** a relative `servers` entry the person has to give a base URL for */
|
|
30
39
|
needsBaseUrl: boolean
|
|
31
40
|
warnings: string[]
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import type { OAuthSettings } from '@specbook/core'
|
|
2
|
+
|
|
1
3
|
export interface AuthProfileLike {
|
|
2
4
|
title: string
|
|
3
5
|
apply: { headers?: Record<string, string>; query?: Record<string, string> }
|
|
6
|
+
/** the scheme's authorization code flow, when it has one the page can sign in to */
|
|
7
|
+
oauth?: OAuthSettings
|
|
4
8
|
}
|
package/src/viewer/registry.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the document in a worker and puts the result on `globalThis.__SPECBOOK_VIEWER__` before the app is
|
|
4
4
|
* imported; this module turns that into the same named exports the dev server and the static build give.
|
|
5
5
|
*/
|
|
6
|
-
import { defineOperation, type OpMeta } from '@specbook/core'
|
|
6
|
+
import { defineOperation, environmentsFromServers, type OpMeta } from '@specbook/core'
|
|
7
7
|
import type { OpenResult } from './protocol'
|
|
8
8
|
|
|
9
9
|
export interface ViewerGlobal {
|
|
@@ -32,7 +32,10 @@ const ids = Object.keys(open.metas)
|
|
|
32
32
|
export const mode = 'static' as const
|
|
33
33
|
export const project = { root: '', outDir: '', requestsDir: 'requests' }
|
|
34
34
|
export const manifests = { [service]: open.manifest }
|
|
35
|
+
export const loadManifests = async (_services?: string[]) => {}
|
|
35
36
|
export const requests: never[] = []
|
|
37
|
+
export const requestMeta = {}
|
|
38
|
+
export const unresolvedRequests: string[] = []
|
|
36
39
|
export const sequences: never[] = []
|
|
37
40
|
export const sequenceMeta = {}
|
|
38
41
|
export const unresolvedSequences: string[] = []
|
|
@@ -134,19 +137,61 @@ export const loadFaker = () =>
|
|
|
134
137
|
},
|
|
135
138
|
}))
|
|
136
139
|
|
|
137
|
-
|
|
140
|
+
// `live` is the document's first server, or the address the person typed for it; the rest keep their own
|
|
141
|
+
// names. Each address is the variable `baseUrl` in its environment, so a copy of one can point elsewhere.
|
|
142
|
+
const live = v.baseUrl ?? open.baseUrl
|
|
143
|
+
const liveServer = open.servers.find((s) => s.name === 'live')
|
|
144
|
+
const addresses = environmentsFromServers(
|
|
145
|
+
[
|
|
146
|
+
...(live ? [{ name: 'live', url: live, description: liveServer?.description }] : []),
|
|
147
|
+
...open.servers.filter((s) => s.name !== 'live'),
|
|
148
|
+
],
|
|
149
|
+
'baseUrl',
|
|
150
|
+
)
|
|
151
|
+
// a sign-in address the document gave relative to its server needs the address the person typed
|
|
152
|
+
const absolute = (value: string) => {
|
|
153
|
+
if (/^https?:\/\//i.test(value) || !v.baseUrl) return value
|
|
154
|
+
try {
|
|
155
|
+
return new URL(value, `${v.baseUrl.replace(/\/+$/, '')}/`).toString()
|
|
156
|
+
} catch {
|
|
157
|
+
return value
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const vars = {
|
|
161
|
+
...addresses.vars,
|
|
162
|
+
...Object.fromEntries(Object.entries(open.authVars).map(([k, x]) => [k, absolute(x)])),
|
|
163
|
+
}
|
|
164
|
+
const environmentSettings: Record<string, { description?: string; vars?: Record<string, string> }> =
|
|
165
|
+
{
|
|
166
|
+
...addresses.environments,
|
|
167
|
+
}
|
|
168
|
+
for (const [name, evars] of Object.entries(open.authEnvironmentVars))
|
|
169
|
+
environmentSettings[name] = {
|
|
170
|
+
...environmentSettings[name],
|
|
171
|
+
vars: { ...environmentSettings[name]?.vars, ...evars },
|
|
172
|
+
}
|
|
173
|
+
/** Where environments made in this browser are kept: per spec address, or for the tab for an upload. */
|
|
174
|
+
export const environmentStore: { scope: string; session: boolean } | null = v.source
|
|
175
|
+
? {
|
|
176
|
+
scope: v.source.kind === 'url' ? v.source.url : `upload:${service}`,
|
|
177
|
+
session: v.source.kind !== 'url',
|
|
178
|
+
}
|
|
179
|
+
: null
|
|
138
180
|
export const loadConfig = () =>
|
|
139
181
|
Promise.resolve({
|
|
140
182
|
default: {
|
|
141
183
|
project: service,
|
|
142
184
|
secrets: open.secrets,
|
|
143
185
|
auth: open.auth,
|
|
186
|
+
...(Object.keys(vars).length ? { vars } : {}),
|
|
187
|
+
...(Object.keys(environmentSettings).length ? { environments: environmentSettings } : {}),
|
|
144
188
|
// one locale, so the header offers no locale choice
|
|
145
189
|
faker: { default: 'en', locales: { en: ['en', 'base'] } },
|
|
146
190
|
services: {
|
|
147
191
|
[service]: {
|
|
148
192
|
spec: '',
|
|
149
|
-
|
|
193
|
+
// a document that gives no address at all has no live environment either
|
|
194
|
+
baseUrl: live || open.servers.length ? addresses.baseUrl : {},
|
|
150
195
|
...(open.defaultProfile ? { auth: open.defaultProfile } : {}),
|
|
151
196
|
},
|
|
152
197
|
},
|
package/src/viewer/worker.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference lib="webworker" />
|
|
2
2
|
import { generateData, type OperationMeta, openSpec } from '@specbook/codegen/browser'
|
|
3
|
-
import { authFromSpec,
|
|
3
|
+
import { authFromSpec, MAX_SERVERS, openApiServers } from '@specbook/core'
|
|
4
4
|
import { bundleJson, type Parts, sortFiles } from './bundles'
|
|
5
5
|
import type { OpenResult, Request, Response, Source } from './protocol'
|
|
6
6
|
|
|
@@ -112,11 +112,16 @@ async function open(id: number, source: Source, signal: AbortSignal): Promise<Op
|
|
|
112
112
|
(o.examples as { name: string }[]).map((e) => e.name),
|
|
113
113
|
]),
|
|
114
114
|
)
|
|
115
|
-
const { auth, secrets, defaultProfile } = authFromSpec(doc)
|
|
116
115
|
const sourceUrl = source.kind === 'url' ? source.url : undefined
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
116
|
+
const { servers, needsBaseUrl, omitted } = openApiServers(doc, sourceUrl ?? name ?? '')
|
|
117
|
+
const {
|
|
118
|
+
auth,
|
|
119
|
+
secrets,
|
|
120
|
+
defaultProfile,
|
|
121
|
+
vars: authVars,
|
|
122
|
+
environmentVars: authEnvironmentVars,
|
|
123
|
+
} = authFromSpec(doc, sourceUrl, servers)
|
|
124
|
+
const baseUrl = servers.find((s) => s.name === 'live')?.url
|
|
120
125
|
return {
|
|
121
126
|
service,
|
|
122
127
|
title,
|
|
@@ -126,9 +131,17 @@ async function open(id: number, source: Source, signal: AbortSignal): Promise<Op
|
|
|
126
131
|
auth,
|
|
127
132
|
secrets,
|
|
128
133
|
defaultProfile,
|
|
134
|
+
authVars,
|
|
135
|
+
authEnvironmentVars,
|
|
129
136
|
baseUrl,
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
servers,
|
|
138
|
+
needsBaseUrl,
|
|
139
|
+
warnings: omitted
|
|
140
|
+
? [
|
|
141
|
+
...warnings,
|
|
142
|
+
`This document lists ${servers.length + omitted} servers. The first ${MAX_SERVERS} are offered as environments.`,
|
|
143
|
+
]
|
|
144
|
+
: warnings,
|
|
132
145
|
timings: {
|
|
133
146
|
...(fetched !== undefined ? { fetch: Math.round(fetched) } : {}),
|
|
134
147
|
parse: Math.round(parse),
|
package/src/virtual.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ declare module 'virtual:specbook/registry' {
|
|
|
6
6
|
source?: () => Promise<string>
|
|
7
7
|
}
|
|
8
8
|
export const mode: 'dev' | 'static'
|
|
9
|
+
/** A build that publishes only the operations the project's requests and flows use. */
|
|
10
|
+
export const minimal: boolean | undefined
|
|
9
11
|
export const project: { root: string; outDir: string; requestsDir: string }
|
|
10
12
|
export const manifests: Record<
|
|
11
13
|
string,
|
|
@@ -28,7 +30,13 @@ declare module 'virtual:specbook/registry' {
|
|
|
28
30
|
}[]
|
|
29
31
|
}
|
|
30
32
|
>
|
|
33
|
+
/** Fetches the manifests of the named services (all when none are named) into `manifests`. A build starts with it empty. */
|
|
34
|
+
export const loadManifests: (services?: string[]) => Promise<void>
|
|
31
35
|
export const requests: Entry[]
|
|
36
|
+
/** Which `service/Operation` each request file is for, read from its text (no import), by project-relative file. */
|
|
37
|
+
export const requestMeta: Record<string, string>
|
|
38
|
+
/** Request files whose text does not say; these are imported to be listed. */
|
|
39
|
+
export const unresolvedRequests: string[]
|
|
32
40
|
export const sequences: Entry[]
|
|
33
41
|
/** Sequence headers read from the `*.seq.ts` text (no import), keyed by id. */
|
|
34
42
|
export const sequenceMeta: Record<
|