svelte-rtf-editor 0.1.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.
@@ -0,0 +1,703 @@
1
+ <script lang="ts">
2
+ /**
3
+ * InkEditor — A reusable Svelte 5 Rich Text Editor component.
4
+ *
5
+ * Props:
6
+ * content — Initial HTML content (string). Default: empty paragraph.
7
+ * placeholder — Placeholder text shown when editor is empty.
8
+ * autosave — Enable localStorage auto-save. Default: true.
9
+ * storageKey — localStorage key for auto-save. Default: 'ink-editor-content'.
10
+ * showToolbar — Show the formatting toolbar. Default: true.
11
+ * showStatusBar — Show the word/char count status bar. Default: true.
12
+ * minHeight — CSS min-height of the editing area. Default: '40vh'.
13
+ * readonly — If true, editor is not editable. Default: false.
14
+ *
15
+ * Events (callback props):
16
+ * onchange — Fires on content change with { html, text, wordCount, charCount }.
17
+ * onsave — Fires on auto-save or Ctrl+S with { html }.
18
+ * onimport — Fires after a successful RTF import with { html }.
19
+ *
20
+ * Bindable methods (via bind:this):
21
+ * getHTML() — Returns the current editor HTML string.
22
+ * getText() — Returns plain text content.
23
+ * setHTML(html) — Replaces editor content with the given HTML.
24
+ * getMarkdown() — Returns Markdown conversion of current content.
25
+ * getRTF() — Returns RTF conversion of current content.
26
+ * clear() — Clears the editor (no confirm dialog).
27
+ * focus() — Focuses the editor.
28
+ * exportFile(format) — Downloads as 'html', 'md', or 'rtf'.
29
+ * importRtf() — Opens the file picker to import an RTF file.
30
+ */
31
+
32
+ import { onMount } from 'svelte';
33
+ import Toolbar from './Toolbar.svelte';
34
+ import Modal from './Modal.svelte';
35
+ import { htmlToMarkdown, downloadFile } from '../utils.js';
36
+ import { readRtfFile } from '../rtf-parser.js';
37
+ import { htmlToRtf } from '../rtf-writer.js';
38
+
39
+ interface ChangePayload {
40
+ html: string;
41
+ text: string;
42
+ wordCount: number;
43
+ charCount: number;
44
+ }
45
+
46
+ interface Props {
47
+ content?: string;
48
+ placeholder?: string;
49
+ autosave?: boolean;
50
+ storageKey?: string;
51
+ showToolbar?: boolean;
52
+ showStatusBar?: boolean;
53
+ minHeight?: string;
54
+ readonly?: boolean;
55
+ onchange?: (payload: ChangePayload) => void;
56
+ onsave?: (payload: { html: string }) => void;
57
+ onimport?: (payload: { html: string }) => void;
58
+ }
59
+
60
+ // ── Props ──
61
+ let {
62
+ content = '<p></p>',
63
+ placeholder = 'Start writing something beautiful...',
64
+ autosave = true,
65
+ storageKey = 'ink-editor-content',
66
+ showToolbar = true,
67
+ showStatusBar = true,
68
+ minHeight = '40vh',
69
+ readonly = false,
70
+ onchange,
71
+ onsave,
72
+ onimport
73
+ }: Props = $props();
74
+
75
+ // ── Internal state ──
76
+ let editorEl: HTMLDivElement | null = $state(null);
77
+ let wordCount: number = $state(0);
78
+ let charCount: number = $state(0);
79
+ let lastSaved: string = $state('');
80
+ let currentBlock: string = $state('p');
81
+
82
+ let formatState = $state({
83
+ bold: false,
84
+ italic: false,
85
+ underline: false,
86
+ strikeThrough: false
87
+ });
88
+
89
+ // Modal state
90
+ let linkModalOpen: boolean = $state(false);
91
+ let imageModalOpen: boolean = $state(false);
92
+ let linkUrl: string = $state('');
93
+ let linkText: string = $state('');
94
+ let imageUrl: string = $state('');
95
+ let imageAlt: string = $state('');
96
+ let savedSelection: Range | null = $state(null);
97
+
98
+ // Import state
99
+ let fileInputEl: HTMLInputElement | null = $state(null);
100
+ let importing: boolean = $state(false);
101
+ let importError: string = $state('');
102
+
103
+ // ── Public API (exposed via bind:this) ──
104
+ export function getHTML(): string {
105
+ return editorEl?.innerHTML || '';
106
+ }
107
+
108
+ export function getText(): string {
109
+ return editorEl?.innerText || '';
110
+ }
111
+
112
+ export function setHTML(html: string): void {
113
+ if (editorEl) {
114
+ editorEl.innerHTML = html;
115
+ updateCounts();
116
+ if (autosave) scheduleAutoSave();
117
+ }
118
+ }
119
+
120
+ export function getMarkdown(): string {
121
+ if (!editorEl) return '';
122
+ return htmlToMarkdown(editorEl);
123
+ }
124
+
125
+ export function getRTF(): string {
126
+ if (!editorEl) return '';
127
+ return htmlToRtf(editorEl);
128
+ }
129
+
130
+ export function clear(): void {
131
+ if (editorEl) {
132
+ editorEl.innerHTML = '<p></p>';
133
+ try { localStorage.removeItem(storageKey); } catch (e) {}
134
+ updateCounts();
135
+ lastSaved = '';
136
+ fireChange();
137
+ }
138
+ }
139
+
140
+ export function focus(): void {
141
+ editorEl?.focus();
142
+ }
143
+
144
+ export function exportFile(format = 'html'): void {
145
+ if (!editorEl) return;
146
+ switch (format) {
147
+ case 'html': {
148
+ const html = `<!DOCTYPE html>\n<html lang="en">\n<head><meta charset="UTF-8"><title>Document</title></head>\n<body>\n${editorEl.innerHTML}\n</body>\n</html>`;
149
+ downloadFile('document.html', html, 'text/html');
150
+ break;
151
+ }
152
+ case 'md':
153
+ case 'markdown': {
154
+ const md = htmlToMarkdown(editorEl);
155
+ downloadFile('document.md', md, 'text/markdown');
156
+ break;
157
+ }
158
+ case 'rtf': {
159
+ const rtf = htmlToRtf(editorEl);
160
+ downloadFile('document.rtf', rtf, 'application/rtf');
161
+ break;
162
+ }
163
+ }
164
+ }
165
+
166
+ export function importRtf(): void {
167
+ fileInputEl?.click();
168
+ }
169
+
170
+ // ── Selection helpers ──
171
+ function saveSelection(): void {
172
+ const sel = window.getSelection();
173
+ if (sel && sel.rangeCount > 0) {
174
+ savedSelection = sel.getRangeAt(0).cloneRange();
175
+ }
176
+ }
177
+
178
+ function restoreSelection(): void {
179
+ if (savedSelection) {
180
+ const sel = window.getSelection();
181
+ sel?.removeAllRanges();
182
+ sel?.addRange(savedSelection);
183
+ }
184
+ }
185
+
186
+ // ── Exec command ──
187
+ function exec(cmd: string, value: string | null = null): void {
188
+ restoreSelection();
189
+ editorEl?.focus();
190
+ document.execCommand(cmd, false, value ?? undefined);
191
+ updateToolbarState();
192
+ }
193
+
194
+ // ── Block type ──
195
+ function setBlock(tag: string): void {
196
+ restoreSelection();
197
+ editorEl?.focus();
198
+ document.execCommand('formatBlock', false, tag);
199
+ updateToolbarState();
200
+ }
201
+
202
+ // ── Toolbar state ──
203
+ function updateToolbarState(): void {
204
+ // Snapshot the selection so exec/setBlock can restore it even if focus
205
+ // temporarily moved to a toolbar element (e.g. colour picker, select).
206
+ saveSelection();
207
+
208
+ formatState = {
209
+ bold: document.queryCommandState('bold'),
210
+ italic: document.queryCommandState('italic'),
211
+ underline: document.queryCommandState('underline'),
212
+ strikeThrough: document.queryCommandState('strikeThrough')
213
+ };
214
+
215
+ const block = document.queryCommandValue('formatBlock');
216
+ const normalized = block.replace(/[<>]/g, '').toLowerCase();
217
+ const map: Record<string, string> = { h1: 'h1', h2: 'h2', h3: 'h3', blockquote: 'blockquote', pre: 'pre', p: 'p' };
218
+ currentBlock = map[normalized] || 'p';
219
+
220
+ updateCounts();
221
+ }
222
+
223
+ function updateCounts(): void {
224
+ if (!editorEl) return;
225
+ const text = editorEl.innerText || '';
226
+ const words = text.trim().split(/\s+/).filter((w) => w.length > 0);
227
+ wordCount = words.length;
228
+ charCount = text.length;
229
+ }
230
+
231
+ function fireChange(): void {
232
+ onchange?.({
233
+ html: editorEl?.innerHTML || '',
234
+ text: editorEl?.innerText || '',
235
+ wordCount,
236
+ charCount
237
+ });
238
+ }
239
+
240
+ // ── Auto-save ──
241
+ let saveTimer: ReturnType<typeof setTimeout> | undefined;
242
+
243
+ function autoSaveNow(): void {
244
+ if (!editorEl || !autosave) return;
245
+ try {
246
+ localStorage.setItem(storageKey, editorEl.innerHTML);
247
+ lastSaved = `Saved ${new Date().toLocaleTimeString()}`;
248
+ } catch (e) {}
249
+ onsave?.({ html: editorEl.innerHTML });
250
+ }
251
+
252
+ function scheduleAutoSave(): void {
253
+ clearTimeout(saveTimer);
254
+ saveTimer = setTimeout(autoSaveNow, 1000);
255
+ }
256
+
257
+ // ── Input handler ──
258
+ function handleInput(): void {
259
+ updateToolbarState();
260
+ fireChange();
261
+ if (autosave) scheduleAutoSave();
262
+ }
263
+
264
+ // ── Link modal ──
265
+ function openLinkModal(): void {
266
+ saveSelection();
267
+ linkUrl = '';
268
+ linkText = '';
269
+ linkModalOpen = true;
270
+ }
271
+
272
+ function applyLink(): void {
273
+ if (!linkUrl) { linkModalOpen = false; return; }
274
+ restoreSelection();
275
+ editorEl?.focus();
276
+
277
+ if (linkText) {
278
+ const sel = window.getSelection();
279
+ if (sel && sel.rangeCount) {
280
+ const range = sel.getRangeAt(0);
281
+ range.deleteContents();
282
+ const a = document.createElement('a');
283
+ a.href = linkUrl;
284
+ a.textContent = linkText;
285
+ range.insertNode(a);
286
+ range.setStartAfter(a);
287
+ sel.removeAllRanges();
288
+ sel.addRange(range);
289
+ }
290
+ } else {
291
+ document.execCommand('createLink', false, linkUrl);
292
+ }
293
+ linkModalOpen = false;
294
+ }
295
+
296
+ // ── Image modal ──
297
+ function openImageModal(): void {
298
+ saveSelection();
299
+ imageUrl = '';
300
+ imageAlt = '';
301
+ imageModalOpen = true;
302
+ }
303
+
304
+ function applyImage(): void {
305
+ if (!imageUrl) { imageModalOpen = false; return; }
306
+ restoreSelection();
307
+ editorEl?.focus();
308
+ document.execCommand('insertHTML', false, `<img src="${imageUrl}" alt="${imageAlt || ''}" />`);
309
+ imageModalOpen = false;
310
+ }
311
+
312
+ // ── Import RTF ──
313
+ async function handleFileImport(e: Event): Promise<void> {
314
+ const input = e.target as HTMLInputElement;
315
+ const file = input.files?.[0];
316
+ if (!file) return;
317
+
318
+ importError = '';
319
+ importing = true;
320
+
321
+ try {
322
+ const html = await readRtfFile(file);
323
+ if (editorEl) {
324
+ editorEl.innerHTML = html;
325
+ updateCounts();
326
+ fireChange();
327
+ if (autosave) scheduleAutoSave();
328
+ onimport?.({ html });
329
+ }
330
+ } catch (err) {
331
+ importError = (err as Error).message || 'Failed to import RTF file';
332
+ setTimeout(() => (importError = ''), 4000);
333
+ } finally {
334
+ importing = false;
335
+ if (fileInputEl) fileInputEl.value = '';
336
+ }
337
+ }
338
+
339
+ // ── Keyboard shortcuts ──
340
+ function handleKeydown(e: KeyboardEvent): void {
341
+ const mod = e.ctrlKey || e.metaKey;
342
+ if (mod && e.key === 'b') { e.preventDefault(); exec('bold'); }
343
+ if (mod && e.key === 'i') { e.preventDefault(); exec('italic'); }
344
+ if (mod && e.key === 'u') { e.preventDefault(); exec('underline'); }
345
+ if (mod && e.key === 'k') { e.preventDefault(); openLinkModal(); }
346
+ if (mod && e.key === 's') { e.preventDefault(); autoSaveNow(); }
347
+
348
+ if (e.key === 'Tab') {
349
+ const sel = window.getSelection();
350
+ if (sel?.anchorNode?.parentElement?.closest?.('pre')) {
351
+ e.preventDefault();
352
+ document.execCommand('insertHTML', false, ' ');
353
+ }
354
+ }
355
+ }
356
+
357
+ function handlePaste(e: ClipboardEvent): void {
358
+ const sel = window.getSelection();
359
+ if (sel?.anchorNode?.parentElement?.closest?.('pre')) {
360
+ e.preventDefault();
361
+ const text = e.clipboardData?.getData('text/plain') || '';
362
+ document.execCommand('insertText', false, text);
363
+ }
364
+ }
365
+
366
+ // ── Lifecycle ──
367
+ onMount(() => {
368
+ if (!editorEl) return;
369
+ if (autosave) {
370
+ try {
371
+ const saved = localStorage.getItem(storageKey);
372
+ if (saved) {
373
+ editorEl.innerHTML = saved;
374
+ updateCounts();
375
+ return;
376
+ }
377
+ } catch (e) {}
378
+ }
379
+ // Always write the initial content prop so the div isn't empty.
380
+ // {#html content} must NOT be used inside the contenteditable div —
381
+ // Svelte would re-inject it on every reactive update, duplicating text.
382
+ editorEl.innerHTML = content || '<p></p>';
383
+ updateCounts();
384
+ });
385
+
386
+ // Suppress unused warning for importing state (used in template)
387
+ $effect(() => { void importing; });
388
+ </script>
389
+
390
+ <!-- Hidden file input for RTF import -->
391
+ <input
392
+ type="file"
393
+ accept=".rtf,application/rtf,text/rtf"
394
+ class="ink-hidden-input"
395
+ bind:this={fileInputEl}
396
+ onchange={handleFileImport}
397
+ />
398
+
399
+ <div class="ink-editor" style="--ink-min-height: {minHeight};">
400
+ {#if showToolbar && !readonly}
401
+ <Toolbar
402
+ {formatState}
403
+ blockType={currentBlock}
404
+ onexec={exec}
405
+ onblock={setBlock}
406
+ oninsertlink={openLinkModal}
407
+ oninsertimage={openImageModal}
408
+ />
409
+ {/if}
410
+
411
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
412
+ <div
413
+ class="ink-content"
414
+ contenteditable={!readonly}
415
+ spellcheck="true"
416
+ bind:this={editorEl}
417
+ oninput={handleInput}
418
+ onmouseup={updateToolbarState}
419
+ onkeyup={updateToolbarState}
420
+ onkeydown={handleKeydown}
421
+ onpaste={handlePaste}
422
+ data-placeholder={placeholder}
423
+ ></div>
424
+
425
+ {#if showStatusBar}
426
+ <div class="ink-status-bar">
427
+ <span>{wordCount} word{wordCount !== 1 ? 's' : ''} · {charCount} character{charCount !== 1 ? 's' : ''}</span>
428
+ {#if autosave}
429
+ <span>{lastSaved || 'Auto-saved to browser'}</span>
430
+ {/if}
431
+ </div>
432
+ {/if}
433
+ </div>
434
+
435
+ <!-- Link Modal -->
436
+ <Modal title="Insert Link" open={linkModalOpen} onclose={() => (linkModalOpen = false)}>
437
+ <input type="text" placeholder="Link text (leave empty to use selection)" bind:value={linkText} />
438
+ <input
439
+ type="url"
440
+ placeholder="https://example.com"
441
+ bind:value={linkUrl}
442
+ onkeydown={(e) => e.key === 'Enter' && applyLink()}
443
+ />
444
+ {#snippet actions()}
445
+ <button class="ink-btn-ghost" onclick={() => (linkModalOpen = false)}>Cancel</button>
446
+ <button class="ink-btn-primary" onclick={applyLink}>Insert</button>
447
+ {/snippet}
448
+ </Modal>
449
+
450
+ <!-- Image Modal -->
451
+ <Modal title="Insert Image" open={imageModalOpen} onclose={() => (imageModalOpen = false)}>
452
+ <input
453
+ type="url"
454
+ placeholder="https://example.com/image.jpg"
455
+ bind:value={imageUrl}
456
+ onkeydown={(e) => e.key === 'Enter' && applyImage()}
457
+ />
458
+ <input type="text" placeholder="Alt text (optional)" bind:value={imageAlt} />
459
+ {#snippet actions()}
460
+ <button class="ink-btn-ghost" onclick={() => (imageModalOpen = false)}>Cancel</button>
461
+ <button class="ink-btn-primary" onclick={applyImage}>Insert</button>
462
+ {/snippet}
463
+ </Modal>
464
+
465
+ <!-- Import Error Toast -->
466
+ {#if importError}
467
+ <div class="ink-toast-error">
468
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
469
+ {importError}
470
+ </div>
471
+ {/if}
472
+
473
+ <style>
474
+ .ink-hidden-input {
475
+ display: none;
476
+ }
477
+
478
+ .ink-editor {
479
+ width: 100%;
480
+ }
481
+
482
+ /* ── Content Area ── */
483
+ .ink-content {
484
+ min-height: var(--ink-min-height, 40vh);
485
+ padding: 48px 56px;
486
+ background: white;
487
+ border: 1px solid var(--border, #e5e2dc);
488
+ border-radius: var(--radius, 8px);
489
+ box-shadow: var(--shadow-md, 0 4px 12px rgba(44, 37, 32, 0.08));
490
+ outline: none;
491
+ font-family: 'DM Sans', sans-serif;
492
+ font-size: 16px;
493
+ line-height: 1.75;
494
+ color: var(--text, #2c2520);
495
+ caret-color: var(--accent, #d4622b);
496
+ transition: border-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
497
+ }
498
+
499
+ .ink-content:focus {
500
+ border-color: var(--border-active, #c4b5a0);
501
+ }
502
+
503
+ /* Placeholder */
504
+ .ink-content:empty::before {
505
+ content: attr(data-placeholder);
506
+ color: var(--text-muted, #8a7e72);
507
+ opacity: 0.5;
508
+ pointer-events: none;
509
+ }
510
+
511
+ /* ── Typography inside editor ── */
512
+ .ink-content :global(h1) {
513
+ font-family: 'Instrument Serif', serif;
514
+ font-size: 38px;
515
+ line-height: 1.2;
516
+ color: var(--ink, #1a1714);
517
+ margin-bottom: 16px;
518
+ letter-spacing: -0.5px;
519
+ }
520
+
521
+ .ink-content :global(h2) {
522
+ font-family: 'Instrument Serif', serif;
523
+ font-size: 28px;
524
+ line-height: 1.3;
525
+ color: var(--ink, #1a1714);
526
+ margin-bottom: 12px;
527
+ margin-top: 32px;
528
+ }
529
+
530
+ .ink-content :global(h3) {
531
+ font-family: 'DM Sans', sans-serif;
532
+ font-size: 20px;
533
+ font-weight: 600;
534
+ line-height: 1.4;
535
+ color: var(--ink, #1a1714);
536
+ margin-bottom: 8px;
537
+ margin-top: 24px;
538
+ }
539
+
540
+ .ink-content :global(p) {
541
+ margin: 0 0 0.2em;
542
+ }
543
+
544
+ .ink-content :global(blockquote) {
545
+ border-left: 3px solid var(--accent, #d4622b);
546
+ padding: 8px 20px;
547
+ margin: 16px 0;
548
+ color: var(--text-muted, #8a7e72);
549
+ font-style: italic;
550
+ background: var(--accent-soft, #fdf0e9);
551
+ border-radius: 0 var(--radius-sm, 5px) var(--radius-sm, 5px) 0;
552
+ }
553
+
554
+ .ink-content :global(pre) {
555
+ background: var(--ink, #1a1714);
556
+ color: #e8e2da;
557
+ padding: 20px 24px;
558
+ border-radius: var(--radius, 8px);
559
+ font-family: 'JetBrains Mono', monospace;
560
+ font-size: 14px;
561
+ line-height: 1.6;
562
+ margin: 16px 0;
563
+ overflow-x: auto;
564
+ }
565
+
566
+ .ink-content :global(code) {
567
+ font-family: 'JetBrains Mono', monospace;
568
+ font-size: 14px;
569
+ background: var(--surface, #f2f0ec);
570
+ padding: 2px 6px;
571
+ border-radius: 3px;
572
+ color: var(--accent, #d4622b);
573
+ }
574
+
575
+ .ink-content :global(pre code) {
576
+ background: none;
577
+ padding: 0;
578
+ color: inherit;
579
+ }
580
+
581
+ .ink-content :global(ul),
582
+ .ink-content :global(ol) {
583
+ padding-left: 24px;
584
+ margin-bottom: 12px;
585
+ }
586
+
587
+ .ink-content :global(li) {
588
+ margin-bottom: 4px;
589
+ }
590
+
591
+ .ink-content :global(a) {
592
+ color: var(--accent, #d4622b);
593
+ text-decoration: underline;
594
+ text-underline-offset: 2px;
595
+ }
596
+
597
+ .ink-content :global(img) {
598
+ max-width: 100%;
599
+ border-radius: var(--radius, 8px);
600
+ margin: 16px 0;
601
+ }
602
+
603
+ .ink-content :global(hr) {
604
+ border: none;
605
+ height: 1px;
606
+ background: var(--border, #e5e2dc);
607
+ margin: 32px 0;
608
+ }
609
+
610
+ /* ── Status Bar ── */
611
+ .ink-status-bar {
612
+ display: flex;
613
+ align-items: center;
614
+ justify-content: space-between;
615
+ padding: 10px 0;
616
+ font-size: 12px;
617
+ color: var(--text-muted, #8a7e72);
618
+ letter-spacing: 0.2px;
619
+ }
620
+
621
+ .ink-status-bar span {
622
+ opacity: 0.7;
623
+ }
624
+
625
+ /* ── Buttons (for modals) ── */
626
+ :global(.ink-btn-primary) {
627
+ background: var(--accent, #d4622b);
628
+ border: none;
629
+ padding: 8px 18px;
630
+ border-radius: var(--radius-sm, 5px);
631
+ font-family: 'DM Sans', sans-serif;
632
+ font-size: 13px;
633
+ font-weight: 500;
634
+ color: white;
635
+ cursor: pointer;
636
+ transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
637
+ }
638
+
639
+ :global(.ink-btn-primary:hover) {
640
+ background: var(--accent-hover, #be5524);
641
+ }
642
+
643
+ :global(.ink-btn-ghost) {
644
+ background: none;
645
+ border: 1px solid var(--border, #e5e2dc);
646
+ padding: 8px 16px;
647
+ border-radius: var(--radius-sm, 5px);
648
+ font-family: 'DM Sans', sans-serif;
649
+ font-size: 13px;
650
+ font-weight: 500;
651
+ color: var(--text-muted, #8a7e72);
652
+ cursor: pointer;
653
+ transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
654
+ }
655
+
656
+ :global(.ink-btn-ghost:hover) {
657
+ border-color: var(--border-active, #c4b5a0);
658
+ color: var(--text, #2c2520);
659
+ background: var(--surface, #f2f0ec);
660
+ }
661
+
662
+ /* ── Error Toast ── */
663
+ .ink-toast-error {
664
+ position: fixed;
665
+ bottom: 24px;
666
+ left: 50%;
667
+ transform: translateX(-50%);
668
+ background: #d32f2f;
669
+ color: white;
670
+ padding: 10px 20px;
671
+ border-radius: var(--radius, 8px);
672
+ font-size: 13px;
673
+ font-weight: 500;
674
+ display: flex;
675
+ align-items: center;
676
+ gap: 8px;
677
+ box-shadow: var(--shadow-lg, 0 8px 30px rgba(44, 37, 32, 0.12));
678
+ z-index: 300;
679
+ animation: inkToastIn 0.25s ease-out;
680
+ }
681
+
682
+ .ink-toast-error svg {
683
+ flex-shrink: 0;
684
+ }
685
+
686
+ @keyframes inkToastIn {
687
+ from { opacity: 0; transform: translateX(-50%) translateY(12px); }
688
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
689
+ }
690
+
691
+ /* ── Responsive ── */
692
+ @media (max-width: 700px) {
693
+ .ink-content {
694
+ padding: 28px 24px;
695
+ }
696
+ .ink-content :global(h1) {
697
+ font-size: 28px;
698
+ }
699
+ .ink-content :global(h2) {
700
+ font-size: 22px;
701
+ }
702
+ }
703
+ </style>