diff --git a/web/src/app.css b/web/src/app.css
index 243c323..725b3ea 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -82,3 +82,42 @@ select {
outline-offset: 2px;
border-radius: var(--radius-s);
}
+
+button.primary,
+button.secondary {
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-s);
+ border: 1px solid transparent;
+ font-weight: 600;
+ font-size: 0.95rem;
+ cursor: pointer;
+ transition:
+ background 120ms ease,
+ border-color 120ms ease,
+ color 120ms ease;
+}
+
+button.primary {
+ background: var(--accent);
+ color: #ffffff;
+}
+
+button.primary:hover:not(:disabled) {
+ background: var(--accent-hover);
+}
+
+button.secondary {
+ background: var(--surface);
+ color: var(--text);
+ border-color: var(--border);
+}
+
+button.secondary:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+button:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
diff --git a/web/src/lib/components/DownloadButton.svelte b/web/src/lib/components/DownloadButton.svelte
new file mode 100644
index 0000000..1658f01
--- /dev/null
+++ b/web/src/lib/components/DownloadButton.svelte
@@ -0,0 +1,41 @@
+
+
+
diff --git a/web/src/lib/components/DropZone.svelte b/web/src/lib/components/DropZone.svelte
new file mode 100644
index 0000000..c9a697e
--- /dev/null
+++ b/web/src/lib/components/DropZone.svelte
@@ -0,0 +1,107 @@
+
+
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ openPicker();
+ }
+ }}
+ ondragover={(e) => {
+ e.preventDefault();
+ dragging = true;
+ }}
+ ondragleave={() => (dragging = false)}
+ ondrop={(e) => {
+ e.preventDefault();
+ dragging = false;
+ accept(e.dataTransfer?.files[0]);
+ }}
+>
+ ⬇
+ {label}
+
+ {
+ accept(input?.files?.[0]);
+ if (input) {
+ input.value = '';
+ }
+ }}
+/>
+
+
diff --git a/web/src/lib/components/InfoPanel.svelte b/web/src/lib/components/InfoPanel.svelte
new file mode 100644
index 0000000..88b863f
--- /dev/null
+++ b/web/src/lib/components/InfoPanel.svelte
@@ -0,0 +1,49 @@
+
+
+{#if info}
+
+
+
- Размеры
+ - {info.width} × {info.height} px
+
+
+
- Альфа-канал
+ - {info.hasAlpha ? 'есть — есть полупрозрачные пиксели' : 'нет'}
+
+
+
- Уникальных цветов (RGBA)
+ - {info.colorCount.toLocaleString('ru-RU')}
+
+
+{/if}
+
+
diff --git a/web/src/lib/components/ParamForm.svelte b/web/src/lib/components/ParamForm.svelte
new file mode 100644
index 0000000..b821aa6
--- /dev/null
+++ b/web/src/lib/components/ParamForm.svelte
@@ -0,0 +1,89 @@
+
+
+{#each params as param (param.id)}
+
+ {#if param.type === 'checkbox'}
+
+ {:else}
+
+ {#if param.type === 'number'}
+
+ {:else if param.type === 'select'}
+
+ {:else if param.type === 'color'}
+
+ {/if}
+ {/if}
+
+{/each}
+
+
diff --git a/web/src/lib/components/Preview.svelte b/web/src/lib/components/Preview.svelte
new file mode 100644
index 0000000..1a1090b
--- /dev/null
+++ b/web/src/lib/components/Preview.svelte
@@ -0,0 +1,40 @@
+
+
+{#if image}
+
+ {image.width} × {image.height} px
+{/if}
+
+
diff --git a/web/src/lib/components/ToolPage.svelte b/web/src/lib/components/ToolPage.svelte
new file mode 100644
index 0000000..f24e2e3
--- /dev/null
+++ b/web/src/lib/components/ToolPage.svelte
@@ -0,0 +1,187 @@
+
+
+
+ {tool.title}
+ {tool.description}
+
+ {#if errorText}
+ {errorText}
+ {/if}
+
+ {#if !source}
+ (errorText = message)} />
+ {:else}
+
+
+
{isInfo ? 'Изображение' : 'Результат'}
+ {#if status === 'processing' && !result}
+
Обработка…
+ {:else}
+
+ {/if}
+ {#if isInfo && info}
+
+ {/if}
+
+
+
+ {#if !isInfo}
+
+
Параметры
+ {#if tool.params.length > 0}
+
+
+ {:else}
+
У этого инструмента нет параметров — результат уже готов.
+ {/if}
+
+
+
+
+ {/if}
+
+ {/if}
+
+
+