@verbaly/studio 0.56.1 → 0.57.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/README.md +2 -40
- package/dist/cli.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/ui/assets/{index-3A7RArYB.css → index-B2gDPqi5.css} +1 -1
- package/dist/ui/index.html +2 -2
- package/package.json +5 -27
- package/dist/panel/Actions.svelte +0 -255
- package/dist/panel/Health.svelte +0 -184
- package/dist/panel/Marks.svelte +0 -56
- package/dist/panel/Panel.svelte +0 -337
- package/dist/panel/Panel.svelte.d.ts +0 -12
- package/dist/panel/Rail.svelte +0 -303
- package/dist/panel/Row.svelte +0 -340
- package/dist/panel/api.ts +0 -82
- package/dist/panel/index.ts +0 -18
- package/dist/panel/model.ts +0 -163
- package/dist/panel/tokens.css +0 -106
- package/dist/panel/validate.ts +0 -94
- package/dist/panel/wire.ts +0 -29
- package/dist/panel/words.ts +0 -59
- /package/dist/ui/assets/{index-DlWGcJoP.js → index-3Geaeu_2.js} +0 -0
package/dist/panel/Panel.svelte
DELETED
|
@@ -1,337 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import HealthView from './Health.svelte';
|
|
3
|
-
import Rail from './Rail.svelte';
|
|
4
|
-
import RowView from './Row.svelte';
|
|
5
|
-
import { applyState, emptyReason, health, visible, type Panel } from './model';
|
|
6
|
-
import Actions from './Actions.svelte';
|
|
7
|
-
import type { Check, StudioApi } from './api';
|
|
8
|
-
import { EMPTY } from './words';
|
|
9
|
-
|
|
10
|
-
interface Props {
|
|
11
|
-
panel: Panel;
|
|
12
|
-
api: StudioApi;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const { panel, api }: Props = $props();
|
|
16
|
-
|
|
17
|
-
let editing: string | null = $state(null);
|
|
18
|
-
let note: { text: string; file: string } | null = $state(null);
|
|
19
|
-
let pending: { locale: string; keys: string[] } | null = $state(null);
|
|
20
|
-
let view: 'messages' | 'health' = $state('messages');
|
|
21
|
-
let checks: { ok: boolean; entries: Check[] } | null = $state(null);
|
|
22
|
-
|
|
23
|
-
// doctor reads the disk, so it is asked once at boot and again whenever you open the view
|
|
24
|
-
async function openHealth() {
|
|
25
|
-
view = 'health';
|
|
26
|
-
const result = await api.health();
|
|
27
|
-
if (result.error) {
|
|
28
|
-
note = { text: result.error, file: '' };
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
if (result.value) checks = result.value;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
$effect(() => {
|
|
35
|
-
void api.health().then((result) => {
|
|
36
|
-
if (result.value) checks = result.value;
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// a command rewrote the catalogs, so the panel re-reads them instead of patching key by key
|
|
41
|
-
async function refresh(said: string) {
|
|
42
|
-
const answer = await api.state();
|
|
43
|
-
if (answer.error || !answer.value) {
|
|
44
|
-
note = { text: answer.error ?? '[verbaly] the server sent no state', file: '' };
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
// the open editor and the undo both point at keys this run may have moved under them
|
|
48
|
-
editing = null;
|
|
49
|
-
pending = null;
|
|
50
|
-
applyState(panel, answer.value);
|
|
51
|
-
note = { text: said, file: '' };
|
|
52
|
-
const next = await api.health();
|
|
53
|
-
if (next.value) checks = next.value;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// the flag says who wrote the text, so both directions are a write and both report what moved
|
|
57
|
-
async function setRead(locale: string, keys: string[] | undefined, undo: boolean) {
|
|
58
|
-
const result = await api.setRead(locale, keys, undo);
|
|
59
|
-
if (result.error) {
|
|
60
|
-
note = { text: result.error, file: '' };
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
const moved = new Set(result.value?.keys ?? []);
|
|
64
|
-
for (const row of panel.rows) {
|
|
65
|
-
if (!moved.has(row.key)) continue;
|
|
66
|
-
const cell = row.cells.find((c) => c.locale === locale);
|
|
67
|
-
if (cell?.text) cell.state = undo ? 'draft' : 'done';
|
|
68
|
-
}
|
|
69
|
-
pending = undo ? null : { locale, keys: result.value?.keys ?? [] };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// the row shows what the server refused; the panel only celebrates what it accepted
|
|
73
|
-
async function save(locale: string, key: string, text: string): Promise<string | null> {
|
|
74
|
-
const result = await api.save(locale, key, text);
|
|
75
|
-
if (result.error) return result.error;
|
|
76
|
-
const row = panel.rows.find((r) => r.key === key);
|
|
77
|
-
const cell = row?.cells.find((c) => c.locale === locale);
|
|
78
|
-
if (cell) {
|
|
79
|
-
cell.text = text;
|
|
80
|
-
cell.state = text ? 'done' : 'missing';
|
|
81
|
-
cell.issue = undefined;
|
|
82
|
-
}
|
|
83
|
-
note = { text, file: `${locale}.json` };
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// `state` would shadow the rune, so the filter is named for what it reads like at the call site
|
|
88
|
-
let only: 'all' | 'look' | 'missing' | 'draft' | 'broken' = $state('all');
|
|
89
|
-
let text: string = $state('');
|
|
90
|
-
// null means nobody has picked yet, so the default follows the project instead of freezing at mount
|
|
91
|
-
let picked: string[] | null = $state(null);
|
|
92
|
-
|
|
93
|
-
const targets = $derived(panel.locales.filter((locale) => locale !== panel.sourceLocale));
|
|
94
|
-
const shown = $derived(picked ?? targets.slice(0, 1));
|
|
95
|
-
|
|
96
|
-
const rail = $derived(health(panel));
|
|
97
|
-
const rows = $derived(visible(panel.rows, { text, state: only, locales: shown }));
|
|
98
|
-
const empty = $derived(emptyReason(panel, shown, rows));
|
|
99
|
-
|
|
100
|
-
// every count is over the languages actually shown, so the rail never promises rows a filter hides
|
|
101
|
-
const counts = $derived.by(() => {
|
|
102
|
-
const out: Record<string, number> = { all: 0, look: 0, missing: 0, draft: 0, broken: 0 };
|
|
103
|
-
for (const id of ['all', 'look', 'missing', 'draft', 'broken'] as const) {
|
|
104
|
-
out[id] = visible(panel.rows, { text, state: id, locales: shown }).length;
|
|
105
|
-
}
|
|
106
|
-
return out;
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
// a virtual window is a slice here: it measured 0.000 ms on the real catalog, so no library
|
|
110
|
-
const STEP = 60;
|
|
111
|
-
let limit: number = $state(STEP);
|
|
112
|
-
$effect(() => {
|
|
113
|
-
void text;
|
|
114
|
-
void only;
|
|
115
|
-
void shown;
|
|
116
|
-
limit = STEP;
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
function onScroll(event: Event) {
|
|
120
|
-
const el = event.currentTarget as HTMLElement;
|
|
121
|
-
if (el.scrollTop + el.clientHeight > el.scrollHeight - 600 && limit < rows.length) {
|
|
122
|
-
limit += STEP;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function toggle(locale: string) {
|
|
127
|
-
picked = shown.includes(locale)
|
|
128
|
-
? shown.filter((other: string) => other !== locale)
|
|
129
|
-
: [...shown, locale];
|
|
130
|
-
}
|
|
131
|
-
</script>
|
|
132
|
-
|
|
133
|
-
<div class="shell">
|
|
134
|
-
<header class="bar">
|
|
135
|
-
<span class="brand">verbaly <b>studio</b></span>
|
|
136
|
-
<input
|
|
137
|
-
class="search"
|
|
138
|
-
type="search"
|
|
139
|
-
bind:value={text}
|
|
140
|
-
placeholder="Search the text, not the key"
|
|
141
|
-
aria-label="Search the text, not the key"
|
|
142
|
-
/>
|
|
143
|
-
<span class="root" title={panel.root}>{panel.root}</span>
|
|
144
|
-
</header>
|
|
145
|
-
|
|
146
|
-
{#if panel.problems.length}
|
|
147
|
-
<ul class="problems">
|
|
148
|
-
{#each panel.problems as problem (problem.scope + problem.message)}
|
|
149
|
-
<li><code>{problem.scope}</code> {problem.message}</li>
|
|
150
|
-
{/each}
|
|
151
|
-
</ul>
|
|
152
|
-
{/if}
|
|
153
|
-
|
|
154
|
-
<div class="body">
|
|
155
|
-
<Rail
|
|
156
|
-
health={rail}
|
|
157
|
-
{shown}
|
|
158
|
-
state={only}
|
|
159
|
-
{counts}
|
|
160
|
-
{pending}
|
|
161
|
-
checks={checks ? { bad: checks.entries.filter((e) => e.level !== 'ok').length } : null}
|
|
162
|
-
onToggle={toggle}
|
|
163
|
-
onState={(next) => (only = next)}
|
|
164
|
-
onRead={(locale) => setRead(locale, undefined, false)}
|
|
165
|
-
onUndo={() => pending && setRead(pending.locale, pending.keys, true)}
|
|
166
|
-
onHealth={openHealth}
|
|
167
|
-
>
|
|
168
|
-
{#snippet actions()}
|
|
169
|
-
<Actions {api} scanning={panel.scanning} targets={targets.length} {shown} onDone={refresh} />
|
|
170
|
-
{/snippet}
|
|
171
|
-
</Rail>
|
|
172
|
-
|
|
173
|
-
{#if view === 'health'}
|
|
174
|
-
<HealthView
|
|
175
|
-
entries={checks?.entries ?? []}
|
|
176
|
-
ok={checks?.ok ?? true}
|
|
177
|
-
onClose={() => (view = 'messages')}
|
|
178
|
-
/>
|
|
179
|
-
{:else}
|
|
180
|
-
<main class="table" onscroll={onScroll}>
|
|
181
|
-
{#if empty}
|
|
182
|
-
<p class="note">{EMPTY[empty]}{empty === 'oneLocale' ? ` ${EMPTY.oneLocaleFix}` : ''}</p>
|
|
183
|
-
{:else}
|
|
184
|
-
{#each rows.slice(0, limit) as row (row.key)}
|
|
185
|
-
<RowView {row} {shown} {editing} onEdit={(id) => (editing = id)} onSave={save} />
|
|
186
|
-
{/each}
|
|
187
|
-
{#if limit < rows.length}
|
|
188
|
-
<p class="note">{rows.length - limit} more below</p>
|
|
189
|
-
{/if}
|
|
190
|
-
{/if}
|
|
191
|
-
</main>
|
|
192
|
-
{/if}
|
|
193
|
-
</div>
|
|
194
|
-
|
|
195
|
-
{#if note}
|
|
196
|
-
<p class="snack" role="status">
|
|
197
|
-
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
198
|
-
<path d="m3.4 8.4 3 3 6.2-6.8" />
|
|
199
|
-
</svg>
|
|
200
|
-
{#if note.file}Saved in <code>{note.file}</code>{:else}{note.text}{/if}
|
|
201
|
-
<button onclick={() => (note = null)}>Dismiss</button>
|
|
202
|
-
</p>
|
|
203
|
-
{/if}
|
|
204
|
-
</div>
|
|
205
|
-
|
|
206
|
-
<style>
|
|
207
|
-
.shell {
|
|
208
|
-
position: relative;
|
|
209
|
-
display: flex;
|
|
210
|
-
flex-direction: column;
|
|
211
|
-
height: 100%;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/* the bar stays ink in both themes: the one brand colour cannot vanish on a theme switch */
|
|
215
|
-
.bar {
|
|
216
|
-
display: flex;
|
|
217
|
-
align-items: center;
|
|
218
|
-
gap: 16px;
|
|
219
|
-
flex: none;
|
|
220
|
-
padding: 0 16px;
|
|
221
|
-
height: 48px;
|
|
222
|
-
background: var(--bar);
|
|
223
|
-
color: var(--on-bar);
|
|
224
|
-
/* the bar keeps its value in both themes, so in dark it needs an edge: it measured 1.01:1 */
|
|
225
|
-
box-shadow: inset 0 -1px 0 rgb(255 255 255 / 0.12);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
.brand {
|
|
229
|
-
font-size: 13px;
|
|
230
|
-
letter-spacing: 0.02em;
|
|
231
|
-
opacity: 0.75;
|
|
232
|
-
white-space: nowrap;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
.brand b {
|
|
236
|
-
opacity: 1;
|
|
237
|
-
font-weight: 600;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
.search {
|
|
241
|
-
flex: 1;
|
|
242
|
-
max-width: 520px;
|
|
243
|
-
padding: 6px 12px;
|
|
244
|
-
border: 1px solid rgb(255 255 255 / 0.16);
|
|
245
|
-
border-radius: var(--radius);
|
|
246
|
-
background: rgb(255 255 255 / 0.06);
|
|
247
|
-
color: var(--on-bar);
|
|
248
|
-
font: inherit;
|
|
249
|
-
font-size: 13px;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
.search::placeholder {
|
|
253
|
-
color: rgb(255 255 255 / 0.45);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
.search:focus-visible {
|
|
257
|
-
outline: 2px solid var(--done);
|
|
258
|
-
outline-offset: 1px;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
.root {
|
|
262
|
-
margin-left: auto;
|
|
263
|
-
font-family: var(--mono);
|
|
264
|
-
font-size: 11.5px;
|
|
265
|
-
opacity: 0.55;
|
|
266
|
-
max-width: 30ch;
|
|
267
|
-
overflow: hidden;
|
|
268
|
-
text-overflow: ellipsis;
|
|
269
|
-
white-space: nowrap;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
.problems {
|
|
273
|
-
margin: 0;
|
|
274
|
-
padding: 8px 16px;
|
|
275
|
-
list-style: none;
|
|
276
|
-
background: var(--broken-tint);
|
|
277
|
-
color: var(--broken);
|
|
278
|
-
font-size: 12.5px;
|
|
279
|
-
border-bottom: 1px solid var(--rule);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
.body {
|
|
283
|
-
display: flex;
|
|
284
|
-
flex: 1;
|
|
285
|
-
min-height: 0;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
.table {
|
|
289
|
-
flex: 1;
|
|
290
|
-
min-width: 0;
|
|
291
|
-
overflow-y: auto;
|
|
292
|
-
background: var(--paper);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
.note {
|
|
296
|
-
margin: 0;
|
|
297
|
-
padding: 24px 20px;
|
|
298
|
-
color: var(--muted);
|
|
299
|
-
font-size: 13px;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/* the one thing that moves: it reports a write that really happened, and it waits to be dismissed */
|
|
303
|
-
.snack {
|
|
304
|
-
position: absolute;
|
|
305
|
-
left: 50%;
|
|
306
|
-
bottom: 24px;
|
|
307
|
-
transform: translateX(-50%);
|
|
308
|
-
display: flex;
|
|
309
|
-
align-items: center;
|
|
310
|
-
gap: 12px;
|
|
311
|
-
margin: 0;
|
|
312
|
-
height: 44px;
|
|
313
|
-
padding: 0 12px 0 20px;
|
|
314
|
-
background: var(--bar);
|
|
315
|
-
color: var(--on-bar);
|
|
316
|
-
border-radius: var(--radius);
|
|
317
|
-
font-size: 13px;
|
|
318
|
-
white-space: nowrap;
|
|
319
|
-
box-shadow: 0 2px 6px rgb(6 16 5 / 0.18), 0 34px 64px -28px rgb(6 16 5 / 0.4);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
.snack svg {
|
|
323
|
-
color: #8ed67f;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
.snack button {
|
|
327
|
-
height: 30px;
|
|
328
|
-
padding: 0 12px;
|
|
329
|
-
border: 0;
|
|
330
|
-
border-radius: 4px;
|
|
331
|
-
background: rgb(255 255 255 / 0.14);
|
|
332
|
-
color: inherit;
|
|
333
|
-
font: inherit;
|
|
334
|
-
font-size: 12.5px;
|
|
335
|
-
cursor: pointer;
|
|
336
|
-
}
|
|
337
|
-
</style>
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { Component } from 'svelte';
|
|
2
|
-
import type { Panel } from './model';
|
|
3
|
-
import type { StudioApi } from './api';
|
|
4
|
-
|
|
5
|
-
// Written by hand, like @verbaly/svelte's Trans.svelte.d.ts: the build copies the component as
|
|
6
|
-
// source and there is no svelte2tsx step in this repo.
|
|
7
|
-
declare const PanelView: Component<{
|
|
8
|
-
panel: Panel;
|
|
9
|
-
api: StudioApi;
|
|
10
|
-
}>;
|
|
11
|
-
|
|
12
|
-
export default PanelView;
|
package/dist/panel/Rail.svelte
DELETED
|
@@ -1,303 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { FILTER, MARK_AS_READ, UNDO } from './words';
|
|
3
|
-
import type { Snippet } from 'svelte';
|
|
4
|
-
import type { LocaleHealth, MessageState } from './model';
|
|
5
|
-
|
|
6
|
-
type Which = 'all' | 'look' | 'missing' | 'draft' | 'broken';
|
|
7
|
-
|
|
8
|
-
interface Props {
|
|
9
|
-
health: LocaleHealth[];
|
|
10
|
-
shown: string[];
|
|
11
|
-
state: Which;
|
|
12
|
-
counts: Record<string, number>;
|
|
13
|
-
pending: { locale: string; keys: string[] } | null;
|
|
14
|
-
onToggle: (locale: string) => void;
|
|
15
|
-
onState: (state: Which) => void;
|
|
16
|
-
onRead: (locale: string) => void;
|
|
17
|
-
onUndo: () => void;
|
|
18
|
-
checks: { bad: number } | null;
|
|
19
|
-
onHealth: () => void;
|
|
20
|
-
actions: Snippet;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const {
|
|
24
|
-
health,
|
|
25
|
-
shown,
|
|
26
|
-
state,
|
|
27
|
-
counts,
|
|
28
|
-
pending,
|
|
29
|
-
checks,
|
|
30
|
-
onToggle,
|
|
31
|
-
onState,
|
|
32
|
-
onRead,
|
|
33
|
-
onUndo,
|
|
34
|
-
onHealth,
|
|
35
|
-
actions,
|
|
36
|
-
}: Props = $props();
|
|
37
|
-
|
|
38
|
-
const ORDER: Which[] = ['all', 'look', 'missing', 'draft', 'broken'];
|
|
39
|
-
|
|
40
|
-
// the second line is the state, and it is what makes the rail worth more than a dropdown
|
|
41
|
-
function summary(h: LocaleHealth): string {
|
|
42
|
-
if (h.source) return 'the one you write';
|
|
43
|
-
if (h.broken) return `${h.broken} breaks your site`;
|
|
44
|
-
if (h.missing) return `${h.missing} missing`;
|
|
45
|
-
if (h.draft) return `${h.draft} nobody has read`;
|
|
46
|
-
return 'all read';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function tone(h: LocaleHealth): MessageState {
|
|
50
|
-
if (h.source) return 'done';
|
|
51
|
-
if (h.broken) return 'broken';
|
|
52
|
-
if (h.missing) return 'missing';
|
|
53
|
-
if (h.draft) return 'draft';
|
|
54
|
-
return 'done';
|
|
55
|
-
}
|
|
56
|
-
</script>
|
|
57
|
-
|
|
58
|
-
<nav class="rail" aria-label="Languages and filters">
|
|
59
|
-
<button class="nav" onclick={onHealth}>
|
|
60
|
-
<span>Health</span>
|
|
61
|
-
{#if checks}<span class="bad" class:clean={!checks.bad}>{checks.bad || "ok"}</span>{/if}
|
|
62
|
-
</button>
|
|
63
|
-
|
|
64
|
-
{@render actions()}
|
|
65
|
-
|
|
66
|
-
<h2 class="cap">Languages</h2>
|
|
67
|
-
<ul class="langs">
|
|
68
|
-
{#each health as h (h.locale)}
|
|
69
|
-
<li>
|
|
70
|
-
<label class="lang" class:source={h.source}>
|
|
71
|
-
<input
|
|
72
|
-
type="checkbox"
|
|
73
|
-
checked={shown.includes(h.locale)}
|
|
74
|
-
disabled={h.source}
|
|
75
|
-
onchange={() => onToggle(h.locale)}
|
|
76
|
-
/>
|
|
77
|
-
<span class="meta">
|
|
78
|
-
<span class="name">{h.locale}</span>
|
|
79
|
-
<span class="state" data-state={tone(h)}>{summary(h)}</span>
|
|
80
|
-
</span>
|
|
81
|
-
</label>
|
|
82
|
-
{#if !h.source && h.draft > 0}
|
|
83
|
-
<!-- no confirming dialog: the action is reversible and undo lives right here, not in a toast -->
|
|
84
|
-
<button class="read" onclick={() => onRead(h.locale)}>
|
|
85
|
-
{MARK_AS_READ}
|
|
86
|
-
<span class="n">{h.draft}</span>
|
|
87
|
-
</button>
|
|
88
|
-
{/if}
|
|
89
|
-
</li>
|
|
90
|
-
{/each}
|
|
91
|
-
</ul>
|
|
92
|
-
|
|
93
|
-
{#if pending}
|
|
94
|
-
<p class="undo">
|
|
95
|
-
<span>{pending.keys.length} in {pending.locale} marked as read</span>
|
|
96
|
-
<button onclick={onUndo}>{UNDO}</button>
|
|
97
|
-
</p>
|
|
98
|
-
{/if}
|
|
99
|
-
|
|
100
|
-
<h2 class="cap">Show</h2>
|
|
101
|
-
<ul class="filters">
|
|
102
|
-
{#each ORDER as which (which)}
|
|
103
|
-
<li>
|
|
104
|
-
<button type="button" class:on={state === which} onclick={() => onState(which)}>
|
|
105
|
-
<span>{FILTER[which]}</span>
|
|
106
|
-
<span class="count">{counts[which] ?? 0}</span>
|
|
107
|
-
</button>
|
|
108
|
-
</li>
|
|
109
|
-
{/each}
|
|
110
|
-
</ul>
|
|
111
|
-
</nav>
|
|
112
|
-
|
|
113
|
-
<style>
|
|
114
|
-
.rail {
|
|
115
|
-
width: var(--rail);
|
|
116
|
-
flex: none;
|
|
117
|
-
padding: 18px 14px;
|
|
118
|
-
border-right: 1px solid var(--rule);
|
|
119
|
-
background: var(--ground);
|
|
120
|
-
overflow-y: auto;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
.nav {
|
|
124
|
-
display: flex;
|
|
125
|
-
align-items: center;
|
|
126
|
-
gap: 8px;
|
|
127
|
-
width: 100%;
|
|
128
|
-
margin-bottom: 18px;
|
|
129
|
-
padding: 7px 8px;
|
|
130
|
-
border: 0;
|
|
131
|
-
border-radius: var(--radius);
|
|
132
|
-
background: none;
|
|
133
|
-
font: inherit;
|
|
134
|
-
font-size: 13px;
|
|
135
|
-
color: var(--body);
|
|
136
|
-
cursor: pointer;
|
|
137
|
-
text-align: left;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
.nav:hover {
|
|
141
|
-
background: var(--hollow);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
.bad {
|
|
145
|
-
margin-left: auto;
|
|
146
|
-
padding: 0 7px;
|
|
147
|
-
border-radius: 999px;
|
|
148
|
-
background: var(--broken-tint);
|
|
149
|
-
color: var(--broken);
|
|
150
|
-
font-family: var(--mono);
|
|
151
|
-
font-size: 11px;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
.bad.clean {
|
|
155
|
-
background: var(--done-tint);
|
|
156
|
-
color: var(--done);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
.cap {
|
|
160
|
-
margin: 0 0 8px;
|
|
161
|
-
padding: 0 6px;
|
|
162
|
-
font-size: 11px;
|
|
163
|
-
font-weight: 600;
|
|
164
|
-
letter-spacing: 0.08em;
|
|
165
|
-
text-transform: uppercase;
|
|
166
|
-
color: var(--muted);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
ul {
|
|
170
|
-
margin: 0 0 22px;
|
|
171
|
-
padding: 0;
|
|
172
|
-
list-style: none;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
.lang {
|
|
176
|
-
display: flex;
|
|
177
|
-
align-items: flex-start;
|
|
178
|
-
gap: 10px;
|
|
179
|
-
padding: 7px 6px;
|
|
180
|
-
border-radius: var(--radius);
|
|
181
|
-
cursor: pointer;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
.lang:hover {
|
|
185
|
-
background: var(--hollow);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
.lang.source {
|
|
189
|
-
cursor: default;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
.lang input {
|
|
193
|
-
margin: 4px 0 0;
|
|
194
|
-
accent-color: var(--done);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
.meta {
|
|
198
|
-
display: flex;
|
|
199
|
-
flex-direction: column;
|
|
200
|
-
min-width: 0;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
.name {
|
|
204
|
-
font-family: var(--mono);
|
|
205
|
-
font-size: 13px;
|
|
206
|
-
font-weight: 600;
|
|
207
|
-
color: var(--ink);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
.state {
|
|
211
|
-
font-size: 11.5px;
|
|
212
|
-
color: var(--muted);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
.state[data-state='broken'] {
|
|
216
|
-
color: var(--broken);
|
|
217
|
-
}
|
|
218
|
-
.state[data-state='missing'] {
|
|
219
|
-
color: var(--missing);
|
|
220
|
-
}
|
|
221
|
-
.state[data-state='draft'] {
|
|
222
|
-
color: var(--draft);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
.read {
|
|
226
|
-
display: flex;
|
|
227
|
-
align-items: center;
|
|
228
|
-
gap: 6px;
|
|
229
|
-
margin: 2px 0 6px 30px;
|
|
230
|
-
padding: 3px 9px;
|
|
231
|
-
border: 1px solid var(--draft);
|
|
232
|
-
border-radius: 999px;
|
|
233
|
-
background: var(--draft-tint);
|
|
234
|
-
color: var(--draft);
|
|
235
|
-
font: inherit;
|
|
236
|
-
font-size: 11.5px;
|
|
237
|
-
cursor: pointer;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
.read .n {
|
|
241
|
-
font-family: var(--mono);
|
|
242
|
-
opacity: 0.8;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/* undo lives in the rail and does not expire: for hundreds of messages a toast is not undo */
|
|
246
|
-
.undo {
|
|
247
|
-
display: flex;
|
|
248
|
-
align-items: center;
|
|
249
|
-
gap: 8px;
|
|
250
|
-
margin: -14px 0 22px;
|
|
251
|
-
padding: 8px 10px;
|
|
252
|
-
border-radius: var(--radius);
|
|
253
|
-
background: var(--hollow);
|
|
254
|
-
font-size: 11.5px;
|
|
255
|
-
color: var(--body);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
.undo button {
|
|
259
|
-
margin-left: auto;
|
|
260
|
-
padding: 3px 10px;
|
|
261
|
-
border: 1px solid var(--rule-strong);
|
|
262
|
-
border-radius: var(--radius);
|
|
263
|
-
background: var(--paper);
|
|
264
|
-
color: var(--ink);
|
|
265
|
-
font: inherit;
|
|
266
|
-
font-size: 11.5px;
|
|
267
|
-
cursor: pointer;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
.filters button {
|
|
271
|
-
display: flex;
|
|
272
|
-
justify-content: space-between;
|
|
273
|
-
align-items: center;
|
|
274
|
-
gap: 8px;
|
|
275
|
-
width: 100%;
|
|
276
|
-
padding: 6px 8px;
|
|
277
|
-
border: 0;
|
|
278
|
-
border-radius: var(--radius);
|
|
279
|
-
background: none;
|
|
280
|
-
font: inherit;
|
|
281
|
-
font-size: 13px;
|
|
282
|
-
color: var(--body);
|
|
283
|
-
cursor: pointer;
|
|
284
|
-
text-align: left;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
.filters button:hover {
|
|
288
|
-
background: var(--hollow);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
.filters button.on {
|
|
292
|
-
background: var(--hollow);
|
|
293
|
-
color: var(--ink);
|
|
294
|
-
font-weight: 600;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/* the number is half the value: it says whether entering is worth it before you enter */
|
|
298
|
-
.count {
|
|
299
|
-
font-family: var(--mono);
|
|
300
|
-
font-size: 12px;
|
|
301
|
-
color: var(--muted);
|
|
302
|
-
}
|
|
303
|
-
</style>
|