@verbaly/studio 0.55.0 → 0.56.1
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 +41 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/panel/Actions.svelte +255 -0
- package/dist/panel/Health.svelte +184 -0
- package/dist/panel/Marks.svelte +56 -0
- package/dist/panel/Panel.svelte +337 -0
- package/dist/panel/Panel.svelte.d.ts +12 -0
- package/dist/panel/Rail.svelte +303 -0
- package/dist/panel/Row.svelte +340 -0
- package/dist/panel/api.ts +82 -0
- package/dist/panel/index.ts +18 -0
- package/dist/panel/model.ts +163 -0
- package/dist/panel/tokens.css +106 -0
- package/dist/panel/validate.ts +94 -0
- package/dist/panel/wire.ts +29 -0
- package/dist/panel/words.ts +59 -0
- package/dist/ui/assets/{index-XfXTmvne.css → index-3A7RArYB.css} +1 -1
- package/dist/ui/assets/index-DlWGcJoP.js +3 -0
- package/dist/ui/index.html +3 -3
- package/package.json +27 -5
- package/dist/ui/assets/index-D8NKyN7c.js +0 -3
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Marks from './Marks.svelte';
|
|
3
|
+
import { preview } from './validate';
|
|
4
|
+
import { SIGNAL, STATE } from './words';
|
|
5
|
+
import type { Cell, Row } from './model';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
row: Row;
|
|
9
|
+
shown: string[];
|
|
10
|
+
editing: string | null;
|
|
11
|
+
onEdit: (id: string | null) => void;
|
|
12
|
+
onSave: (locale: string, key: string, text: string) => Promise<string | null>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { row, shown, editing, onEdit, onSave }: Props = $props();
|
|
16
|
+
const cells = $derived(row.cells.filter((cell) => shown.includes(cell.locale)));
|
|
17
|
+
// the row's own state is the worst of the languages you are comparing, so the tile never lies
|
|
18
|
+
const worst = $derived(
|
|
19
|
+
(['broken', 'missing', 'draft', 'done'] as const).find((s) =>
|
|
20
|
+
cells.some((c) => c.state === s),
|
|
21
|
+
) ?? 'done',
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
let draft: string = $state('');
|
|
25
|
+
let saving: boolean = $state(false);
|
|
26
|
+
let refused: string | null = $state(null);
|
|
27
|
+
|
|
28
|
+
const live = $derived(preview(row.source, draft));
|
|
29
|
+
const id = (cell: Cell) => `${cell.locale} ${row.key}`;
|
|
30
|
+
|
|
31
|
+
function open(cell: Cell) {
|
|
32
|
+
draft = cell.text;
|
|
33
|
+
refused = null;
|
|
34
|
+
onEdit(id(cell));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function commit(cell: Cell) {
|
|
38
|
+
if (saving) return;
|
|
39
|
+
saving = true;
|
|
40
|
+
refused = await onSave(cell.locale, row.key, draft);
|
|
41
|
+
saving = false;
|
|
42
|
+
if (!refused) onEdit(null);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function keys(event: KeyboardEvent, cell: Cell) {
|
|
46
|
+
if (event.key === 'Escape') onEdit(null);
|
|
47
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
void commit(cell);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<article class="row" class:open={cells.some((c) => editing === id(c))} data-worst={worst}>
|
|
55
|
+
<span class="tile" data-state={worst}><Marks state={worst} /></span>
|
|
56
|
+
|
|
57
|
+
<span class="row-t">
|
|
58
|
+
<span class="src">{row.source}</span>
|
|
59
|
+
|
|
60
|
+
{#each cells as cell (cell.locale)}
|
|
61
|
+
{#if editing === id(cell)}
|
|
62
|
+
<span class="tl edit">
|
|
63
|
+
<span class="disc" data-state={cell.state}>{cell.locale}</span>
|
|
64
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
65
|
+
<textarea
|
|
66
|
+
class="tf"
|
|
67
|
+
bind:value={draft}
|
|
68
|
+
autofocus
|
|
69
|
+
rows="2"
|
|
70
|
+
aria-label="Translation for {cell.locale}"
|
|
71
|
+
onkeydown={(event) => keys(event, cell)}
|
|
72
|
+
></textarea>
|
|
73
|
+
</span>
|
|
74
|
+
<span class="editbar">
|
|
75
|
+
{#each live.params as param (param.name)}
|
|
76
|
+
<span class="pm" class:gone={param.inSource && !param.inTarget}
|
|
77
|
+
>{param.inSource && !param.inTarget ? '+' : ''}{{param.name}}</span
|
|
78
|
+
>
|
|
79
|
+
{/each}
|
|
80
|
+
{#each live.problems as problem (problem)}
|
|
81
|
+
<span class="vmsg">{problem}</span>
|
|
82
|
+
{/each}
|
|
83
|
+
<!-- the preview is a subset of the gate, so the server only speaks when it saw more -->
|
|
84
|
+
{#if refused && !live.problems.length}
|
|
85
|
+
<span class="vmsg">{refused}</span>
|
|
86
|
+
{/if}
|
|
87
|
+
<button class="btn sm" disabled={saving} onclick={() => commit(cell)}>
|
|
88
|
+
{saving ? 'Saving' : 'Save'}
|
|
89
|
+
</button>
|
|
90
|
+
<button class="btn sm quiet" onclick={() => onEdit(null)}>Cancel</button>
|
|
91
|
+
<span class="keys">Enter saves · Esc closes</span>
|
|
92
|
+
</span>
|
|
93
|
+
{:else}
|
|
94
|
+
<span class="tl">
|
|
95
|
+
<span class="disc" data-state={cell.state}>{cell.locale}</span>
|
|
96
|
+
{#if cell.text}
|
|
97
|
+
<button class="tv" onclick={() => open(cell)} title={cell.text}>{cell.text}</button>
|
|
98
|
+
{:else}
|
|
99
|
+
<button class="tv none" onclick={() => open(cell)}>{STATE.missing.long}</button>
|
|
100
|
+
{/if}
|
|
101
|
+
{#each cell.signals as signal (signal)}
|
|
102
|
+
<span class="sig" title={SIGNAL[signal] ?? signal}>{SIGNAL[signal] ?? signal}</span>
|
|
103
|
+
{/each}
|
|
104
|
+
</span>
|
|
105
|
+
{/if}
|
|
106
|
+
{/each}
|
|
107
|
+
</span>
|
|
108
|
+
</article>
|
|
109
|
+
|
|
110
|
+
<style>
|
|
111
|
+
.row {
|
|
112
|
+
display: flex;
|
|
113
|
+
align-items: flex-start;
|
|
114
|
+
gap: 16px;
|
|
115
|
+
min-height: 64px;
|
|
116
|
+
padding: 12px 20px;
|
|
117
|
+
border-bottom: 1px solid var(--rule);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.row:hover {
|
|
121
|
+
background: var(--ground);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.row.open {
|
|
125
|
+
background: var(--paper);
|
|
126
|
+
box-shadow: inset 3px 0 0 var(--done);
|
|
127
|
+
outline: 1px solid var(--rule);
|
|
128
|
+
outline-offset: -1px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.tile {
|
|
132
|
+
width: 26px;
|
|
133
|
+
height: 26px;
|
|
134
|
+
flex: none;
|
|
135
|
+
margin-top: 2px;
|
|
136
|
+
display: inline-flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: center;
|
|
139
|
+
border-radius: var(--radius);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.tile[data-state='done'] {
|
|
143
|
+
color: var(--rule-strong);
|
|
144
|
+
}
|
|
145
|
+
.tile[data-state='draft'] {
|
|
146
|
+
background: var(--draft-tint);
|
|
147
|
+
color: var(--draft);
|
|
148
|
+
}
|
|
149
|
+
.tile[data-state='missing'] {
|
|
150
|
+
background: var(--missing-tint);
|
|
151
|
+
color: var(--missing);
|
|
152
|
+
}
|
|
153
|
+
.tile[data-state='broken'] {
|
|
154
|
+
background: var(--broken);
|
|
155
|
+
color: var(--paper);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.row-t {
|
|
159
|
+
flex: 1;
|
|
160
|
+
min-width: 0;
|
|
161
|
+
display: flex;
|
|
162
|
+
flex-direction: column;
|
|
163
|
+
gap: 3px;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* the original is context: one line, clipped, quieter than the thing you came to correct */
|
|
167
|
+
.src {
|
|
168
|
+
font-size: 13.5px;
|
|
169
|
+
line-height: 1.3;
|
|
170
|
+
color: var(--muted);
|
|
171
|
+
overflow: hidden;
|
|
172
|
+
text-overflow: ellipsis;
|
|
173
|
+
white-space: nowrap;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.tl {
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: baseline;
|
|
179
|
+
gap: 8px;
|
|
180
|
+
min-width: 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.tl.edit {
|
|
184
|
+
align-items: flex-start;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* a flag is not a language and Windows will not draw one: a disc with the code, in its state */
|
|
188
|
+
.disc {
|
|
189
|
+
width: 20px;
|
|
190
|
+
height: 20px;
|
|
191
|
+
flex: none;
|
|
192
|
+
transform: translateY(3px);
|
|
193
|
+
display: inline-flex;
|
|
194
|
+
align-items: center;
|
|
195
|
+
justify-content: center;
|
|
196
|
+
border-radius: 50%;
|
|
197
|
+
background: var(--hollow);
|
|
198
|
+
color: var(--body);
|
|
199
|
+
font-family: var(--mono);
|
|
200
|
+
font-size: 9px;
|
|
201
|
+
letter-spacing: -0.02em;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.disc[data-state='done'] {
|
|
205
|
+
background: var(--done-tint);
|
|
206
|
+
color: var(--done);
|
|
207
|
+
}
|
|
208
|
+
.disc[data-state='draft'] {
|
|
209
|
+
background: var(--draft-tint);
|
|
210
|
+
color: var(--draft);
|
|
211
|
+
}
|
|
212
|
+
.disc[data-state='missing'] {
|
|
213
|
+
background: var(--missing-tint);
|
|
214
|
+
color: var(--missing);
|
|
215
|
+
}
|
|
216
|
+
.disc[data-state='broken'] {
|
|
217
|
+
background: var(--broken-tint);
|
|
218
|
+
color: var(--broken);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.tv {
|
|
222
|
+
flex: 1;
|
|
223
|
+
min-width: 0;
|
|
224
|
+
padding: 0;
|
|
225
|
+
border: 0;
|
|
226
|
+
background: none;
|
|
227
|
+
text-align: left;
|
|
228
|
+
font-family: var(--serif);
|
|
229
|
+
font-size: 17px;
|
|
230
|
+
line-height: 1.35;
|
|
231
|
+
color: var(--ink);
|
|
232
|
+
overflow: hidden;
|
|
233
|
+
text-overflow: ellipsis;
|
|
234
|
+
white-space: nowrap;
|
|
235
|
+
cursor: text;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* a signal is a margin note, never a state: it points, it does not judge */
|
|
239
|
+
.sig {
|
|
240
|
+
flex: none;
|
|
241
|
+
font-size: 11px;
|
|
242
|
+
color: var(--draft);
|
|
243
|
+
background: var(--draft-tint);
|
|
244
|
+
border-radius: 4px;
|
|
245
|
+
padding: 1px 6px;
|
|
246
|
+
white-space: nowrap;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.tv.none {
|
|
250
|
+
font-family: var(--sans);
|
|
251
|
+
font-size: 13px;
|
|
252
|
+
font-style: italic;
|
|
253
|
+
color: var(--missing);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.tf {
|
|
257
|
+
flex: 1;
|
|
258
|
+
min-width: 0;
|
|
259
|
+
font-family: var(--serif);
|
|
260
|
+
font-size: 17px;
|
|
261
|
+
line-height: 1.45;
|
|
262
|
+
color: var(--ink);
|
|
263
|
+
background: var(--paper);
|
|
264
|
+
border: 1px solid var(--done);
|
|
265
|
+
border-radius: var(--radius);
|
|
266
|
+
padding: 6px 10px;
|
|
267
|
+
resize: vertical;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.tf:focus-visible {
|
|
271
|
+
outline: 2px solid color-mix(in oklab, var(--done) 26%, transparent);
|
|
272
|
+
outline-offset: 1px;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.editbar {
|
|
276
|
+
display: flex;
|
|
277
|
+
align-items: center;
|
|
278
|
+
gap: 8px;
|
|
279
|
+
margin-top: 10px;
|
|
280
|
+
flex-wrap: wrap;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* a param is a chip so you can see it survive, and the one that did not is dashed and red */
|
|
284
|
+
.pm {
|
|
285
|
+
font-family: var(--mono);
|
|
286
|
+
font-size: 12.5px;
|
|
287
|
+
color: var(--body);
|
|
288
|
+
background: var(--hollow);
|
|
289
|
+
border-radius: 4px;
|
|
290
|
+
padding: 1px 5px;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.pm.gone {
|
|
294
|
+
background: var(--broken-tint);
|
|
295
|
+
border: 1px dashed var(--broken);
|
|
296
|
+
color: var(--broken);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.vmsg {
|
|
300
|
+
display: inline-flex;
|
|
301
|
+
align-items: center;
|
|
302
|
+
gap: 6px;
|
|
303
|
+
font-size: 12.5px;
|
|
304
|
+
color: var(--broken);
|
|
305
|
+
background: var(--broken-tint);
|
|
306
|
+
border-radius: var(--radius);
|
|
307
|
+
padding: 4px 9px;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.btn {
|
|
311
|
+
height: 30px;
|
|
312
|
+
padding: 0 12px;
|
|
313
|
+
border: 1px solid var(--done);
|
|
314
|
+
border-radius: var(--radius);
|
|
315
|
+
background: var(--done);
|
|
316
|
+
color: var(--paper);
|
|
317
|
+
font: inherit;
|
|
318
|
+
font-size: 12.5px;
|
|
319
|
+
font-weight: 500;
|
|
320
|
+
cursor: pointer;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.btn.quiet {
|
|
324
|
+
background: var(--paper);
|
|
325
|
+
color: var(--body);
|
|
326
|
+
border-color: var(--rule-strong);
|
|
327
|
+
font-weight: 400;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.btn:disabled {
|
|
331
|
+
opacity: 0.6;
|
|
332
|
+
cursor: default;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.keys {
|
|
336
|
+
font-family: var(--mono);
|
|
337
|
+
font-size: 11px;
|
|
338
|
+
color: var(--muted);
|
|
339
|
+
}
|
|
340
|
+
</style>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ask, type Answer } from './wire';
|
|
2
|
+
import type { RawState } from './model';
|
|
3
|
+
|
|
4
|
+
// Everything the panel can ask for, as one object it is handed rather than one it imports.
|
|
5
|
+
export interface Saved {
|
|
6
|
+
locale: string;
|
|
7
|
+
key: string;
|
|
8
|
+
clearedDraft: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ReadResult {
|
|
12
|
+
locale: string;
|
|
13
|
+
approved: number;
|
|
14
|
+
keys: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Check {
|
|
18
|
+
level: 'ok' | 'warn' | 'error';
|
|
19
|
+
check: string;
|
|
20
|
+
message: string;
|
|
21
|
+
fix?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Health {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
entries: Check[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Added {
|
|
30
|
+
added: Record<string, string[]>;
|
|
31
|
+
found: number;
|
|
32
|
+
messages: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Plan {
|
|
36
|
+
pending: Record<string, string[]>;
|
|
37
|
+
total: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Job {
|
|
41
|
+
id: string;
|
|
42
|
+
kind: 'translate';
|
|
43
|
+
state: 'running' | 'done' | 'failed';
|
|
44
|
+
total: number;
|
|
45
|
+
done: number;
|
|
46
|
+
locale?: string;
|
|
47
|
+
message?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface StudioApi {
|
|
51
|
+
state(): Promise<Answer<RawState>>;
|
|
52
|
+
health(): Promise<Answer<Health>>;
|
|
53
|
+
save(locale: string, key: string, text: string): Promise<Answer<Saved>>;
|
|
54
|
+
setRead(locale: string, keys: string[] | undefined, undo: boolean): Promise<Answer<ReadResult>>;
|
|
55
|
+
extract(): Promise<Answer<Added>>;
|
|
56
|
+
// the plan is its own call so that seeing the cost can never start the spending
|
|
57
|
+
plan(locales: string[]): Promise<Answer<Plan>>;
|
|
58
|
+
translate(locales: string[]): Promise<Answer<Job>>;
|
|
59
|
+
job(id?: string): Promise<Answer<Job | null>>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function serverApi(token: string): StudioApi {
|
|
63
|
+
return {
|
|
64
|
+
state: () => ask<RawState>(token, '/api/state'),
|
|
65
|
+
health: () => ask<Health>(token, '/api/health'),
|
|
66
|
+
save: (locale, key, text) =>
|
|
67
|
+
ask<Saved>(token, `/api/message/${encodeURIComponent(locale)}/${encodeURIComponent(key)}`, {
|
|
68
|
+
method: 'PUT',
|
|
69
|
+
body: { text },
|
|
70
|
+
}),
|
|
71
|
+
setRead: (locale, keys, undo) =>
|
|
72
|
+
ask<ReadResult>(token, '/api/approve', { method: 'POST', body: { locale, keys, undo } }),
|
|
73
|
+
extract: () => ask<Added>(token, '/api/extract', { method: 'POST' }),
|
|
74
|
+
plan: (locales) => {
|
|
75
|
+
const query = locales.map((locale) => `locale=${encodeURIComponent(locale)}`).join('&');
|
|
76
|
+
return ask<Plan>(token, `/api/translate${query ? `?${query}` : ''}`);
|
|
77
|
+
},
|
|
78
|
+
translate: (locales) => ask<Job>(token, '/api/translate', { method: 'POST', body: { locales } }),
|
|
79
|
+
// without an id it asks what is running, which is how a reloaded page finds its bar again
|
|
80
|
+
job: (id) => ask<Job | null>(token, id ? `/api/job/${encodeURIComponent(id)}` : '/api/job'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// What a host needs to render the panel: the component itself is @verbaly/studio/Panel.svelte.
|
|
2
|
+
export { applyState, health, toPanel, visible } from './model';
|
|
3
|
+
export type { Cell, Filter, LocaleHealth, MessageState, Panel, RawState, Row } from './model';
|
|
4
|
+
export { serverApi } from './api';
|
|
5
|
+
export type {
|
|
6
|
+
Added,
|
|
7
|
+
Check,
|
|
8
|
+
Health,
|
|
9
|
+
Job,
|
|
10
|
+
Plan,
|
|
11
|
+
ReadResult,
|
|
12
|
+
Saved,
|
|
13
|
+
StudioApi,
|
|
14
|
+
} from './api';
|
|
15
|
+
export type { Answer } from './wire';
|
|
16
|
+
export { preview } from './validate';
|
|
17
|
+
export type { Preview } from './validate';
|
|
18
|
+
export { FILTER, MARK_AS_READ, SIGNAL, STATE, UNDO } from './words';
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The shapes the panel reads: StudioState restated, so the UI never imports Node code.
|
|
2
|
+
export type MessageState = 'done' | 'draft' | 'missing' | 'broken';
|
|
3
|
+
|
|
4
|
+
export interface Cell {
|
|
5
|
+
locale: string;
|
|
6
|
+
text: string;
|
|
7
|
+
state: MessageState;
|
|
8
|
+
issue?: string;
|
|
9
|
+
signals: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Row {
|
|
13
|
+
key: string;
|
|
14
|
+
source: string;
|
|
15
|
+
cells: Cell[];
|
|
16
|
+
origins: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LocaleHealth {
|
|
20
|
+
locale: string;
|
|
21
|
+
source: boolean;
|
|
22
|
+
total: number;
|
|
23
|
+
done: number;
|
|
24
|
+
draft: number;
|
|
25
|
+
missing: number;
|
|
26
|
+
broken: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Panel {
|
|
30
|
+
root: string;
|
|
31
|
+
scanning: boolean;
|
|
32
|
+
sourceLocale: string;
|
|
33
|
+
locales: string[];
|
|
34
|
+
rows: Row[];
|
|
35
|
+
problems: { scope: string; message: string }[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Catalogs = Record<string, Record<string, unknown>>;
|
|
39
|
+
|
|
40
|
+
export interface RawState {
|
|
41
|
+
root: string;
|
|
42
|
+
dir: string;
|
|
43
|
+
sourceLocale: string;
|
|
44
|
+
locales: string[];
|
|
45
|
+
// include: [] means scanning is off, which is not the same as having found nothing
|
|
46
|
+
scanning: boolean;
|
|
47
|
+
catalogs: Catalogs;
|
|
48
|
+
origins: Record<string, string[]>;
|
|
49
|
+
drafts: Record<string, string[]>;
|
|
50
|
+
triage: Record<string, Record<string, { signal: string; text: string }[]>>;
|
|
51
|
+
check: { broken: { locale: string; key: string; severity: string; issue: string }[] };
|
|
52
|
+
problems: { scope: string; message: string }[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// a catalog arrives in the shape its file has, so the panel flattens it to read
|
|
56
|
+
function flatten(tree: Record<string, unknown>, prefix = '', out: Record<string, string> = {}) {
|
|
57
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
58
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
59
|
+
if (typeof value === 'string') out[path] = value;
|
|
60
|
+
else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
61
|
+
flatten(value as Record<string, unknown>, path, out);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function toPanel(raw: RawState): Panel {
|
|
68
|
+
const flat: Record<string, Record<string, string>> = {};
|
|
69
|
+
for (const locale of raw.locales) flat[locale] = flatten(raw.catalogs[locale] ?? {});
|
|
70
|
+
|
|
71
|
+
// the separator is the escape, never the byte: a raw NUL makes git call this file binary
|
|
72
|
+
const broken = new Map<string, string>();
|
|
73
|
+
for (const entry of raw.check.broken) {
|
|
74
|
+
if (entry.severity === 'error') broken.set(`${entry.locale}\u0000${entry.key}`, entry.issue);
|
|
75
|
+
}
|
|
76
|
+
const drafts = new Map<string, Set<string>>();
|
|
77
|
+
for (const [locale, keys] of Object.entries(raw.drafts)) drafts.set(locale, new Set(keys));
|
|
78
|
+
|
|
79
|
+
const source = flat[raw.sourceLocale] ?? {};
|
|
80
|
+
const targets = raw.locales.filter((locale) => locale !== raw.sourceLocale);
|
|
81
|
+
|
|
82
|
+
const rows: Row[] = Object.keys(source).map((key) => ({
|
|
83
|
+
key,
|
|
84
|
+
source: source[key] ?? '',
|
|
85
|
+
origins: raw.origins[key] ?? [],
|
|
86
|
+
cells: targets.map((locale) => {
|
|
87
|
+
const text = flat[locale]?.[key] ?? '';
|
|
88
|
+
const issue = broken.get(`${locale}\u0000${key}`);
|
|
89
|
+
const state: MessageState = issue
|
|
90
|
+
? 'broken'
|
|
91
|
+
: !text
|
|
92
|
+
? 'missing'
|
|
93
|
+
: drafts.get(locale)?.has(key)
|
|
94
|
+
? 'draft'
|
|
95
|
+
: 'done';
|
|
96
|
+
const signals = (raw.triage?.[locale]?.[key] ?? []).map((reason) => reason.signal);
|
|
97
|
+
return { locale, text, state, issue, signals };
|
|
98
|
+
}),
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
root: raw.root,
|
|
103
|
+
scanning: raw.scanning,
|
|
104
|
+
sourceLocale: raw.sourceLocale,
|
|
105
|
+
locales: raw.locales,
|
|
106
|
+
rows,
|
|
107
|
+
problems: raw.problems,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// a projection of the rows, never stored: a saved cell has to move the rail in the same breath
|
|
112
|
+
export function health(panel: Panel): LocaleHealth[] {
|
|
113
|
+
return panel.locales.map((locale) => {
|
|
114
|
+
const total = panel.rows.length;
|
|
115
|
+
if (locale === panel.sourceLocale) {
|
|
116
|
+
return { locale, source: true, total, done: total, draft: 0, missing: 0, broken: 0 };
|
|
117
|
+
}
|
|
118
|
+
const count = { done: 0, draft: 0, missing: 0, broken: 0 };
|
|
119
|
+
for (const row of panel.rows) {
|
|
120
|
+
const cell = row.cells.find((c) => c.locale === locale);
|
|
121
|
+
if (cell) count[cell.state]++;
|
|
122
|
+
}
|
|
123
|
+
return { locale, source: false, total, ...count };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A command rewrote the catalogs, so every row comes back rather than being patched key by key.
|
|
128
|
+
export function applyState(panel: Panel, raw: RawState): void {
|
|
129
|
+
Object.assign(panel, toPanel(raw));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type Empty = 'noCatalogs' | 'oneLocale' | 'pickOne' | 'noMatch';
|
|
133
|
+
|
|
134
|
+
// An empty screen that names what to click instead of what is true is the worst first run.
|
|
135
|
+
export function emptyReason(panel: Panel, shown: string[], rows: Row[]): Empty | null {
|
|
136
|
+
if (!panel.rows.length) return 'noCatalogs';
|
|
137
|
+
if (panel.locales.length < 2) return 'oneLocale';
|
|
138
|
+
if (!shown.length) return 'pickOne';
|
|
139
|
+
if (!rows.length) return 'noMatch';
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface Filter {
|
|
144
|
+
text: string;
|
|
145
|
+
state: MessageState | 'all' | 'look';
|
|
146
|
+
locales: string[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// One pass over every row: measured at 0.9 ms on 1573 messages, so nothing here is memoized.
|
|
150
|
+
export function visible(rows: Row[], filter: Filter): Row[] {
|
|
151
|
+
const needle = filter.text.trim().toLowerCase();
|
|
152
|
+
const shown = new Set(filter.locales);
|
|
153
|
+
return rows.filter((row) => {
|
|
154
|
+
const cells = row.cells.filter((cell) => shown.has(cell.locale));
|
|
155
|
+
if (filter.state === 'look' && !cells.some((cell) => cell.signals.length)) return false;
|
|
156
|
+
if (filter.state !== 'all' && filter.state !== 'look') {
|
|
157
|
+
if (!cells.some((cell) => cell.state === filter.state)) return false;
|
|
158
|
+
}
|
|
159
|
+
if (!needle) return true;
|
|
160
|
+
if (row.source.toLowerCase().includes(needle)) return true;
|
|
161
|
+
return cells.some((cell) => cell.text.toLowerCase().includes(needle));
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/* The Studio's own layer, on one class: a host puts it on a wrapper, the command on <html>. */
|
|
2
|
+
.verbaly-studio {
|
|
3
|
+
--paper: #f8fbf7;
|
|
4
|
+
--ground: #eff4ee;
|
|
5
|
+
--hollow: #e5ede4;
|
|
6
|
+
--rule: #d7dfd5;
|
|
7
|
+
--rule-strong: #828d80;
|
|
8
|
+
--muted: #636c61;
|
|
9
|
+
--body: #374036;
|
|
10
|
+
--ink: #061005;
|
|
11
|
+
/* the bar never turns: lime on paper is 1.2:1 and the one brand colour cannot vanish on a theme */
|
|
12
|
+
--bar: #131e12;
|
|
13
|
+
--on-bar: #f8fbf7;
|
|
14
|
+
|
|
15
|
+
--broken: #b43931;
|
|
16
|
+
--broken-tint: #eee0db;
|
|
17
|
+
--missing: #8e5a04;
|
|
18
|
+
--missing-tint: #e9e4d5;
|
|
19
|
+
--draft: #2267ad;
|
|
20
|
+
--draft-tint: #dae6ed;
|
|
21
|
+
--done: #2e7428;
|
|
22
|
+
--done-tint: #dce8da;
|
|
23
|
+
|
|
24
|
+
--sans: 'Schibsted Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
25
|
+
--serif: 'Newsreader', ui-serif, Georgia, 'Times New Roman', serif;
|
|
26
|
+
--mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
|
|
27
|
+
|
|
28
|
+
--rail: 232px;
|
|
29
|
+
--radius: 6px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* The panel had a dark palette and no way to reach it, so it opened light on a dark machine. */
|
|
33
|
+
@media (prefers-color-scheme: dark) {
|
|
34
|
+
.verbaly-studio:not([data-theme='light']) {
|
|
35
|
+
--paper: #10170f;
|
|
36
|
+
--ground: #161e15;
|
|
37
|
+
--hollow: #1d271c;
|
|
38
|
+
--rule: #2a352a;
|
|
39
|
+
--rule-strong: #6f7a6d;
|
|
40
|
+
--muted: #9aa598;
|
|
41
|
+
--body: #d3dbd1;
|
|
42
|
+
--ink: #f2f7f1;
|
|
43
|
+
|
|
44
|
+
--broken: #f08a82;
|
|
45
|
+
--broken-tint: #3a211f;
|
|
46
|
+
--missing: #dcae4e;
|
|
47
|
+
--missing-tint: #352c16;
|
|
48
|
+
--draft: #7db6e8;
|
|
49
|
+
--draft-tint: #172a3a;
|
|
50
|
+
--done: #79c46f;
|
|
51
|
+
--done-tint: #182c16;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* an embedder sets the attribute, which is why it wins over the machine in both directions */
|
|
56
|
+
.verbaly-studio[data-theme='dark'] {
|
|
57
|
+
--paper: #10170f;
|
|
58
|
+
--ground: #161e15;
|
|
59
|
+
--hollow: #1d271c;
|
|
60
|
+
--rule: #2a352a;
|
|
61
|
+
--rule-strong: #6f7a6d;
|
|
62
|
+
--muted: #9aa598;
|
|
63
|
+
--body: #d3dbd1;
|
|
64
|
+
--ink: #f2f7f1;
|
|
65
|
+
|
|
66
|
+
--broken: #f08a82;
|
|
67
|
+
--broken-tint: #3a211f;
|
|
68
|
+
--missing: #dcae4e;
|
|
69
|
+
--missing-tint: #352c16;
|
|
70
|
+
--draft: #7db6e8;
|
|
71
|
+
--draft-tint: #172a3a;
|
|
72
|
+
--done: #79c46f;
|
|
73
|
+
--done-tint: #182c16;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.verbaly-studio,
|
|
77
|
+
.verbaly-studio *,
|
|
78
|
+
.verbaly-studio *::before,
|
|
79
|
+
.verbaly-studio *::after {
|
|
80
|
+
box-sizing: border-box;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.verbaly-studio {
|
|
84
|
+
color: var(--body);
|
|
85
|
+
font-family: var(--sans);
|
|
86
|
+
font-size: 14px;
|
|
87
|
+
line-height: 1.5;
|
|
88
|
+
-webkit-font-smoothing: antialiased;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.verbaly-studio code,
|
|
92
|
+
.verbaly-studio kbd {
|
|
93
|
+
font-family: var(--mono);
|
|
94
|
+
font-size: 0.92em;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* Nothing loops and nothing moves on its own: only what reports a real process animates. */
|
|
98
|
+
@media (prefers-reduced-motion: reduce) {
|
|
99
|
+
.verbaly-studio,
|
|
100
|
+
.verbaly-studio *,
|
|
101
|
+
.verbaly-studio *::before,
|
|
102
|
+
.verbaly-studio *::after {
|
|
103
|
+
animation-duration: 0.01ms !important;
|
|
104
|
+
transition-duration: 0.01ms !important;
|
|
105
|
+
}
|
|
106
|
+
}
|