@nocturnium/svelte-ide 1.3.0 → 1.5.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.
Files changed (31) hide show
  1. package/README.md +26 -4
  2. package/dist/components/ai/AIEditPreview.svelte +36 -6
  3. package/dist/components/ai/AIPanel.svelte +42 -14
  4. package/dist/components/core/Button.svelte +11 -4
  5. package/dist/components/core/Icon.svelte +19 -1
  6. package/dist/components/core/ResizeHandle.svelte +90 -6
  7. package/dist/components/core/ResizeHandle.svelte.d.ts +6 -0
  8. package/dist/components/core/Tooltip.svelte +13 -2
  9. package/dist/components/editor/CustomEditor.svelte +34 -0
  10. package/dist/components/editor/CustomEditor.svelte.d.ts +5 -1
  11. package/dist/components/editor/EchoCursorLayer.svelte +4 -1
  12. package/dist/components/editor/GhostBracketLayer.svelte +17 -7
  13. package/dist/components/editor/GitBlameLayer.svelte +10 -3
  14. package/dist/components/editor/InlineDiagnosticsLayer.svelte +226 -13
  15. package/dist/components/editor/InlineDiagnosticsLayer.svelte.d.ts +7 -0
  16. package/dist/components/editor/InlineDiffLayer.svelte +8 -2
  17. package/dist/components/editor/PluginPreviewSandbox.svelte +10 -2
  18. package/dist/components/editor/ProblemsPanel.svelte +40 -5
  19. package/dist/components/editor/SnippetPalette.svelte +63 -20
  20. package/dist/components/editor/core/diagnostics.js +4 -1
  21. package/dist/components/editor/core/extract-variable.d.ts +48 -0
  22. package/dist/components/editor/core/extract-variable.js +457 -0
  23. package/dist/components/editor/core/index.d.ts +2 -0
  24. package/dist/components/editor/core/index.js +2 -0
  25. package/dist/components/editor/core/organize-imports.d.ts +38 -0
  26. package/dist/components/editor/core/organize-imports.js +249 -0
  27. package/dist/components/editor/core/snippet-manager.js +3 -3
  28. package/dist/components/plugins/PluginCard.svelte +21 -1
  29. package/dist/components/plugins/PluginPanel.svelte +17 -3
  30. package/dist/styles/theme.css +8 -1
  31. package/package.json +1 -1
