@volter/world-console 2.0.1 → 2.0.2
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/README.md +6 -4
- package/client/console.css +192 -106
- package/client/console.tsx +1239 -492
- package/client/kit/kit.css +279 -0
- package/client/kit/kit.tsx +259 -0
- package/client/vendor-logos.ts +42 -0
- package/dist/client/console.bundle.js +126 -113
- package/dist/client/console.css +192 -106
- package/dist/client/console.tsx +1239 -492
- package/dist/client/kit/kit.css +279 -0
- package/dist/client/kit/kit.tsx +259 -0
- package/dist/client/vendor-logos.ts +42 -0
- package/dist/src/console.d.ts +20 -11
- package/dist/src/console.js +36 -24
- package/package.json +7 -5
- package/src/console.ts +43 -30
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/* THE KIT's design system (docs/contributing/architecture.md, "The hosted product"): the dashboard, the platform's
|
|
2
|
+
pages and the site read these roles. Punchlist 3.1–3.2 — the comparable is a plain modern developer
|
|
3
|
+
console: a ground, cards with one hairline, one action colour, danger only for danger, the brand's
|
|
4
|
+
type, monospace for addresses. Every colour and face is a role of the Volter brand (company
|
|
5
|
+
decision 0018): the shell links the brand's tokens (`base/brand/tokens.css`, fetched at build) before
|
|
6
|
+
this sheet, and the names below are this sheet's aliases for those roles; the pre-commit hook
|
|
7
|
+
refuses a literal colour here. The roles carry both themes (light-dark()); `color-scheme` picks one.
|
|
8
|
+
The site reads the same roles (apps/www/src/site.css). */
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: light dark;
|
|
11
|
+
--ground: var(--volter-surface-page); --surface: var(--volter-surface-raised); --surface-2: var(--volter-surface-subtle);
|
|
12
|
+
--line: var(--volter-border-default); --line-strong: var(--volter-border-strong);
|
|
13
|
+
--ink: var(--volter-text-primary); --muted: var(--volter-text-muted); --faint: var(--volter-text-faint);
|
|
14
|
+
--accent: var(--volter-action-default); --accent-ink: var(--volter-text-on-strong); --accent-soft: var(--volter-action-soft);
|
|
15
|
+
--danger: var(--volter-status-danger-base); --danger-text: var(--volter-status-danger-text); --danger-soft: var(--volter-status-danger-soft);
|
|
16
|
+
--warn: var(--volter-status-attention-text); --warn-soft: var(--volter-status-attention-soft); --ok: var(--volter-status-healthy-text);
|
|
17
|
+
--mono: var(--volter-font-data);
|
|
18
|
+
--sans: var(--volter-font-ui);
|
|
19
|
+
--radius: 8px; --shadow: var(--volter-shadow-resting);
|
|
20
|
+
}
|
|
21
|
+
:root[data-theme="light"] { color-scheme: light; }
|
|
22
|
+
:root[data-theme="dark"] { color-scheme: dark; }
|
|
23
|
+
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
html { background: var(--ground); }
|
|
26
|
+
body { margin: 0; font: 14px/1.5 var(--sans); color: var(--ink); background: var(--ground); -webkit-font-smoothing: antialiased; }
|
|
27
|
+
main { max-width: 1120px; margin: 0 auto; padding: 20px 24px 72px; }
|
|
28
|
+
/* a link is the action colour, which is the text colour in the brand: its underline, in the strong
|
|
29
|
+
rule, is what tells it from text (the brand's own pages mark links the same way) */
|
|
30
|
+
a { color: var(--accent); text-decoration: underline; text-decoration-color: var(--line-strong); text-underline-offset: 3px; }
|
|
31
|
+
a:hover { text-decoration-color: currentColor; }
|
|
32
|
+
header a { text-decoration: none; }
|
|
33
|
+
header a:hover { text-decoration: underline; }
|
|
34
|
+
code { font: 12.5px/1.5 var(--mono); background: var(--surface-2); border: 1px solid var(--line); padding: 1px 6px; border-radius: 5px; color: var(--ink); }
|
|
35
|
+
pre { margin: 0; font: 12px/1.45 var(--mono); white-space: pre-wrap; max-width: 640px; background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; }
|
|
36
|
+
h1, h2, h3, h4, h5 { margin: 0; font-weight: 600; letter-spacing: -0.005em; text-wrap: balance; }
|
|
37
|
+
h1 { font-size: 17px; }
|
|
38
|
+
h2 { font-size: 18px; margin: 28px 0 10px; }
|
|
39
|
+
h3 { font-size: 15px; margin: 22px 0 8px; }
|
|
40
|
+
h4 { font-size: 13px; margin: 18px 0 6px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; }
|
|
41
|
+
h5 { font-size: 13px; margin: 10px 0 4px; color: var(--danger-text); }
|
|
42
|
+
p { margin: 6px 0; }
|
|
43
|
+
ul, ol { margin: 6px 0; padding-left: 20px; }
|
|
44
|
+
li { margin: 3px 0; }
|
|
45
|
+
|
|
46
|
+
/* the top bar */
|
|
47
|
+
header { display: flex; align-items: center; gap: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 10px 16px; margin-bottom: 22px; }
|
|
48
|
+
header h1 a { color: var(--ink); }
|
|
49
|
+
header nav a { color: var(--muted); }
|
|
50
|
+
header .mode { margin-left: auto; color: var(--muted); font-size: 12.5px; }
|
|
51
|
+
header > a { color: var(--muted); font-size: 13px; }
|
|
52
|
+
header button { padding: 5px 12px; }
|
|
53
|
+
|
|
54
|
+
/* cards: every section that holds a thing */
|
|
55
|
+
/* tables */
|
|
56
|
+
table { border-collapse: collapse; width: 100%; margin: 6px 0 4px; font-variant-numeric: tabular-nums; }
|
|
57
|
+
th, td { text-align: left; vertical-align: top; padding: 9px 10px; border-bottom: 1px solid var(--line); }
|
|
58
|
+
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
59
|
+
tr:last-child td { border-bottom: 0; }
|
|
60
|
+
td code { font-size: 12px; }
|
|
61
|
+
/* controls */
|
|
62
|
+
button { font: inherit; font-size: 13px; padding: 6px 12px; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--surface); color: var(--ink); cursor: pointer; }
|
|
63
|
+
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
64
|
+
button:disabled { opacity: 0.45; cursor: default; }
|
|
65
|
+
input:disabled, select:disabled, textarea:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; }
|
|
66
|
+
button[type="submit"] { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
|
67
|
+
button[type="submit"]:hover:not(:disabled) { color: var(--accent-ink); filter: brightness(1.06); }
|
|
68
|
+
button[role="tab"] { border-color: transparent; background: transparent; color: var(--muted); }
|
|
69
|
+
button[role="tab"][aria-selected="true"] { border-color: var(--line-strong); background: var(--surface); color: var(--ink); }
|
|
70
|
+
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
71
|
+
form { display: grid; gap: 10px; max-width: 560px; margin-top: 8px; }
|
|
72
|
+
form button[type="submit"] { justify-self: start; }
|
|
73
|
+
label { display: grid; gap: 4px; color: var(--muted); font-size: 12.5px; }
|
|
74
|
+
label:has(input[type="checkbox"]) { display: flex; align-items: center; gap: 8px; color: var(--ink); font-size: 13px; }
|
|
75
|
+
input, select { font: inherit; font-size: 14px; padding: 7px 10px; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--surface); color: var(--ink); }
|
|
76
|
+
input::placeholder { color: var(--faint); }
|
|
77
|
+
select { padding-right: 28px; }
|
|
78
|
+
[role="alert"] { color: var(--danger-text); background: var(--danger-soft); border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--line)); border-radius: 6px; padding: 8px 10px; }
|
|
79
|
+
[role="status"] { color: var(--muted); }
|
|
80
|
+
.mode { color: var(--muted); font-size: 12.5px; }
|
|
81
|
+
|
|
82
|
+
/* a page that is one card: signing in, the command's approval, a refusal */
|
|
83
|
+
body.portal { display: grid; place-items: center; min-height: 100vh; padding: 24px; }
|
|
84
|
+
body.portal .card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); padding: 28px 30px; width: min(440px, 100%); text-align: center; }
|
|
85
|
+
body.portal .card h1 { font-size: 22px; margin-bottom: 4px; }
|
|
86
|
+
body.portal .card p { color: var(--muted); margin: 0 0 18px; }
|
|
87
|
+
body.account main { max-width: 900px; }
|
|
88
|
+
body.account #profile { margin-top: 12px; }
|
|
89
|
+
@media (max-width: 720px) { main { padding: 14px 14px 60px; } header { flex-wrap: wrap; } header .mode { margin-left: 0; } }
|
|
90
|
+
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
91
|
+
.badge { display: inline-block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line-strong); color: var(--muted); background: var(--surface-2); }
|
|
92
|
+
a.button { display: inline-block; font-size: 13px; padding: 5px 12px; border: 1px solid var(--accent); border-radius: 6px; background: var(--accent); color: var(--accent-ink); text-decoration: none; }
|
|
93
|
+
a.button:hover { filter: brightness(1.06); }
|
|
94
|
+
form.inline { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; max-width: none; margin: 6px 0 0; }
|
|
95
|
+
form.inline label { display: grid; }
|
|
96
|
+
/* THE APP SHELL (the dashboard's World and the platform's org alike): a full-width shell, as a project dashboard is (Vercel, Neon): a top bar, a sidebar of the World's
|
|
97
|
+
pages with its vendors under Vendors, a page head with its title and actions, dense content under it. */
|
|
98
|
+
main.app { max-width: none; padding: 0; font-size: 13.5px; }
|
|
99
|
+
main.app > header { margin: 0; border-radius: 0; border-width: 0 0 1px; box-shadow: none; padding: 8px 18px; position: sticky; top: 0; z-index: 10; }
|
|
100
|
+
main.app > header h1 { font-size: 15px; }
|
|
101
|
+
.shell { display: grid; grid-template-columns: 236px minmax(0, 1fr); min-height: calc(100vh - 47px); }
|
|
102
|
+
.side { border-right: 1px solid var(--line); background: var(--surface); padding: 14px 10px; position: sticky; top: 47px; align-self: start; height: calc(100vh - 47px); overflow-y: auto; }
|
|
103
|
+
/* the sidebar scrolls, which clips sideways too: while one of its popovers is open (Connect is wider than it) it lets it out */
|
|
104
|
+
.side:has(details[open] > .popover) { overflow: visible; z-index: 5; }
|
|
105
|
+
.side-world { display: flex; align-items: center; gap: 10px; padding: 4px 8px 14px; }
|
|
106
|
+
.side-world > span:last-child { display: grid; line-height: 1.25; }
|
|
107
|
+
.side-nav a { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; color: var(--muted); text-decoration: none; font-weight: 500; }
|
|
108
|
+
.side-nav a:hover { background: var(--surface-2); color: var(--ink); }
|
|
109
|
+
.side-nav a.on { background: var(--surface-2); color: var(--ink); }
|
|
110
|
+
.side-vendors { list-style: none; margin: 2px 0 6px 10px; padding: 0 0 0 8px; border-left: 1px solid var(--line); }
|
|
111
|
+
.side-vendors li { margin: 0; }
|
|
112
|
+
.side-vendors a { font-weight: 400; padding: 4px 8px; }
|
|
113
|
+
.side-vendors .count { margin-left: auto; color: var(--faint); font: 11.5px var(--mono); }
|
|
114
|
+
.mark { display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; border-radius: 5px; background: var(--surface-2); border: 1px solid var(--line); color: var(--ink); font: 600 11px/1 var(--sans); }
|
|
115
|
+
.world-mark { width: 30px; height: 30px; border-radius: 7px; font-size: 14px; background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
|
116
|
+
.content { padding: 18px 28px 48px; min-width: 0; }
|
|
117
|
+
.content-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; margin: 0 0 16px; }
|
|
118
|
+
.content-head h2 { margin: 0; font-size: 20px; }
|
|
119
|
+
.head-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
120
|
+
.head-actions .clock { flex: none; padding: 4px 10px; background: var(--surface); }
|
|
121
|
+
.head-actions .clock-now { font-size: 12.5px; }
|
|
122
|
+
.head-actions .clock { position: relative; display: flex; align-items: center; gap: 10px; }
|
|
123
|
+
.head-actions .clock-more { margin: 0; }
|
|
124
|
+
details.menu, .clock-more, .as-of { position: relative; }
|
|
125
|
+
details.menu > summary { list-style: none; cursor: pointer; }
|
|
126
|
+
details.menu > summary::-webkit-details-marker { display: none; }
|
|
127
|
+
.popover, .as-of > form { position: absolute; right: 0; top: calc(100% + 6px); z-index: 20; width: min(440px, 80vw); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 12px 14px; display: grid; gap: 8px; max-width: none; }
|
|
128
|
+
.popover pre { max-width: none; }
|
|
129
|
+
a.button, summary.button { display: inline-block; padding: 5px 12px; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--surface); color: var(--ink); text-decoration: none; font-weight: 500; }
|
|
130
|
+
.block { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
|
|
131
|
+
.block-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--line); }
|
|
132
|
+
.block-head h3 { margin: 0; font-size: 13.5px; }
|
|
133
|
+
.block-head a { font-size: 12.5px; }
|
|
134
|
+
table.dense { width: 100%; border-collapse: collapse; margin: 0; }
|
|
135
|
+
table.dense td { padding: 8px 14px; border-top: 1px solid var(--line); vertical-align: middle; }
|
|
136
|
+
table.dense tr:first-child td { border-top: 0; }
|
|
137
|
+
.vendor-cell { display: flex; align-items: center; gap: 8px; font-weight: 500; }
|
|
138
|
+
.vendor-cell a { color: var(--ink); text-decoration: none; }
|
|
139
|
+
.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
140
|
+
@media (max-width: 900px) { .shell { grid-template-columns: 1fr; } .side { position: static; height: auto; } .overview-grid { grid-template-columns: 1fr; } .content { padding: 14px; } }
|
|
141
|
+
/* a vendor's mark keeps its own colours on a light tile in either theme, as an app icon does (a dark brand colour
|
|
142
|
+
disappears on the dark theme's surface; a mark drawn in the text's colour stays dark on the tile) */
|
|
143
|
+
.mark.logo { background: var(--volter-color-surface); color: var(--volter-color-ink); }
|
|
144
|
+
.mark.logo svg { width: 13px; height: 13px; display: block; }
|
|
145
|
+
.side-vendors .mark, .mini-feed .mark, .vendor-cell .mark { width: 22px; height: 22px; }
|
|
146
|
+
header .crumbs { display: flex; align-items: center; gap: 8px; color: var(--muted); }
|
|
147
|
+
header .crumbs .crumb-world { color: var(--ink); font-weight: 600; }
|
|
148
|
+
.crumb-sep { color: var(--faint); }
|
|
149
|
+
#head-slot { display: contents; }
|
|
150
|
+
summary.button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
|
151
|
+
.side-group { margin: 14px 10px 4px; color: var(--faint); font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
152
|
+
.side-nav > .side-group:first-child { margin-top: 2px; }
|
|
153
|
+
.side-nav .side-vendors { margin: 2px 0 0; padding: 0; border-left: 0; }
|
|
154
|
+
.side-nav .side-vendors a { padding: 5px 10px; }
|
|
155
|
+
.all-screens .mark { font-size: 12px; }
|
|
156
|
+
.branch-picker { margin: 12px 0 0; }
|
|
157
|
+
.branch-picker > summary { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-2); list-style: none; cursor: pointer; }
|
|
158
|
+
.branch-picker > summary::-webkit-details-marker { display: none; }
|
|
159
|
+
.branch-picker > summary::after { content: "▾"; margin-left: auto; color: var(--muted); font-size: 11px; }
|
|
160
|
+
.branch-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
161
|
+
.branch-glyph { color: var(--muted); flex: none; }
|
|
162
|
+
.branch-picker .popover { left: 0; right: 0; width: auto; }
|
|
163
|
+
.branch-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
|
|
164
|
+
.branch-list a, .branch-list > li > span { display: flex; justify-content: space-between; gap: 8px; padding: 5px 8px; border-radius: 5px; color: var(--ink); text-decoration: none; }
|
|
165
|
+
.branch-list a:hover, .branch-list a[aria-current="page"] { background: var(--surface-2); }
|
|
166
|
+
.content-head h2 .vendor-cell { display: inline-flex; font-weight: 600; }
|
|
167
|
+
.top-picker { margin: 0 0 6px; }
|
|
168
|
+
.top-picker > summary .mark { width: 22px; height: 22px; font-size: 11px; }
|
|
169
|
+
.mark.world-mark.small { width: 22px; height: 22px; font-size: 11px; border-radius: 5px; }
|
|
170
|
+
a.button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
|
171
|
+
body.portal .card { text-align: left; padding: 30px 30px 26px; }
|
|
172
|
+
body.portal .card h2 { margin: 0 0 4px; font-size: 20px; }
|
|
173
|
+
body.portal .card .brand { display: flex; align-items: center; gap: 8px; font-weight: 600; margin: 0 0 18px; color: var(--ink); }
|
|
174
|
+
body.portal .card a.button.wide { display: block; text-align: center; padding: 10px 14px; margin: 18px 0 12px; }
|
|
175
|
+
body.portal .card .small { font-size: 12.5px; }
|
|
176
|
+
body.portal .card .links { display: flex; gap: 16px; margin: 18px 0 0; padding-top: 14px; border-top: 1px solid var(--line); font-size: 13px; }
|
|
177
|
+
.services th, .content table.dense th { padding: 8px 14px; border-bottom: 1px solid var(--line); background: var(--surface-2); }
|
|
178
|
+
/* cards: every section that holds a thing; the danger zone */
|
|
179
|
+
section { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; margin: 14px 0; }
|
|
180
|
+
section > h2:first-child, section > h3:first-child { margin-top: 0; }
|
|
181
|
+
.danger { margin-top: 14px; padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--line)); background: var(--danger-soft); border-radius: var(--radius); }
|
|
182
|
+
.danger button { border-color: var(--danger); color: var(--danger-text); }
|
|
183
|
+
.danger button:hover:not(:disabled) { background: var(--danger); color: var(--volter-text-on-strong); }
|
|
184
|
+
|
|
185
|
+
/* ── the shared conventions: dialogs, confirmations, toasts, skeletons, menus, meters, tabs, the phone's drawer ── */
|
|
186
|
+
dialog.dialog { padding: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--ink); box-shadow: var(--volter-shadow-dialog); width: min(520px, calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow: auto; }
|
|
187
|
+
dialog.dialog.wide { width: min(720px, calc(100vw - 32px)); }
|
|
188
|
+
dialog.dialog::backdrop { background: var(--volter-overlay-scrim); }
|
|
189
|
+
dialog.dialog[open] { animation: dialog-in var(--volter-motion-fast, 140ms) ease-out; }
|
|
190
|
+
@keyframes dialog-in { from { opacity: 0; transform: translateY(6px) scale(0.99); } to { opacity: 1; transform: none; } }
|
|
191
|
+
.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 20px 0; }
|
|
192
|
+
.dialog-head h2 { margin: 0; font-size: 16px; }
|
|
193
|
+
.dialog-body { padding: 12px 20px 18px; display: grid; gap: 12px; }
|
|
194
|
+
.dialog-body form { margin: 0; max-width: none; }
|
|
195
|
+
.dialog-body p { margin: 0; }
|
|
196
|
+
.dialog-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--line); background: var(--surface-2); border-radius: 0 0 12px 12px; }
|
|
197
|
+
.confirm-body { color: var(--muted); }
|
|
198
|
+
.typed-confirm { display: grid; gap: 6px; }
|
|
199
|
+
.typed-confirm p { margin: 0; color: var(--muted); }
|
|
200
|
+
.confirm-body strong, .confirm-body code { color: var(--ink); }
|
|
201
|
+
button.icon-button, summary.icon-button { display: inline-grid; place-items: center; min-width: 30px; height: 30px; padding: 0 8px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); font-size: 14px; line-height: 1; cursor: pointer; }
|
|
202
|
+
button.icon-button:hover, summary.icon-button:hover { background: var(--surface-2); color: var(--ink); border-color: var(--line); }
|
|
203
|
+
button.danger-button { background: var(--danger); border-color: var(--danger); color: var(--volter-text-on-strong); }
|
|
204
|
+
button.danger-button:hover:not(:disabled) { color: var(--volter-text-on-strong); filter: brightness(1.06); }
|
|
205
|
+
button.danger-outline { border-color: color-mix(in srgb, var(--danger) 55%, var(--line)); color: var(--danger-text); background: var(--surface); }
|
|
206
|
+
button.danger-outline:hover:not(:disabled) { background: var(--danger); border-color: var(--danger); color: var(--volter-text-on-strong); }
|
|
207
|
+
|
|
208
|
+
.toasts { position: fixed; right: 16px; bottom: 16px; z-index: 100; display: grid; gap: 8px; width: min(380px, calc(100vw - 32px)); }
|
|
209
|
+
.toast { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 10px 10px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); box-shadow: var(--volter-shadow-floating); font-size: 13px; animation: dialog-in var(--volter-motion-fast, 140ms) ease-out; }
|
|
210
|
+
.toast.ok { border-left: 3px solid var(--volter-status-healthy-base); }
|
|
211
|
+
.toast.error { border-left: 3px solid var(--danger); color: var(--ink); background: var(--surface); }
|
|
212
|
+
.toast .icon-button { min-width: 24px; height: 24px; }
|
|
213
|
+
.toast[role="alert"] { padding: 10px 10px 10px 14px; }
|
|
214
|
+
|
|
215
|
+
.skeleton { display: grid; gap: 10px; padding: 14px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
|
216
|
+
.skeleton span { display: block; height: 12px; border-radius: 4px; background: linear-gradient(90deg, var(--surface-2) 0%, var(--volter-surface-inset) 50%, var(--surface-2) 100%); background-size: 200% 100%; animation: shimmer 1.3s linear infinite; }
|
|
217
|
+
@keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
|
218
|
+
@media (prefers-reduced-motion: reduce) { .skeleton span, dialog.dialog[open], .toast { animation: none; } }
|
|
219
|
+
.error-state { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
220
|
+
|
|
221
|
+
details.row-menu { display: inline-block; }
|
|
222
|
+
details.row-menu > summary { list-style: none; }
|
|
223
|
+
details.row-menu > summary::-webkit-details-marker { display: none; }
|
|
224
|
+
/* a table's block clips what leaves it (its corners, its sideways scroll); an open row menu is let out of it */
|
|
225
|
+
:is(.block, .card):has(details.row-menu[open]) { overflow: visible; }
|
|
226
|
+
.menu-list { width: max-content; min-width: 180px; padding: 4px; gap: 0; }
|
|
227
|
+
.menu-list a, .menu-list button { display: block; width: 100%; text-align: left; padding: 7px 10px; border: 0; border-radius: 5px; background: transparent; color: var(--ink); font-size: 13px; text-decoration: none; }
|
|
228
|
+
.menu-list a:hover, .menu-list button:hover:not(:disabled) { background: var(--surface-2); color: var(--ink); }
|
|
229
|
+
.menu-list .danger-item { color: var(--danger-text); }
|
|
230
|
+
.menu-list .danger-item:hover:not(:disabled) { background: var(--danger-soft); color: var(--danger-text); }
|
|
231
|
+
|
|
232
|
+
.empty-state { display: grid; justify-items: center; gap: 6px; text-align: center; padding: 40px 20px; background: var(--surface); border: 1px dashed var(--line-strong); border-radius: var(--radius); }
|
|
233
|
+
.empty-title { font-weight: 600; font-size: 14.5px; margin: 0; }
|
|
234
|
+
.empty-body { color: var(--muted); max-width: 460px; }
|
|
235
|
+
.empty-action { margin-top: 8px; }
|
|
236
|
+
|
|
237
|
+
.copy-field { display: grid; gap: 4px; }
|
|
238
|
+
.copy-label { color: var(--muted); font-size: 12.5px; }
|
|
239
|
+
.copy-row { display: flex; gap: 6px; }
|
|
240
|
+
.copy-row input { flex: 1; min-width: 0; font: 12.5px/1.4 var(--mono); background: var(--surface-2); }
|
|
241
|
+
.copy-field + .copy-field { margin-top: 12px; }
|
|
242
|
+
|
|
243
|
+
.meter { display: grid; gap: 6px; }
|
|
244
|
+
.meter-head { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; }
|
|
245
|
+
.meter-num { font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
246
|
+
.meter-track { height: 8px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--line); overflow: hidden; }
|
|
247
|
+
.meter-track span { display: block; height: 100%; border-radius: 999px; background: var(--volter-data-base); }
|
|
248
|
+
.meter.near .meter-track span { background: var(--volter-status-attention-base); }
|
|
249
|
+
.meter.full .meter-track span { background: var(--danger); }
|
|
250
|
+
.meter-note { font-size: 12px; }
|
|
251
|
+
|
|
252
|
+
.subnav { display: flex; flex-wrap: wrap; gap: 2px; border-bottom: 1px solid var(--line); margin: 0 0 16px; }
|
|
253
|
+
.subnav a { padding: 8px 12px; margin-bottom: -1px; border-bottom: 2px solid transparent; color: var(--muted); text-decoration: none; font-weight: 500; }
|
|
254
|
+
.subnav a:hover { color: var(--ink); }
|
|
255
|
+
.subnav a.on { color: var(--ink); border-bottom-color: var(--ink); }
|
|
256
|
+
|
|
257
|
+
button.drawer-button { display: none; }
|
|
258
|
+
@media (max-width: 900px) {
|
|
259
|
+
button.drawer-button { display: inline-grid; }
|
|
260
|
+
main.app > header { min-width: 0; }
|
|
261
|
+
main.app > header h1 { flex: none; }
|
|
262
|
+
main.app .side { display: none; }
|
|
263
|
+
main.app[data-drawer] .side { display: block; position: fixed; inset: 47px 0 0 0; width: min(300px, 86vw); height: auto; align-self: stretch; z-index: 30; box-shadow: var(--volter-shadow-floating); }
|
|
264
|
+
main.app[data-drawer]::after { content: ""; position: fixed; inset: 47px 0 0 0; background: var(--volter-overlay-scrim); z-index: 29; }
|
|
265
|
+
}
|
|
266
|
+
/* the one primary action of a page or a dialog */
|
|
267
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
|
268
|
+
button.primary:hover:not(:disabled) { color: var(--accent-ink); filter: brightness(1.06); }
|
|
269
|
+
/* a phone: the top bar keeps the brand's mark without its name, so where you are fits beside it */
|
|
270
|
+
@media (max-width: 720px) { main.app > header .brand-name { display: none; } main.app > header { flex-wrap: nowrap; } main.app > header .crumbs { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } }
|
|
271
|
+
|
|
272
|
+
/* the theme choice: System, Light, Dark as one segmented control */
|
|
273
|
+
.theme-choice { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); }
|
|
274
|
+
.theme-choice button { border: 0; background: transparent; padding: 4px 10px; font-size: 12.5px; color: var(--muted); border-radius: 6px; }
|
|
275
|
+
.theme-choice button[aria-checked="true"] { background: var(--surface); color: var(--ink); box-shadow: 0 0 0 1px var(--line); }
|
|
276
|
+
.theme-choice button:hover:not([aria-checked="true"]) { color: var(--ink); border: 0; }
|
|
277
|
+
.menu-list .menu-theme { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: 4px 0; }
|
|
278
|
+
.menu-list .theme-choice button { display: inline-block; width: auto; padding: 3px 8px; }
|
|
279
|
+
.side-foot { margin: 18px 10px 4px; }
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// THE KIT (docs/contributing/architecture.md, "The hosted product"): the pieces every Volter World page shares — the
|
|
2
|
+
// brand's mark, a door read's loading and error faces, instants, counts and vendors' marks — so the dashboard and the
|
|
3
|
+
// platform's pages read as one product. Nothing here knows an org, a member, a plan or a price: the dashboard imports
|
|
4
|
+
// it, and a laptop's World must never carry the platform's words.
|
|
5
|
+
import React, { useEffect, useId, useRef, useState } from 'react';
|
|
6
|
+
import { VENDOR_LOGOS } from '../vendor-logos.ts';
|
|
7
|
+
|
|
8
|
+
/** Volter World's mark, served by the brand (brand.volter.ai). */
|
|
9
|
+
export const WORLD_LOGO = 'https://brand.volter.ai/logo/twin-world/svg?size=64&variant=mono';
|
|
10
|
+
|
|
11
|
+
/** The product's name beside its mark: the top bar's first thing, a link home. */
|
|
12
|
+
export function Brand({ href, onClick }: { href: string; onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void }): React.JSX.Element {
|
|
13
|
+
return <h1><a href={href} onClick={onClick}><img src={WORLD_LOGO} alt="" width={24} height={24} style={{ verticalAlign: 'middle' }} /><span className="brand-name"> Volter World</span></a></h1>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A door's own error text, or its status when it said nothing. */
|
|
17
|
+
export async function failure(r: Response): Promise<string> { const b = (await r.json().catch(() => ({}))) as { error?: string }; return b.error ?? `answered ${r.status}`; }
|
|
18
|
+
|
|
19
|
+
export type Load<T> = { state: 'loading' } | { state: 'ok'; data: T } | { state: 'error'; status: number; error: string };
|
|
20
|
+
/** One door read through `read`: loading → its answer or its error. A new `version` re-reads and keeps the last answer on
|
|
21
|
+
* screen meanwhile; a new `key` (the credential) or path starts from loading. */
|
|
22
|
+
export function useDoor<T>(read: (path: string) => Promise<Response>, path: string | null, version = 0, key = ''): Load<T> {
|
|
23
|
+
const [load, setLoad] = useState<Load<T>>({ state: 'loading' });
|
|
24
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: a new door (credential or path) starts from loading
|
|
25
|
+
useEffect(() => { setLoad({ state: 'loading' }); }, [key, path]);
|
|
26
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `version` is the re-read trigger; `read` is new each render, `key` stands for it
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!path) return;
|
|
29
|
+
let live = true;
|
|
30
|
+
void (async () => {
|
|
31
|
+
try {
|
|
32
|
+
const r = await read(path);
|
|
33
|
+
const next: Load<T> = r.ok ? { state: 'ok', data: (await r.json()) as T } : { state: 'error', status: r.status, error: await failure(r) };
|
|
34
|
+
if (live) setLoad(next);
|
|
35
|
+
} catch (e) { if (live) setLoad({ state: 'error', status: 0, error: String(e) }); }
|
|
36
|
+
})();
|
|
37
|
+
return () => { live = false; };
|
|
38
|
+
}, [key, path, version]);
|
|
39
|
+
return load;
|
|
40
|
+
}
|
|
41
|
+
/** The loading and error faces every page shares: a skeleton while it loads (its words for a screen reader), the door's
|
|
42
|
+
* own words when it refused, and a way to ask again when the page has one. */
|
|
43
|
+
export function Pending({ load, what, retry, lines = 3 }: { load: Load<unknown>; what: string; retry?: () => void; lines?: number }): React.JSX.Element | null {
|
|
44
|
+
if (load.state === 'loading') return <Skeleton lines={lines} label={`Loading ${what}…`} />;
|
|
45
|
+
if (load.state === 'error') return <div role="alert" className="error-state"><span>Could not read {what}: {load.error}</span>{retry ? <button type="button" onClick={retry}>Try again</button> : null}</div>;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** Grey lines where content is coming: the shape of a list, not the word "Loading". */
|
|
49
|
+
export function Skeleton({ lines = 3, label = 'Loading…' }: { lines?: number; label?: string }): React.JSX.Element {
|
|
50
|
+
return <div className="skeleton" role="status" aria-label={label}>{Array.from({ length: lines }, (_, i) => <span key={i} style={{ width: `${[92, 76, 84, 64, 88][i % 5]}%` }} />)}</div>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A moment in the viewer's own locale, the ISO instant in its title. */
|
|
54
|
+
export function At({ iso, time }: { iso?: string | null; time?: boolean }): React.JSX.Element {
|
|
55
|
+
if (!iso) return <span>—</span>;
|
|
56
|
+
const d = new Date(iso);
|
|
57
|
+
return <time dateTime={iso} title={iso}>{Number.isNaN(d.getTime()) ? iso : time ? d.toLocaleTimeString() : d.toLocaleString()}</time>;
|
|
58
|
+
}
|
|
59
|
+
/** An instant as the viewer's locale writes it, or a dash. */
|
|
60
|
+
export const when = (iso?: string | null): string => (iso ? new Date(iso).toLocaleString() : '—');
|
|
61
|
+
/** A date in the person's own calendar — a UTC slice reads a day ahead in the Americas. */
|
|
62
|
+
export const day = (iso: string): string => { const t = Date.parse(iso); return Number.isNaN(t) ? iso : new Date(t).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); };
|
|
63
|
+
/** "4m ago", "2h ago", "3d ago": how long since an instant, as a dashboard says it. */
|
|
64
|
+
export function ago(iso?: string | null): string {
|
|
65
|
+
const t = iso ? Date.parse(iso) : Number.NaN; if (Number.isNaN(t)) return '—';
|
|
66
|
+
const s = Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
67
|
+
return s < 60 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86_400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86_400)}d ago`;
|
|
68
|
+
}
|
|
69
|
+
/** "3 issues", "1 repository": a count of a thing in plain words. */
|
|
70
|
+
export function counted(n: number, type: string): string {
|
|
71
|
+
const w = type.replace(/[_-]+/g, ' ');
|
|
72
|
+
const many = w.endsWith('s') ? w : /[^aeiou]y$/.test(w) ? `${w.slice(0, -1)}ies` : /(ch|sh|x)$/.test(w) ? `${w}es` : `${w}s`;
|
|
73
|
+
return `${n} ${n === 1 ? w : many}`;
|
|
74
|
+
}
|
|
75
|
+
/** "a, b and c". */
|
|
76
|
+
export const listed = (names: string[]): string => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
77
|
+
|
|
78
|
+
/** A vendor's name as its own people write it. */
|
|
79
|
+
export const VENDOR_NAMES: Record<string, string> = { github: 'GitHub', openai: 'OpenAI', aws: 'AWS', hubspot: 'HubSpot', paypal: 'PayPal', youtube: 'YouTube', tiktok: 'TikTok', linkedin: 'LinkedIn', xai: 'xAI', sendgrid: 'SendGrid', elevenlabs: 'ElevenLabs', assemblyai: 'AssemblyAI', planetscale: 'PlanetScale', googleoauth: 'Google sign-in', npm: 'npm', 'npm-registry': 'npm registry', posthog: 'PostHog', mongodb: 'MongoDB', pagerduty: 'PagerDuty', mailgun: 'Mailgun', turbopuffer: 'turbopuffer' };
|
|
80
|
+
export const vendorName = (v: string): string => VENDOR_NAMES[v] ?? v.charAt(0).toUpperCase() + v.slice(1);
|
|
81
|
+
/** A vendor's mark: its logo (vendor-logos.ts, Simple Icons), else its initial. */
|
|
82
|
+
export function Mark({ vendor }: { vendor: string }): React.JSX.Element {
|
|
83
|
+
const logo = VENDOR_LOGOS[vendor];
|
|
84
|
+
return <span className={logo ? 'mark logo' : 'mark'} aria-hidden="true">{logo ? <svg viewBox="0 0 24 24"><path d={logo.d} fill={logo.fill} /></svg> : vendorName(vendor).charAt(0)}</span>;
|
|
85
|
+
}
|
|
86
|
+
/** A thing's initial on the accent square (a World, an org): the sidebar's and a table's first column. */
|
|
87
|
+
export function Initial({ of, small }: { of: string; small?: boolean }): React.JSX.Element {
|
|
88
|
+
return <span className={small ? 'mark world-mark small' : 'mark world-mark'} aria-hidden="true">{(of || '+').charAt(0).toUpperCase()}</span>;
|
|
89
|
+
}
|
|
90
|
+
/** Close the menu (`<details>`) a click came from: a picked item leaves its popover. */
|
|
91
|
+
export const closeMenu = (e: React.MouseEvent<HTMLElement>): void => { (e.currentTarget.closest('details') as HTMLDetailsElement | null)?.removeAttribute('open'); };
|
|
92
|
+
// ── the theme: the system's, or one the person picked (kept per origin; every colour is a light-dark() role) ─────────
|
|
93
|
+
export type Theme = 'system' | 'light' | 'dark';
|
|
94
|
+
const THEME_KEY = 'volter.theme';
|
|
95
|
+
export const storedTheme = (): Theme => { try { const t = localStorage.getItem(THEME_KEY); return t === 'light' || t === 'dark' ? t : 'system'; } catch { return 'system'; } };
|
|
96
|
+
/** Put a theme on the page (the stored one by default): called once as an app starts, before it renders. */
|
|
97
|
+
export function applyTheme(t: Theme = storedTheme()): void { const r = document.documentElement; if (t === 'system') r.removeAttribute('data-theme'); else r.setAttribute('data-theme', t); }
|
|
98
|
+
/** System, Light or Dark, as a segmented choice. */
|
|
99
|
+
export function ThemeChoice(): React.JSX.Element {
|
|
100
|
+
const [theme, setTheme] = useState<Theme>(storedTheme);
|
|
101
|
+
const pick = (t: Theme): void => { try { if (t === 'system') localStorage.removeItem(THEME_KEY); else localStorage.setItem(THEME_KEY, t); } catch { /* a private window keeps it for this page */ } applyTheme(t); setTheme(t); };
|
|
102
|
+
return (
|
|
103
|
+
<div className="theme-choice" role="radiogroup" aria-label="Theme">
|
|
104
|
+
{(['system', 'light', 'dark'] as const).map((t) => <button key={t} type="button" role="radio" aria-checked={theme === t} onClick={() => pick(t)}>{t === 'system' ? 'System' : t === 'light' ? 'Light' : 'Dark'}</button>)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Menus close as menus do: Esc closes the open one and puts focus back on its button; a click outside closes every
|
|
110
|
+
* open one. Rendered once at an app's root. */
|
|
111
|
+
export function MenuDismiss(): null {
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const open = (): HTMLDetailsElement[] => [...document.querySelectorAll<HTMLDetailsElement>('details.menu[open], details.clock-more[open]')];
|
|
114
|
+
const esc = (e: KeyboardEvent): void => {
|
|
115
|
+
if (e.key !== 'Escape' || document.querySelector('dialog[open]')) return;
|
|
116
|
+
const menus = open(); if (!menus.length) return;
|
|
117
|
+
for (const d of menus) d.removeAttribute('open');
|
|
118
|
+
(menus[menus.length - 1]!.querySelector('summary') as HTMLElement | null)?.focus();
|
|
119
|
+
};
|
|
120
|
+
const outside = (e: MouseEvent): void => { const t = e.target as Element | null; for (const d of open()) if (!t || !d.contains(t)) d.removeAttribute('open'); };
|
|
121
|
+
document.addEventListener('keydown', esc); document.addEventListener('click', outside);
|
|
122
|
+
return () => { document.removeEventListener('keydown', esc); document.removeEventListener('click', outside); };
|
|
123
|
+
}, []);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── dialogs, confirmations and toasts: the conventions every page shares ─────────────────────────────────────────────
|
|
128
|
+
/** A modal dialog: the browser's own (`<dialog>` shown modal), so focus stays in it, Esc closes it and the page behind is
|
|
129
|
+
* inert; labelled by its title. A press on the backdrop closes it too. */
|
|
130
|
+
export function Dialog({ open, onClose, title, children, footer, wide }: { open: boolean; onClose: () => void; title: React.ReactNode; children: React.ReactNode; footer?: React.ReactNode; wide?: boolean }): React.JSX.Element {
|
|
131
|
+
const ref = useRef<HTMLDialogElement>(null); const id = useId();
|
|
132
|
+
// opening moves focus to the dialog's first control (its Close); the first field of its body is where a person types
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const d = ref.current; if (!d) return;
|
|
135
|
+
if (open && !d.open) {
|
|
136
|
+
d.showModal();
|
|
137
|
+
const field = d.querySelector<HTMLElement>('.dialog-body :is(input:not([type=hidden]):not([disabled]), select, textarea)');
|
|
138
|
+
field?.focus();
|
|
139
|
+
// a suggested value (a token's default name) is selected, so typing replaces it
|
|
140
|
+
if (field instanceof HTMLInputElement && field.value && /^(text|search|email|url)$/.test(field.type)) field.select();
|
|
141
|
+
}
|
|
142
|
+
if (!open && d.open) d.close();
|
|
143
|
+
}, [open]);
|
|
144
|
+
return (
|
|
145
|
+
<dialog ref={ref} className={wide ? 'dialog wide' : 'dialog'} aria-labelledby={id} onClose={onClose} onCancel={(e) => { e.preventDefault(); onClose(); }} onMouseDown={(e) => { if (e.target === ref.current) onClose(); }}>
|
|
146
|
+
{open ? <>
|
|
147
|
+
<div className="dialog-head"><h2 id={id}>{title}</h2><button type="button" className="icon-button" aria-label="Close" onClick={onClose}>✕</button></div>
|
|
148
|
+
<div className="dialog-body">{children}</div>
|
|
149
|
+
{footer ? <div className="dialog-foot">{footer}</div> : null}
|
|
150
|
+
</> : null}
|
|
151
|
+
</dialog>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type ConfirmAsk = { title: string; body?: React.ReactNode; action: string; danger?: boolean; typed?: string };
|
|
156
|
+
const CONFIRM_EVENT = 'volter:confirm';
|
|
157
|
+
/** Ask before an act that cannot be taken back: a dialog whose button names the act, and for the worst of them the thing's
|
|
158
|
+
* name typed first (GitHub's and Vercel's way). Resolves true when confirmed; `<ConfirmHost/>`, mounted once, shows it. */
|
|
159
|
+
export function confirmAct(ask: ConfirmAsk): Promise<boolean> {
|
|
160
|
+
return new Promise((resolve) => { window.dispatchEvent(new CustomEvent(CONFIRM_EVENT, { detail: { ask, resolve } })); });
|
|
161
|
+
}
|
|
162
|
+
export function ConfirmHost(): React.JSX.Element {
|
|
163
|
+
const [pending, setPending] = useState<{ ask: ConfirmAsk; resolve: (ok: boolean) => void } | null>(null);
|
|
164
|
+
const [typed, setTyped] = useState('');
|
|
165
|
+
useEffect(() => { const on = (e: Event): void => { setTyped(''); setPending((e as CustomEvent).detail); }; window.addEventListener(CONFIRM_EVENT, on); return () => window.removeEventListener(CONFIRM_EVENT, on); }, []);
|
|
166
|
+
const done = (ok: boolean): void => { pending?.resolve(ok); setPending(null); };
|
|
167
|
+
const ask = pending?.ask; const ready = !ask?.typed || typed === ask.typed;
|
|
168
|
+
return (
|
|
169
|
+
<Dialog open={Boolean(pending)} onClose={() => done(false)} title={ask?.title ?? ''} footer={<><button type="button" onClick={() => done(false)}>Cancel</button><button type="button" className={ask?.danger ? 'danger-button' : 'primary'} disabled={!ready} onClick={() => done(true)}>{ask?.action}</button></>}>
|
|
170
|
+
{ask?.body ? <div className="confirm-body">{ask.body}</div> : null}
|
|
171
|
+
{ask?.typed ? <div className="typed-confirm"><p>Type <code>{ask.typed}</code> to confirm.</p><input autoFocus aria-label={`Type ${ask.typed} to confirm`} value={typed} onChange={(e) => setTyped(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && ready) done(true); }} autoComplete="off" spellCheck={false} /></div> : null}
|
|
172
|
+
</Dialog>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type Toast = { id: number; text: string; kind: 'ok' | 'error' | 'info' };
|
|
177
|
+
const TOAST_EVENT = 'volter:toast';
|
|
178
|
+
/** Say what just happened, briefly, in the corner: a result, not a line left on the page. Errors stay until dismissed. */
|
|
179
|
+
export function toast(text: string, kind: Toast['kind'] = 'ok'): void { window.dispatchEvent(new CustomEvent(TOAST_EVENT, { detail: { text, kind } })); }
|
|
180
|
+
export function Toaster(): React.JSX.Element {
|
|
181
|
+
const [list, setList] = useState<Toast[]>([]);
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
let n = 0;
|
|
184
|
+
const on = (e: Event): void => {
|
|
185
|
+
const { text, kind } = (e as CustomEvent<{ text: string; kind: Toast['kind'] }>).detail; const id = ++n;
|
|
186
|
+
setList((l) => [...l.slice(-3), { id, text, kind }]);
|
|
187
|
+
if (kind !== 'error') setTimeout(() => setList((l) => l.filter((t) => t.id !== id)), 4500);
|
|
188
|
+
};
|
|
189
|
+
window.addEventListener(TOAST_EVENT, on); return () => window.removeEventListener(TOAST_EVENT, on);
|
|
190
|
+
}, []);
|
|
191
|
+
return (
|
|
192
|
+
<div className="toasts" role="region" aria-label="Notifications">
|
|
193
|
+
{list.map((t) => <div key={t.id} className={`toast ${t.kind}`} role={t.kind === 'error' ? 'alert' : 'status'}><span>{t.text}</span><button type="button" className="icon-button" aria-label="Dismiss" onClick={() => setList((l) => l.filter((x) => x.id !== t.id))}>✕</button></div>)}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A value to copy (an address, a command, a secret shown once): the value in a monospace field and a Copy button. */
|
|
199
|
+
export function CopyField({ value, label, secret }: { value: string; label?: string; secret?: boolean }): React.JSX.Element {
|
|
200
|
+
const [copied, setCopied] = useState(false);
|
|
201
|
+
const copy = (): void => { void navigator.clipboard?.writeText(value).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1600); toast(secret ? 'Copied. Keep it somewhere safe' : 'Copied'); }).catch(() => toast('Your browser would not copy it: select it and copy', 'error')); };
|
|
202
|
+
return (
|
|
203
|
+
<div className="copy-field">
|
|
204
|
+
{label ? <span className="copy-label">{label}</span> : null}
|
|
205
|
+
<div className="copy-row"><input readOnly value={value} aria-label={label ?? 'Value'} onFocus={(e) => e.currentTarget.select()} spellCheck={false} /><button type="button" onClick={copy}>{copied ? 'Copied' : 'Copy'}</button></div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A "⋯" menu of a row's acts: each item closes the menu when picked. */
|
|
211
|
+
export function Menu({ label, items }: { label: string; items: Array<{ label: string; onSelect?: () => void; href?: string; danger?: boolean; disabled?: boolean } | null | false> }): React.JSX.Element {
|
|
212
|
+
return (
|
|
213
|
+
<details className="menu row-menu">
|
|
214
|
+
<summary className="icon-button" role="button" aria-haspopup="menu" aria-label={label} title={label}>⋯</summary>
|
|
215
|
+
<div className="popover menu-list" role="menu">
|
|
216
|
+
{items.filter((x): x is Exclude<typeof x, null | false> => Boolean(x)).map((i) => (i.href
|
|
217
|
+
? <a key={i.label} role="menuitem" href={i.href} className={i.danger ? 'danger-item' : undefined} onClick={closeMenu}>{i.label}</a>
|
|
218
|
+
: <button key={i.label} type="button" role="menuitem" disabled={i.disabled} className={i.danger ? 'danger-item' : undefined} onClick={(e) => { closeMenu(e); i.onSelect?.(); }}>{i.label}</button>))}
|
|
219
|
+
</div>
|
|
220
|
+
</details>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Nothing here yet: what the page is for, and its one next step. */
|
|
225
|
+
export function Empty({ title, children, action }: { title: string; children?: React.ReactNode; action?: React.ReactNode }): React.JSX.Element {
|
|
226
|
+
return <div className="empty-state"><p className="empty-title">{title}</p>{children ? <div className="empty-body">{children}</div> : null}{action ? <div className="empty-action">{action}</div> : null}</div>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A measure against its limit, as a bar: calm under 80%, attention from there, danger at the limit. Unlimited shows the count. */
|
|
230
|
+
export function Meter({ label, value, limit, unit = '', note }: { label: string; value: number; limit: number | null; unit?: string; note?: React.ReactNode }): React.JSX.Element {
|
|
231
|
+
// at the limit is attention (nothing more fits); past it is danger
|
|
232
|
+
const share = limit ? Math.min(1, value / limit) : 0; const level = !limit ? 'calm' : value > limit ? 'full' : share >= 0.8 ? 'near' : 'calm';
|
|
233
|
+
return (
|
|
234
|
+
<div className={`meter ${level}`}>
|
|
235
|
+
<div className="meter-head"><span>{label}</span><span className="meter-num">{value.toLocaleString()}{unit}{limit !== null ? <span className="mode"> of {limit.toLocaleString()}{unit}</span> : <span className="mode"> · no limit</span>}</span></div>
|
|
236
|
+
{limit !== null ? <div className="meter-track" role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={limit} aria-valuenow={value}><span style={{ width: `${Math.max(share > 0 ? 2 : 0, Math.round(share * 100))}%` }} /></div> : null}
|
|
237
|
+
{note ? <div className="meter-note mode">{note}</div> : null}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Tabs that are addresses: a page's sub-pages (Settings › Security), each a link. */
|
|
243
|
+
export function SubNav({ label, items }: { label: string; items: Array<{ label: string; href: string; on: boolean; onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void }> }): React.JSX.Element {
|
|
244
|
+
return <nav className="subnav" aria-label={label}>{items.map((i) => <a key={i.href} href={i.href} aria-current={i.on ? 'page' : undefined} className={i.on ? 'on' : undefined} onClick={i.onClick}>{i.label}</a>)}</nav>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** On a phone the sidebar is a drawer: this button in the top bar opens it (the shell carries `data-drawer`). */
|
|
248
|
+
export function DrawerButton(): React.JSX.Element {
|
|
249
|
+
// a tap outside the open drawer, or Esc, closes it, as a drawer does
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
const outside = (e: MouseEvent): void => { const main = document.querySelector('main.app'); const t = e.target as Element | null; if (main?.hasAttribute('data-drawer') && t && !t.closest('.side') && !t.closest('.drawer-button')) main.removeAttribute('data-drawer'); };
|
|
252
|
+
const esc = (e: KeyboardEvent): void => { if (e.key === 'Escape') closeDrawer(); };
|
|
253
|
+
document.addEventListener('click', outside); document.addEventListener('keydown', esc);
|
|
254
|
+
return () => { document.removeEventListener('click', outside); document.removeEventListener('keydown', esc); };
|
|
255
|
+
}, []);
|
|
256
|
+
return <button type="button" className="icon-button drawer-button" aria-label="Menu" onClick={() => { document.querySelector('main.app')?.toggleAttribute('data-drawer'); }}>☰</button>;
|
|
257
|
+
}
|
|
258
|
+
/** Close the drawer after a pick in it (nothing on a wide screen). */
|
|
259
|
+
export const closeDrawer = (): void => { document.querySelector('main.app')?.removeAttribute('data-drawer'); };
|