@walkinissue/angy 0.2.17 → 0.2.19

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.
@@ -0,0 +1,177 @@
1
+ <script lang="ts">
2
+ let {
3
+ pending = false,
4
+ affected,
5
+ onCancel,
6
+ onConfirm
7
+ }: {
8
+ pending?: boolean;
9
+ affected: Array<{
10
+ msgid: string;
11
+ msgctxt: string | null;
12
+ baseValue: string;
13
+ workingValue: string;
14
+ }>;
15
+ onCancel: () => void;
16
+ onConfirm: () => void;
17
+ } = $props();
18
+ </script>
19
+
20
+ <div class="dialog-backdrop" role="presentation">
21
+ <div
22
+ class="dialog"
23
+ role="dialog"
24
+ aria-modal="true"
25
+ aria-labelledby="rotation-warning-title"
26
+ aria-describedby="rotation-warning-description"
27
+ >
28
+ <h2 id="rotation-warning-title">Catalog rotation warning</h2>
29
+ <p id="rotation-warning-description">
30
+ These working translations differ from base. Rotating now will promote the working catalog
31
+ and effectively overwrite the current base values for the keys below.
32
+ </p>
33
+
34
+ <ul class="impact-list">
35
+ {#each affected as item}
36
+ <li>
37
+ <div class="impact-key">
38
+ <code>{item.msgid}</code>
39
+ {#if item.msgctxt}
40
+ <code>{item.msgctxt}</code>
41
+ {/if}
42
+ </div>
43
+ <div class="impact-values">
44
+ <div>
45
+ <span>Base</span>
46
+ <code>{item.baseValue || "(empty)"}</code>
47
+ </div>
48
+ <div>
49
+ <span>Working</span>
50
+ <code>{item.workingValue || "(empty)"}</code>
51
+ </div>
52
+ </div>
53
+ </li>
54
+ {/each}
55
+ </ul>
56
+
57
+ <div class="actions">
58
+ <button type="button" class="secondary-btn" onclick={onCancel}>Cancel</button>
59
+ <button type="button" class="primary-btn" disabled={pending} onclick={onConfirm}>
60
+ {pending ? "Rotating..." : "Rotate anyway"}
61
+ </button>
62
+ </div>
63
+ </div>
64
+ </div>
65
+
66
+ <style>
67
+ .dialog-backdrop {
68
+ position: fixed;
69
+ inset: 0;
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ padding: 1rem;
74
+ background: rgba(0, 0, 0, 0.55);
75
+ z-index: 500100;
76
+ }
77
+
78
+ .dialog {
79
+ width: min(42rem, calc(100vw - 2rem));
80
+ max-height: min(80vh, 48rem);
81
+ overflow: auto;
82
+ padding: 1rem;
83
+ border-radius: 1rem;
84
+ border: 1px solid rgba(255, 196, 120, 0.22);
85
+ background: linear-gradient(180deg, rgba(25, 27, 34, 0.98) 0%, rgba(17, 19, 25, 0.99) 100%);
86
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
87
+ color: rgba(248, 250, 252, 0.96);
88
+ }
89
+
90
+ h2 {
91
+ margin: 0 0 0.5rem;
92
+ font-size: 1rem;
93
+ }
94
+
95
+ p {
96
+ margin: 0 0 1rem;
97
+ color: rgba(255, 255, 255, 0.74);
98
+ line-height: 1.45;
99
+ }
100
+
101
+ .impact-list {
102
+ list-style: none;
103
+ margin: 0;
104
+ padding: 0;
105
+ display: flex;
106
+ flex-direction: column;
107
+ gap: 0.75rem;
108
+ }
109
+
110
+ .impact-list li {
111
+ padding: 0.75rem;
112
+ border-radius: 0.8rem;
113
+ background: rgba(255, 255, 255, 0.035);
114
+ border: 1px solid rgba(255, 255, 255, 0.08);
115
+ }
116
+
117
+ .impact-key,
118
+ .impact-values {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 0.4rem;
122
+ }
123
+
124
+ .impact-values {
125
+ margin-top: 0.6rem;
126
+ }
127
+
128
+ .impact-values span {
129
+ display: inline-block;
130
+ min-width: 4rem;
131
+ font-size: 0.72rem;
132
+ font-weight: 700;
133
+ text-transform: uppercase;
134
+ letter-spacing: 0.05em;
135
+ color: rgba(255, 255, 255, 0.58);
136
+ }
137
+
138
+ code {
139
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
140
+ font-size: 0.76rem;
141
+ word-break: break-word;
142
+ color: rgba(232, 243, 255, 0.94);
143
+ }
144
+
145
+ .actions {
146
+ display: flex;
147
+ justify-content: flex-end;
148
+ gap: 0.6rem;
149
+ margin-top: 1rem;
150
+ }
151
+
152
+ .primary-btn,
153
+ .secondary-btn {
154
+ min-height: 2.5rem;
155
+ padding: 0.7rem 1rem;
156
+ border-radius: 0.8rem;
157
+ font: inherit;
158
+ cursor: pointer;
159
+ }
160
+
161
+ .primary-btn {
162
+ border: 1px solid rgba(255, 196, 120, 0.35);
163
+ background: rgba(255, 172, 80, 0.14);
164
+ color: rgba(255, 242, 220, 0.98);
165
+ }
166
+
167
+ .secondary-btn {
168
+ border: 1px solid rgba(255, 255, 255, 0.12);
169
+ background: rgba(255, 255, 255, 0.05);
170
+ color: rgba(248, 250, 252, 0.96);
171
+ }
172
+
173
+ button:disabled {
174
+ opacity: 0.5;
175
+ cursor: not-allowed;
176
+ }
177
+ </style>
@@ -20,14 +20,17 @@
20
20
  focusedAltKey,
21
21
  translatedValue = $bindable(),
22
22
  stagedTranslations,
23
- hasActiveSuggestion,
24
- pending,
25
- error,
26
- success,
23
+ hasActiveSuggestion,
24
+ hasDraft,
25
+ pending,
26
+ previewOnly,
27
+ error,
28
+ success,
27
29
  onClose,
28
30
  onSubmit,
29
- onStage,
30
- onAltFocusChange,
31
+ onStage,
32
+ onInputValue,
33
+ onAltFocusChange,
31
34
  onAltKeydown,
32
35
  onSelectAlt,
33
36
  isStaged,
@@ -43,14 +46,17 @@
43
46
  focusedAltKey: string | null;
44
47
  translatedValue: string;
45
48
  stagedTranslations: Record<string, DraftTranslation>;
46
- hasActiveSuggestion: boolean;
47
- pending: boolean;
48
- error: string | null;
49
- success: string | null;
49
+ hasActiveSuggestion: boolean;
50
+ hasDraft: boolean;
51
+ pending: boolean;
52
+ previewOnly: boolean;
53
+ error: string | null;
54
+ success: string | null;
50
55
  onClose: () => void;
51
56
  onSubmit: (event: SubmitEvent) => void;
52
- onStage: (event?: Event) => void;
53
- onAltFocusChange: (key: string | null) => void;
57
+ onStage: (event?: Event) => void;
58
+ onInputValue: () => void;
59
+ onAltFocusChange: (key: string | null) => void;
54
60
  onAltKeydown: (event: KeyboardEvent, msgid: string, msgctxt: string | null) => void;
55
61
  onSelectAlt: (event: Event, msgid: string, msgctxt: string | null) => void;
56
62
  isStaged: (msgid: string, msgctxt: string | null) => boolean;
@@ -152,7 +158,14 @@
152
158
  <div class="context-row">
153
159
  <span class="context-label">Match score</span>
154
160
  <span>{contextResult.match.score.toFixed(3)}</span>
155
- </div>
161
+ </div>
162
+
163
+ {#if contextResult.catalogState?.status === "out_of_sync"}
164
+ <div class="catalog-warning">
165
+ <span class="context-label">Warning</span>
166
+ <div>Catalogs are out of sync. Rotation can overwrite working-only translations.</div>
167
+ </div>
168
+ {/if}
156
169
 
157
170
  {#if contextResult.entry.flags.length}
158
171
  <div class="context-row">
@@ -214,27 +227,36 @@
214
227
  <label class="field">
215
228
  <span class="field-label">Selected text</span>
216
229
  <div class="selected-copy">{capturedSelection}</div>
217
- <textarea
218
- bind:this={translationInputElement}
219
- name="translationValue"
220
- class="translation-input"
221
- class:suggested={hasActiveSuggestion}
222
- rows="4"
223
- bind:value={translatedValue}
224
- placeholder={capturedSelection}
225
- onkeydown={(event) => {
226
- if (event.key === "Enter" && !event.shiftKey) {
227
- event.preventDefault();
228
- onStage(event);
229
- return;
230
- }
231
-
232
- if (event.key === "Tab") {
233
- event.preventDefault();
234
- moveSelection(event.shiftKey ? -1 : 1);
235
- }
236
- }}
237
- ></textarea>
230
+ {#if previewOnly}
231
+ <div class="preview-note">
232
+ Working locale preview mode is active. Rotate back to a non-working locale to stage or
233
+ commit translations.
234
+ </div>
235
+ {:else}
236
+ <textarea
237
+ bind:this={translationInputElement}
238
+ name="translationValue"
239
+ class="translation-input"
240
+ class:suggested={hasActiveSuggestion}
241
+ class:draft={hasDraft}
242
+ rows="4"
243
+ bind:value={translatedValue}
244
+ placeholder={capturedSelection}
245
+ oninput={onInputValue}
246
+ onkeydown={(event) => {
247
+ if (event.key === "Enter" && !event.shiftKey) {
248
+ event.preventDefault();
249
+ onStage(event);
250
+ return;
251
+ }
252
+
253
+ if (event.key === "Tab") {
254
+ event.preventDefault();
255
+ moveSelection(event.shiftKey ? -1 : 1);
256
+ }
257
+ }}
258
+ ></textarea>
259
+ {/if}
238
260
  </label>
239
261
 
240
262
  <div class="meta-row">
@@ -248,27 +270,29 @@
248
270
  <p>Failed: {error}</p>
249
271
  {/if}
250
272
 
251
- <button
252
- type="button"
253
- class="tool-btn"
254
- disabled={!translatedValue.trim() || !commitTarget}
255
- onclick={(event) => {
256
- event.preventDefault();
257
- event.stopPropagation();
258
- onStage(event);
259
- }}
260
- >
261
- Stage
262
- </button>
263
-
264
- <button
265
- type="submit"
266
- class="tool-btn primary"
267
- disabled={!hasDirtyTranslations || pending}
268
- aria-disabled={!hasDirtyTranslations || pending}
269
- >
270
- {pending ? "Committing..." : "Commit all"}
271
- </button>
273
+ {#if !previewOnly}
274
+ <button
275
+ type="button"
276
+ class="tool-btn"
277
+ disabled={!translatedValue.trim() || !commitTarget}
278
+ onclick={(event) => {
279
+ event.preventDefault();
280
+ event.stopPropagation();
281
+ onStage(event);
282
+ }}
283
+ >
284
+ Stage
285
+ </button>
286
+
287
+ <button
288
+ type="submit"
289
+ class="tool-btn primary"
290
+ disabled={!hasDirtyTranslations || pending}
291
+ aria-disabled={!hasDirtyTranslations || pending}
292
+ >
293
+ {pending ? "Committing..." : "Commit all"}
294
+ </button>
295
+ {/if}
272
296
  </div>
273
297
  </form>
274
298
 
@@ -455,12 +479,38 @@
455
479
  box-shadow: 0 0 0 1px rgba(255, 214, 102, 0.18);
456
480
  }
457
481
 
458
- .translation-input.suggested:hover,
459
- .translation-input.suggested:focus {
460
- background: rgba(255, 214, 102, 0.16);
461
- border-color: rgba(255, 214, 102, 0.48);
462
- box-shadow: 0 0 0 4px rgba(255, 214, 102, 0.12);
463
- }
482
+ .translation-input.suggested:hover,
483
+ .translation-input.suggested:focus {
484
+ background: rgba(255, 214, 102, 0.16);
485
+ border-color: rgba(255, 214, 102, 0.48);
486
+ box-shadow: 0 0 0 4px rgba(255, 214, 102, 0.12);
487
+ }
488
+
489
+ .translation-input.draft {
490
+ background: rgba(255, 120, 120, 0.12);
491
+ border-color: rgba(255, 130, 130, 0.38);
492
+ box-shadow: 0 0 0 1px rgba(255, 130, 130, 0.16);
493
+ }
494
+
495
+ .preview-note {
496
+ padding: 0.8rem 0.9rem;
497
+ border-radius: 0.8rem;
498
+ background: rgba(255, 196, 120, 0.08);
499
+ border: 1px solid rgba(255, 196, 120, 0.24);
500
+ color: rgba(255, 242, 220, 0.96);
501
+ font-size: 0.85rem;
502
+ line-height: 1.45;
503
+ }
504
+
505
+ .catalog-warning {
506
+ padding: 0.75rem 0.8rem;
507
+ border-radius: 0.8rem;
508
+ background: rgba(255, 188, 88, 0.1);
509
+ border: 1px solid rgba(255, 188, 88, 0.26);
510
+ color: rgba(255, 243, 220, 0.96);
511
+ font-size: 0.82rem;
512
+ line-height: 1.45;
513
+ }
464
514
 
465
515
  .meta-row {
466
516
  display: flex;
@@ -1,15 +1,19 @@
1
- <script lang="ts">
2
- let {
3
- text,
4
- position = 'top',
5
- disabled = false,
6
- delay = 120
7
- }: {
8
- text: string;
9
- position?: 'top' | 'bottom' | 'left' | 'right';
10
- disabled?: boolean;
11
- delay?: number;
12
- } = $props();
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ let {
5
+ text,
6
+ position = 'top',
7
+ disabled = false,
8
+ delay = 120,
9
+ children
10
+ }: {
11
+ text: string;
12
+ position?: 'top' | 'bottom' | 'left' | 'right';
13
+ disabled?: boolean;
14
+ delay?: number;
15
+ children?: Snippet;
16
+ } = $props();
13
17
 
14
18
  let visible = $state(false);
15
19
  let timeout: ReturnType<typeof setTimeout> | null = null;
@@ -42,8 +46,8 @@
42
46
  onmouseleave={hide}
43
47
  onfocusin={show}
44
48
  onfocusout={hide}
45
- >
46
- <slot />
49
+ >
50
+ {@render children?.()}
47
51
 
48
52
  {#if visible && !disabled && text}
49
53
  <div
@@ -1,48 +1,74 @@
1
1
 
2
2
  export function draggable(node: HTMLElement, param:{active: (activate:boolean) => void, drag: ()=> boolean}) {
3
-
4
- let x = 100;
5
- let y = 100;
6
- let offsetX = 0;
7
- let offsetY = 0;
8
- let dragging = false;
9
-
10
- function down(event: PointerEvent) {
11
- dragging = param.drag();
12
- offsetX = event.clientX - x;
13
- offsetY = event.clientY - y;
14
-
15
- window.addEventListener('pointermove', move);
16
- window.addEventListener('pointerup', up);
17
- param.active(false)
18
- }
19
-
20
- function move(event: PointerEvent) {
21
- if (!dragging) return;
22
- x = event.clientX - offsetX;
23
- y = event.clientY - offsetY;
24
- node.style.left = `${x}px`;
25
- node.style.top = `${y}px`;
26
- param.active(true)
27
- }
28
-
29
- function up(event: Event) {
30
- dragging = false;
31
- window.removeEventListener('pointermove', move);
32
- window.removeEventListener('pointerup', up);
33
-
34
- }
3
+
4
+ let x = 100;
5
+ let y = 100;
6
+ let offsetX = 0;
7
+ let offsetY = 0;
8
+ let dragging = false;
9
+ let pointerStartX = 0;
10
+ let pointerStartY = 0;
11
+ const dragThreshold = 6;
12
+ let suppressClick = false;
13
+
14
+ function down(event: PointerEvent) {
15
+ dragging = false;
16
+ pointerStartX = event.clientX;
17
+ pointerStartY = event.clientY;
18
+ offsetX = event.clientX - x;
19
+ offsetY = event.clientY - y;
20
+
21
+ window.addEventListener('pointermove', move);
22
+ window.addEventListener('pointerup', up);
23
+ }
24
+
25
+ function move(event: PointerEvent) {
26
+ if (!dragging) {
27
+ if (!param.drag()) return;
28
+ const movedX = Math.abs(event.clientX - pointerStartX);
29
+ const movedY = Math.abs(event.clientY - pointerStartY);
30
+ if (Math.max(movedX, movedY) < dragThreshold) return;
31
+ dragging = true;
32
+ param.active(true);
33
+ }
34
+
35
+ x = event.clientX - offsetX;
36
+ y = event.clientY - offsetY;
37
+ node.style.left = `${x}px`;
38
+ node.style.top = `${y}px`;
39
+ }
40
+
41
+ function up(event: Event) {
42
+ if (dragging) {
43
+ suppressClick = true;
44
+ param.active(false);
45
+ }
46
+ dragging = false;
47
+ window.removeEventListener('pointermove', move);
48
+ window.removeEventListener('pointerup', up);
49
+
50
+ }
51
+
52
+ function onClick(event: MouseEvent) {
53
+ if (!suppressClick) return;
54
+ event.preventDefault();
55
+ event.stopPropagation();
56
+ suppressClick = false;
57
+ }
35
58
 
36
- node.style.position = 'fixed';
37
- node.style.left = `${x}px`;
38
- node.style.top = `${y}px`;
39
- node.style.touchAction = 'none';
40
-
41
- node.addEventListener('pointerdown', down);
42
-
59
+ node.style.position = 'fixed';
60
+ node.style.left = `${x}px`;
61
+ node.style.top = `${y}px`;
62
+ node.style.touchAction = 'none';
63
+ param.active(false);
64
+
65
+ node.addEventListener('pointerdown', down);
66
+ node.addEventListener('click', onClick, true);
67
+
43
68
  return {
44
69
  destroy() {
45
70
  node.removeEventListener('pointerdown', down);
71
+ node.removeEventListener('click', onClick, true);
46
72
  window.removeEventListener('pointermove', move);
47
73
  window.removeEventListener('pointerup', up);
48
74
  }
@@ -1,4 +1,5 @@
1
- export type TranslationOrigin = "base" | "working";
1
+ export type TranslationOrigin = "base" | "working";
2
+ export type TranslationStatus = "base" | "working" | "none" | "fuzzy" | "out_of_sync";
2
3
 
3
4
  export type ResolvedKey = {
4
5
  msgid: string;
@@ -20,6 +21,7 @@ export type TranslationEntry = {
20
21
  isCommittedToWorking?: boolean;
21
22
  matchesTargetTranslation?: boolean;
22
23
  translationOrigin?: TranslationOrigin | null;
24
+ translationStatus?: TranslationStatus | null;
23
25
  };
24
26
 
25
27
  export type TranslationAlternative = {
@@ -33,16 +35,21 @@ export type TranslationAlternative = {
33
35
  isCommittedToWorking?: boolean;
34
36
  matchesTargetTranslation?: boolean;
35
37
  translationOrigin?: TranslationOrigin | null;
38
+ translationStatus?: TranslationStatus | null;
39
+ };
40
+
41
+ export type TranslationContextResult = {
42
+ match: {
43
+ score: number;
44
+ via: string;
45
+ };
46
+ catalogState?: {
47
+ status: "ok" | "out_of_sync";
48
+ affectedCount: number;
49
+ };
50
+ entry: TranslationEntry;
51
+ alternatives: TranslationAlternative[];
36
52
  };
37
-
38
- export type TranslationContextResult = {
39
- match: {
40
- score: number;
41
- via: string;
42
- };
43
- entry: TranslationEntry;
44
- alternatives: TranslationAlternative[];
45
- };
46
53
 
47
54
  export type DraftTranslation = {
48
55
  msgid: string;
@@ -59,7 +66,7 @@ export type TranslationCandidate = Pick<
59
66
  >;
60
67
 
61
68
  export const TRANSLATION_STATUS_TOOLTIP =
62
- "'\u{1F914}' fuzzy translation needs review. '\u2705' target and working agree. '\u274C' no translation. '\u{1F6E0}\uFE0F' working differs from target.";
69
+ "'\u{1F914}' fuzzy translation needs review. '\u2705' translation is in base. '\u274C' no translation. '\u{1F6E0}\uFE0F' working differs from base. '\u26A0\uFE0F' catalogs are out of sync.";
63
70
 
64
71
  export function translationKey(msgid: string, msgctxt: string | null) {
65
72
  return `${msgctxt ?? ""}::${msgid}`;
@@ -155,10 +162,11 @@ export function getTranslationStatus(item: {
155
162
  matchesTargetTranslation?: boolean;
156
163
  hasTranslation?: boolean;
157
164
  isFuzzy?: boolean | null;
165
+ translationStatus?: TranslationStatus | null;
158
166
  }) {
159
- if (item.hasTranslation && item.matchesTargetTranslation) return "\u2705";
160
- if (item.isCommittedToWorking) return "\u{1F6E0}\uFE0F";
161
- if (!item.hasTranslation) return "\u274C";
162
- if (item.isFuzzy) return "\u{1F914}";
167
+ if (item.translationStatus === "out_of_sync") return "\u26A0\uFE0F";
168
+ if (item.translationStatus === "fuzzy" || item.isFuzzy) return "\u{1F914}";
169
+ if (item.translationStatus === "working" || item.isCommittedToWorking) return "\u{1F6E0}\uFE0F";
170
+ if (item.translationStatus === "none" || !item.hasTranslation) return "\u274C";
163
171
  return "\u2705";
164
172
  }
@@ -0,0 +1,59 @@
1
+ import { translationKey } from "./toggleQA.shared";
2
+
3
+ const STORAGE_KEY = "angy-translation-drafts-v1";
4
+
5
+ export type DraftCacheItem = {
6
+ value: string;
7
+ isDirty: boolean;
8
+ };
9
+
10
+ type DraftCache = Record<string, DraftCacheItem>;
11
+
12
+ function isBrowser() {
13
+ return typeof window !== "undefined" && typeof localStorage !== "undefined";
14
+ }
15
+
16
+ export function readDraftCache(): DraftCache {
17
+ if (!isBrowser()) return {};
18
+
19
+ try {
20
+ const raw = localStorage.getItem(STORAGE_KEY);
21
+ if (!raw) return {};
22
+ const parsed = JSON.parse(raw);
23
+ return typeof parsed === "object" && parsed ? parsed : {};
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ export function writeDraftCache(cache: DraftCache) {
30
+ if (!isBrowser()) return;
31
+
32
+ try {
33
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
34
+ } catch {
35
+ // Ignore storage failures in this dev-only helper.
36
+ }
37
+ }
38
+
39
+ export function setDraftValue(
40
+ cache: DraftCache,
41
+ msgid: string,
42
+ msgctxt: string | null,
43
+ item: DraftCacheItem
44
+ ) {
45
+ const next = { ...cache, [translationKey(msgid, msgctxt)]: item };
46
+ writeDraftCache(next);
47
+ return next;
48
+ }
49
+
50
+ export function clearDraftValue(cache: DraftCache, msgid: string, msgctxt: string | null) {
51
+ const next = { ...cache };
52
+ delete next[translationKey(msgid, msgctxt)];
53
+ writeDraftCache(next);
54
+ return next;
55
+ }
56
+
57
+ export function getDraftValue(cache: DraftCache, msgid: string, msgctxt: string | null) {
58
+ return cache[translationKey(msgid, msgctxt)] ?? null;
59
+ }