package/README.md CHANGED
@@ -8,9 +8,10 @@ framework.
8
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
9
9
  [![svelte](https://img.shields.io/badge/Svelte-5-ff3e00.svg)](https://svelte.dev)
10
10
 
11
- Built from scratch with Svelte 5 runes and zero runtime UI dependencies. Use a
12
- single `<CustomEditor>` for a textarea-grade upgrade, or compose the editor,
13
- LSP, collaboration, AI, and plugin pieces into a full IDE experience.
11
+ Built from scratch with Svelte 5 runes and **zero required runtime dependencies
12
+ beyond the Svelte 5 peer**. Use a single `<CustomEditor>` for a textarea-grade
13
+ upgrade, or compose the editor, LSP, collaboration, AI, and plugin pieces into a
14
+ full IDE experience.
14
15
 
15
16
  ---
16
17
 
@@ -26,7 +27,9 @@ LSP, collaboration, AI, and plugin pieces into a full IDE experience.
26
27
  - **AI panel & agent presence** layers for assistant UI and presence patterns.
27
28
  - **Plugin system** with a proposal-based lifecycle (bring your own backend).
28
29
  - **Themeable** — every color/size is a CSS custom property you can override.
29
- - **Zero external UI dependencies**; collaboration deps are optional peers.
30
+ - **Minimal footprint** no required runtime dependencies beyond the Svelte 5
31
+ peer; styling is plain CSS variables (no CSS framework). The Yjs collaboration
32
+ stack is the only other runtime dependency, and it's an optional peer.
30
33
 
31
34
  ## Install
32
35
 
@@ -43,6 +46,25 @@ Collaboration is optional and tree-shakeable — install these only if you use t
43
46
  npm install yjs y-websocket y-protocols
44
47
  ```
45
48
 
49
+ ## Dependencies
50
+
51
+ The published package carries **no top-level `dependencies`** — it ships with
52
+ zero required runtime dependencies beyond the Svelte 5 peer.
53
+
54
+ - **`svelte` `^5.0.0`** — the one required peer. Your app already has it.
55
+ - **`yjs`, `y-protocols`, `y-websocket`** — _optional_ peers, marked
56
+ `optional: true`. They are imported only by the realtime-collaboration code
57
+ (`<CollaborativeEditor>` and the `./crdt` entry), so they're pulled in only if
58
+ you actually use collaboration. The rest of the library never touches them.
59
+ - **No CSS framework at runtime.** Styling is plain CSS custom properties
60
+ shipped in `@nocturnium/svelte-ide/theme.css` — no Tailwind, no `@apply`, no
61
+ utility-class runtime. (Tailwind is used only to build this repo's demo site.)
62
+
63
+ Everything else in `package.json` — Vite, ESLint, TypeScript, Vitest,
64
+ Playwright, semantic-release, Tailwind, Prettier, and the rest — is a
65
+ `devDependency` used to build, test, and lint the library. None of it is
66
+ published: only the `dist/` folder ships (`"files": ["dist"]`).
67
+
46
68
  ## Quick start
47
69
 
48
70
  Import the component **and** the theme stylesheet (components are unstyled
@@ -34,6 +34,18 @@
34
34
  return 'default';
35
35
  }
36
36
  });
37
+
38
+ type DiffLineKind = 'added' | 'removed' | 'context';
39
+
40
+ const diffLines = $derived.by((): Array<{ kind: DiffLineKind; text: string }> => {
41
+ if (!session.diff) return [];
42
+ return session.diff.split('\n').map((text) => {
43
+ let kind: DiffLineKind = 'context';
44
+ if (text.startsWith('+') && !text.startsWith('+++')) kind = 'added';
45
+ else if (text.startsWith('-') && !text.startsWith('---')) kind = 'removed';
46
+ return { kind, text };
47
+ });
48
+ });
37
49
  </script>
38
50
 
39
51
  <div class="ai-edit-preview {className}">
@@ -52,7 +64,11 @@
52
64
 
53
65
  {#if session.diff}
54
66
  <div class="ai-edit-preview__diff">
55
- <pre>{session.diff}</pre>
67
+ {#each diffLines as line, i (i)}
68
+ <div class="ai-edit-preview__diff-line ai-edit-preview__diff-line--{line.kind}">
69
+ {line.text || ' '}
70
+ </div>
71
+ {/each}
56
72
  </div>
57
73
  {/if}
58
74
 
@@ -110,16 +126,30 @@
110
126
  .ai-edit-preview__diff {
111
127
  max-height: 300px;
112
128
  overflow: auto;
129
+ padding: var(--ide-spacing-sm) 0;
113
130
  background: var(--ide-bg-primary);
114
- }
115
-
116
- .ai-edit-preview__diff pre {
117
- margin: 0;
118
- padding: var(--ide-spacing-md);
119
131
  font-family: var(--ide-font-mono);
120
132
  font-size: var(--ide-font-size-xs);
121
133
  line-height: 1.6;
134
+ }
135
+
136
+ .ai-edit-preview__diff-line {
137
+ padding: 0 var(--ide-spacing-md);
138
+ /* keep the +/- gutter and code intact, wrapping long lines */
122
139
  white-space: pre-wrap;
140
+ /* reserve the indent the colored rows claim with their left border,
141
+ so added/removed text doesn't shift relative to context lines */
142
+ border-left: 2px solid transparent;
143
+ }
144
+
145
+ .ai-edit-preview__diff-line--added {
146
+ background: color-mix(in srgb, var(--ide-success) 12%, transparent);
147
+ border-left-color: var(--ide-success);
148
+ }
149
+
150
+ .ai-edit-preview__diff-line--removed {
151
+ background: color-mix(in srgb, var(--ide-error) 12%, transparent);
152
+ border-left-color: var(--ide-error);
123
153
  }
124
154
 
125
155
  .ai-edit-preview__actions {
@@ -60,8 +60,25 @@
60
60
  onMount(async () => {
61
61
  await initPersistence();
62
62
  persistedConversations = await loadConversations();
63
+ // Anchor the conversation to the START of the last message on mount so a
64
+ // seeded/long assistant message reads from its header, not a stale browser
65
+ // mid-content scroll offset (which looks like an abandoned position).
66
+ scrollToLastMessageStart();
63
67
  });
64
68
 
69
+ // Bring the start of the most recent message into view (its header at the top
70
+ // of the viewport) rather than slamming to the very bottom of its body.
71
+ function scrollToLastMessageStart() {
72
+ if (!messagesContainer) return;
73
+ const messages = messagesContainer.querySelectorAll('.ai-message, [data-ai-message]');
74
+ const last = messages[messages.length - 1] as HTMLElement | undefined;
75
+ if (last) {
76
+ messagesContainer.scrollTop = Math.max(0, last.offsetTop - messagesContainer.offsetTop);
77
+ } else {
78
+ messagesContainer.scrollTop = 0;
79
+ }
80
+ }
81
+
65
82
  // Auto-scroll to bottom when new messages arrive
66
83
  $effect(() => {
67
84
  if (messagesContainer && getMessages().length > 0) {
@@ -201,6 +218,7 @@
201
218
  size="xs"
202
219
  onclick={() => (sidebarOpen = !sidebarOpen)}
203
220
  title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
221
+ aria-label={sidebarOpen ? 'Hide conversation sidebar' : 'Show conversation sidebar'}
204
222
  >
205
223
  <Icon name={sidebarOpen ? 'panel-left-close' : 'panel-left'} size={14} />
206
224
  </Button>
@@ -208,12 +226,16 @@
208
226
  <Icon name="sparkles" size={16} />
209
227
  <span>AI Assistant</span>
210
228
  </div>
211
- <span class="ai-panel__mock-badge" title="Demo mock - no real model"
212
- >Demo mock - no real model</span
213
- >
229
+ <span class="ai-panel__mock-badge" title="Demo mock - no real model">Demo mock</span>
214
230
  </div>
215
231
  <div class="ai-panel__actions">
216
- <Button variant="ghost" size="xs" onclick={handleNewConversation} title="New conversation">
232
+ <Button
233
+ variant="ghost"
234
+ size="xs"
235
+ onclick={handleNewConversation}
236
+ title="New conversation"
237
+ aria-label="New conversation"
238
+ >
217
239
  <Icon name="plus" size={14} />
218
240
  </Button>
219
241
  </div>
@@ -271,7 +293,13 @@
271
293
  <div class="ai-panel__error">
272
294
  <Icon name="alert-circle" size={14} />
273
295
  <span>{getError()}</span>
274
- <Button variant="ghost" size="xs" onclick={clearError}>
296
+ <Button
297
+ variant="ghost"
298
+ size="xs"
299
+ onclick={clearError}
300
+ title="Dismiss error"
301
+ aria-label="Dismiss error"
302
+ >
275
303
  <Icon name="x" size={12} />
276
304
  </Button>
277
305
  </div>
@@ -283,7 +311,7 @@
283
311
  bind:this={textareaEl}
284
312
  bind:value={inputValue}
285
313
  class="ai-panel__textarea"
286
- placeholder="Ask AI anything... (Enter to send, Shift+Enter for new line)"
314
+ placeholder="Ask AI anything..."
287
315
  rows={1}
288
316
  disabled={getIsStreaming() || isSubmitting}
289
317
  onkeydown={handleKeydown}
@@ -356,9 +384,9 @@
356
384
  .ai-panel__mock-badge {
357
385
  display: inline-flex;
358
386
  align-items: center;
359
- min-width: 0;
360
- max-width: 100%;
361
- overflow: hidden;
387
+ /* Size to the label so it never clips its own text; the header-left flex
388
+ keeps it from squeezing the title. Full copy lives in the title tooltip. */
389
+ flex-shrink: 0;
362
390
  padding: 0.125rem var(--ide-spacing-sm);
363
391
  border: 1px solid color-mix(in srgb, var(--ide-accent) 40%, var(--ide-border));
364
392
  border-radius: var(--ide-radius-full);
@@ -367,7 +395,6 @@
367
395
  font-size: var(--ide-font-size-xs);
368
396
  font-weight: 500;
369
397
  line-height: var(--ide-line-height-normal);
370
- text-overflow: ellipsis;
371
398
  white-space: nowrap;
372
399
  }
373
400
 
@@ -504,6 +531,11 @@
504
531
  align-items: flex-end;
505
532
  gap: var(--ide-spacing-sm);
506
533
  padding: var(--ide-spacing-md);
534
+ /* Anchor the composer to the bottom of the panel's flex column with a real
535
+ bottom inset (incl. iOS safe-area) so it always sits above the host
536
+ status bar and its border never reads as bleeding off-screen. */
537
+ padding-bottom: calc(var(--ide-spacing-md) + env(safe-area-inset-bottom, 0px));
538
+ flex-shrink: 0;
507
539
  border-top: 1px solid var(--ide-border);
508
540
  background: var(--ide-bg-secondary);
509
541
  }
@@ -564,10 +596,6 @@
564
596
  .ai-panel__input {
565
597
  padding: var(--ide-spacing-sm);
566
598
  }
567
-
568
- .ai-panel__mock-badge {
569
- white-space: normal;
570
- }
571
599
  }
572
600
 
573
601
  @media (max-width: 480px) {
@@ -74,8 +74,15 @@
74
74
  outline-offset: 2px;
75
75
  }
76
76
 
77
- .ide-button:disabled {
78
- opacity: 0.5;
77
+ /* Disabled/loading: dim the fill, not the label. Routing every variant
78
+ to a neutral tertiary surface with muted (still legible) text keeps the
79
+ label >=3:1 instead of multiplying the variant fill by opacity 0.5,
80
+ which crushed cream-on-orange/white-on-salmon below readable contrast. */
81
+ .ide-button.ide-button:disabled {
82
+ background: var(--ide-bg-tertiary);
83
+ color: var(--ide-text-secondary);
84
+ border-color: var(--ide-border);
85
+ opacity: 0.85;
79
86
  cursor: not-allowed;
80
87
  }
81
88
 
@@ -86,7 +93,7 @@
86
93
  /* Variants */
87
94
  .ide-button--primary {
88
95
  background: var(--ide-primary);
89
- color: var(--ide-text-primary);
96
+ color: var(--ide-text-inverse);
90
97
  }
91
98
 
92
99
  .ide-button--primary:hover:not(:disabled) {
@@ -116,7 +123,7 @@
116
123
 
117
124
  .ide-button--danger {
118
125
  background: var(--ide-error);
119
- color: white;
126
+ color: var(--ide-text-inverse);
120
127
  }
121
128
 
122
129
  .ide-button--danger:hover:not(:disabled) {
@@ -14,6 +14,8 @@
14
14
  folder: 'M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z',
15
15
  'folder-open':
16
16
  'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2v2M2 11h20l-2 8H4l-2-8z',
17
+ 'file-code':
18
+ 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM14 2v6h6M10 13l-2 2 2 2M14 13l2 2-2 2',
17
19
 
18
20
  // Actions
19
21
  close: 'M18 6L6 18M6 6l12 12',
@@ -55,6 +57,11 @@
55
57
  bot: 'M12 8V4H8M12 8a4 4 0 0 0-4 4v6a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-6a4 4 0 0 0-4-4zM9 13h.01M15 13h.01',
56
58
  wand: 'M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8L19 13M17.8 6.2L19 5M12.2 11.8L11 13M12.2 6.2L11 5M12 12l-9 9',
57
59
  message: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10z',
60
+ 'message-circle':
61
+ 'M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z',
62
+ send: 'M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z',
63
+ // loader: a continuous spinner ring (3/4 arc with a leading cap) — distinct from `loading`'s 8 discrete spokes
64
+ loader: 'M21 12a9 9 0 1 1-6.219-8.56',
58
65
 
59
66
  // Status
60
67
  info: 'M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10zM12 16v-4M12 8h.01',
@@ -77,6 +84,8 @@
77
84
  'git-commit': 'M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM1.05 12H8M16 12h6.95',
78
85
  'git-merge':
79
86
  'M18 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21V9a9 9 0 0 0 9 9M6 21h12',
87
+ 'git-compare':
88
+ 'M18 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM13 6h3a2 2 0 0 1 2 2v7M11 18H8a2 2 0 0 1-2-2V9',
80
89
 
81
90
  // Misc
82
91
  play: 'M5 3l14 9-14 9V3z',
@@ -87,11 +96,20 @@
87
96
  clock: 'M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10zM12 6v6l4 2',
88
97
  zap: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z',
89
98
  plugin:
90
- 'M12 2v6M12 18v4M4.93 4.93l4.24 4.24M14.83 14.83l4.24 4.24M2 12h6M18 12h4M4.93 19.07l4.24-4.24M14.83 9.17l4.24-4.24'
99
+ 'M12 2v6M12 18v4M4.93 4.93l4.24 4.24M14.83 14.83l4.24 4.24M2 12h6M18 12h4M4.93 19.07l4.24-4.24M14.83 9.17l4.24-4.24',
100
+ database:
101
+ 'M12 8c4.418 0 8-1.343 8-3s-3.582-3-8-3-8 1.343-8 3 3.582 3 8 3zM20 5v6c0 1.657-3.582 3-8 3s-8-1.343-8-3V5M20 11v6c0 1.657-3.582 3-8 3s-8-1.343-8-3v-6'
91
102
  };
92
103
 
93
104
  const path = $derived(icons[name] ?? icons.file);
94
105
  const sizeValue = $derived(typeof size === 'number' ? `${size}px` : size);
106
+
107
+ // Dev-time guard: warn when a name misses the map so silent `file`-fallback drift is caught.
108
+ $effect(() => {
109
+ if (import.meta.env.DEV && !(name in icons)) {
110
+ console.warn(`[Icon] no glyph for name "${name}" — falling back to the generic "file" icon`);
111
+ }
112
+ });
95
113
  </script>
96
114
 
97
115
  <svg
@@ -18,6 +18,12 @@
18
18
  max?: number;
19
19
  /** Current size in pixels */
20
20
  size: number;
21
+ /** Step (px) for keyboard arrow adjustment (default 10; Shift = 5x) */
22
+ step?: number;
23
+ /** Size to snap to on double-click (defaults to the midpoint of min/max) */
24
+ defaultSize?: number;
25
+ /** Accessible name for the slider (e.g. "Resize left panel") */
26
+ ariaLabel?: string;
21
27
  /** Callback when size changes */
22
28
  onResize?: (size: number) => void;
23
29
  /** Callback when drag starts */
@@ -34,6 +40,9 @@
34
40
  min = 100,
35
41
  max = 800,
36
42
  size,
43
+ step = 10,
44
+ defaultSize,
45
+ ariaLabel,
37
46
  onResize,
38
47
  onResizeStart,
39
48
  onResizeEnd,
@@ -102,9 +111,43 @@
102
111
  }
103
112
 
104
113
  function handleDoubleClick() {
105
- // Reset to default size on double-click
106
- const defaultSize = Math.round((min + max) / 2);
107
- onResize?.(defaultSize);
114
+ // Reset to the configured default (or the midpoint of min/max).
115
+ onResize?.(defaultSize ?? Math.round((min + max) / 2));
116
+ }
117
+
118
+ // Keyboard operation for the slider role: arrows adjust the size, Home/End jump
119
+ // to min/max. Without this the role="slider" + aria-value* attributes promise
120
+ // an adjustable control that a keyboard/SR user cannot actually move.
121
+ function handleKeyDown(e: KeyboardEvent) {
122
+ const amount = e.shiftKey ? step * 5 : step;
123
+ let next: number;
124
+ switch (e.key) {
125
+ case 'ArrowRight':
126
+ case 'ArrowUp':
127
+ next = size + amount;
128
+ break;
129
+ case 'ArrowLeft':
130
+ case 'ArrowDown':
131
+ next = size - amount;
132
+ break;
133
+ case 'Home':
134
+ next = min;
135
+ break;
136
+ case 'End':
137
+ next = max;
138
+ break;
139
+ case 'Enter':
140
+ case ' ':
141
+ // Keyboard equivalent of double-click-to-reset: snap to the
142
+ // configured default (or the midpoint of min/max).
143
+ next = defaultSize ?? Math.round((min + max) / 2);
144
+ break;
145
+ default:
146
+ return;
147
+ }
148
+ e.preventDefault();
149
+ const clamped = Math.max(min, Math.min(max, next));
150
+ if (clamped !== size) onResize?.(clamped);
108
151
  }
109
152
 
110
153
  // Cleanup on destroy
@@ -123,7 +166,9 @@
123
166
  class:resize-handle--dragging={isDragging}
124
167
  onmousedown={handleMouseDown}
125
168
  ondblclick={handleDoubleClick}
169
+ onkeydown={handleKeyDown}
126
170
  role="slider"
171
+ aria-label={ariaLabel}
127
172
  aria-orientation={direction}
128
173
  aria-valuenow={size}
129
174
  aria-valuemin={min}
@@ -131,6 +176,11 @@
131
176
  tabindex={0}
132
177
  >
133
178
  <div class="resize-handle__indicator"></div>
179
+ <div class="resize-handle__grip" aria-hidden="true">
180
+ <span></span>
181
+ <span></span>
182
+ <span></span>
183
+ </div>
134
184
  </div>
135
185
 
136
186
  <style>
@@ -163,7 +213,9 @@
163
213
 
164
214
  .resize-handle__indicator {
165
215
  position: absolute;
166
- background: var(--ide-border, rgba(45, 90, 123, 0.4));
216
+ /* Brighter resting hairline so the handle reads as a grab affordance
217
+ without leaning on the page's "Drag the edge" labels. */
218
+ background: color-mix(in srgb, var(--ide-border) 55%, var(--ide-text-secondary));
167
219
  transition: background-color 0.15s ease;
168
220
  }
169
221
 
@@ -184,17 +236,49 @@
184
236
  }
185
237
 
186
238
  .resize-handle:hover .resize-handle__indicator,
239
+ .resize-handle:focus-visible .resize-handle__indicator,
187
240
  .resize-handle--dragging .resize-handle__indicator {
188
241
  background: var(--ide-interactive, #4a8db7);
189
242
  }
190
243
 
244
+ /* Centered grip motif (2-3 dots) so the resting handle reads as grabbable. */
245
+ .resize-handle__grip {
246
+ position: absolute;
247
+ top: 50%;
248
+ left: 50%;
249
+ display: flex;
250
+ gap: 3px;
251
+ transform: translate(-50%, -50%);
252
+ pointer-events: none;
253
+ }
254
+
255
+ .resize-handle__grip span {
256
+ display: block;
257
+ width: 2px;
258
+ height: 2px;
259
+ border-radius: 50%;
260
+ background: color-mix(in srgb, var(--ide-border) 40%, var(--ide-text-secondary));
261
+ transition: background-color 0.15s ease;
262
+ }
263
+
264
+ /* Stack the dots along the drag axis (vertical handle = dots in a column). */
265
+ .resize-handle--vertical .resize-handle__grip {
266
+ flex-direction: column;
267
+ }
268
+
269
+ .resize-handle:hover .resize-handle__grip span,
270
+ .resize-handle:focus-visible .resize-handle__grip span,
271
+ .resize-handle--dragging .resize-handle__grip span {
272
+ background: var(--ide-interactive, #4a8db7);
273
+ }
274
+
191
275
  /* Focus styles for keyboard accessibility */
192
276
  .resize-handle:focus {
193
277
  outline: none;
194
278
  }
195
279
 
196
280
  .resize-handle:focus-visible {
197
- outline: 2px solid var(--ide-interactive, #4a8db7);
198
- outline-offset: -2px;
281
+ outline: 2px solid var(--ide-interactive-focus, #4a8db7);
282
+ outline-offset: 2px;
199
283
  }
200
284
  </style>
@@ -9,6 +9,12 @@ interface Props {
9
9
  max?: number;
10
10
  /** Current size in pixels */
11
11
  size: number;
12
+ /** Step (px) for keyboard arrow adjustment (default 10; Shift = 5x) */
13
+ step?: number;
14
+ /** Size to snap to on double-click (defaults to the midpoint of min/max) */
15
+ defaultSize?: number;
16
+ /** Accessible name for the slider (e.g. "Resize left panel") */
17
+ ariaLabel?: string;
12
18
  /** Callback when size changes */
13
19
  onResize?: (size: number) => void;
14
20
  /** Callback when drag starts */
@@ -1,3 +1,9 @@
1
+ <script module lang="ts">
2
+ // Stable per-instance counter (SSR-safe — no Math.random/Date) so the bubble's
3
+ // id matches across server render and hydration for aria-describedby.
4
+ let tooltipCounter = 0;
5
+ </script>
6
+
1
7
  <script lang="ts">
2
8
  import type { Snippet } from 'svelte';
3
9
 
@@ -11,6 +17,7 @@
11
17
 
12
18
  let { content, position = 'top', delay = 300, class: className = '', children }: Props = $props();
13
19
 
20
+ const tooltipId = `ide-tooltip-${++tooltipCounter}`;
14
21
  let visible = $state(false);
15
22
  let timeout: ReturnType<typeof setTimeout> | null = null;
16
23
 
@@ -29,17 +36,21 @@
29
36
  }
30
37
  </script>
31
38
 
39
+ <!-- Presentational hover/focus container: the real trigger is the wrapped child,
40
+ and the tooltip text is exposed via aria-describedby. It is deliberately NOT
41
+ given a role (a role="tooltip" here was the original bug). -->
42
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
32
43
  <div
33
44
  class="ide-tooltip-wrapper {className}"
34
45
  onmouseenter={show}
35
46
  onmouseleave={hide}
36
47
  onfocus={show}
37
48
  onblur={hide}
38
- role="tooltip"
49
+ aria-describedby={visible && content ? tooltipId : undefined}
39
50
  >
40
51
  {@render children()}
41
52
  {#if visible && content}
42
- <div class="ide-tooltip ide-tooltip--{position}" role="tooltip">
53
+ <div id={tooltipId} class="ide-tooltip ide-tooltip--{position}" role="tooltip">
43
54
  {content}
44
55
  </div>
45
56
  {/if}
@@ -8,8 +8,12 @@
8
8
  createDefaultKeybindings,
9
9
  createFoldManager,
10
10
  extractFunctionAt,
11
+ extractVariableAt,
12
+ organizeImportsAt,
11
13
  type EditorState,
12
14
  type ExtractFunctionResult,
15
+ type ExtractVariableResult,
16
+ type OrganizeImportsResult,
13
17
  type Selection,
14
18
  type SearchMatch,
15
19
  type FoldManager,
@@ -607,6 +611,36 @@
607
611
  return result;
608
612
  }
609
613
 
614
+ /**
615
+ * Extract the current selection into a hoisted `const` (Track H). Single undo
616
+ * step; on refusal the editor is untouched and the reason is returned.
617
+ */
618
+ export function extractVariable(): ExtractVariableResult {
619
+ const result = extractVariableAt(editorState);
620
+ announce(result.ok ? 'Extracted variable' : result.reason);
621
+ return result;
622
+ }
623
+
624
+ /**
625
+ * Sort, de-duplicate, and tidy the leading import block (Track H). Single undo
626
+ * step; on refusal the editor is untouched and the reason is returned.
627
+ */
628
+ export function organizeImports(): OrganizeImportsResult {
629
+ const result = organizeImportsAt(editorState);
630
+ announce(result.ok ? 'Organized imports' : result.reason);
631
+ return result;
632
+ }
633
+
634
+ /** Current primary selection (anchor/head), for callers driving code actions. */
635
+ export function getSelection(): Selection {
636
+ return editorState.selection;
637
+ }
638
+
639
+ /** Text covered by the current primary selection ('' when collapsed). */
640
+ export function getSelectedText(): string {
641
+ return editorState.getSelectedText();
642
+ }
643
+
610
644
  function handleFoldIndicatorClick(lineNumber: number, e: MouseEvent) {
611
645
  e.preventDefault();
612
646
  e.stopPropagation();
@@ -1,5 +1,5 @@
1
1
  import type { EditorPreferences } from '../../types';
2
- import { type ExtractFunctionResult, type Cursor } from './core';
2
+ import { type ExtractFunctionResult, type ExtractVariableResult, type OrganizeImportsResult, type Selection, type Cursor } from './core';
3
3
  import { type ComplexityMetrics, type ComplexityRegion } from './core/complexity-analyzer';
4
4
  import { type FoldPreset } from './core/semantic-analyzer';
5
5
  import type { AIAwareness } from './core/ai-awareness';
@@ -39,6 +39,10 @@ declare const CustomEditor: import("svelte").Component<Props, {
39
39
  flashComplexityRegion: (region: ComplexityRegion) => void;
40
40
  scrollToLine: (line: number, region?: ComplexityRegion) => Promise<void>;
41
41
  extractFunction: () => ExtractFunctionResult;
42
+ extractVariable: () => ExtractVariableResult;
43
+ organizeImports: () => OrganizeImportsResult;
44
+ getSelection: () => Selection;
45
+ getSelectedText: () => string;
42
46
  }, "content">;
43
47
  type CustomEditor = ReturnType<typeof CustomEditor>;
44
48
  export default CustomEditor;
@@ -400,7 +400,10 @@
400
400
 
401
401
  /* Mode indicator */
402
402
  .echo-mode-indicator {
403
- position: fixed;
403
+ /* Anchor to this layer's own editor (the .echo-cursor-layer fills it via
404
+ absolute inset:0), not the viewport — a fixed pill would float free of
405
+ the editor it reports on wherever the layer is embedded. */
406
+ position: absolute;
404
407
  bottom: 16px;
405
408
  right: 16px;
406
409
  display: flex;
@@ -228,23 +228,31 @@
228
228
  display: inline-block;
229
229
  font-family: inherit;
230
230
  font-size: inherit;
231
+ font-weight: 700;
231
232
  color: var(--ghost-color);
232
- opacity: 0.5;
233
+ /* Resting opacity ships legible (was 0.5) so the inferred brace reads
234
+ against body text without a page-level :global override. */
235
+ opacity: 0.85;
236
+ /* Faint highlight: a soft glow in the confidence color keeps the AA-ish
237
+ delta from surrounding code without recoloring the brace. */
238
+ text-shadow: 0 0 6px color-mix(in srgb, var(--ghost-color) 60%, transparent);
239
+ border-radius: 2px;
240
+ background: color-mix(in srgb, var(--ghost-color) 14%, transparent);
233
241
  animation: ghost-pulse 2s ease-in-out infinite;
234
242
  }
235
243
 
236
244
  @keyframes ghost-pulse {
237
245
  0%,
238
246
  100% {
239
- opacity: 0.5;
247
+ opacity: 0.85;
240
248
  }
241
249
  50% {
242
- opacity: 0.3;
250
+ opacity: 0.65;
243
251
  }
244
252
  }
245
253
 
246
254
  .ghost-bracket:hover .ghost-bracket__char {
247
- opacity: 0.8;
255
+ opacity: 1;
248
256
  animation: none;
249
257
  }
250
258
 
@@ -350,9 +358,11 @@
350
358
  position: absolute;
351
359
  bottom: 2px;
352
360
  left: 0;
353
- width: 8px;
354
- height: 2px;
355
- background: var(--mismatch-color);
361
+ /* Span the brace and a short run of the offending line so the marker
362
+ reads as anchored to its cause, not a detached tick above the code. */
363
+ width: 6ch;
364
+ height: 0;
365
+ border-bottom: 2px dotted var(--mismatch-color);
356
366
  border-radius: 1px;
357
367
  }
358
368