-
Settings
-
-
-
Output Folder
-
-
- {#if $settings.output_mode === "custom_folder"}
+
+
+ {#if $settings.output_mode === "custom_folder"}
+
+
- {$settings.output_folder ?? "No folder selected"}
+ {$settings.output_folder ?? "No folder selected"}
- {/if}
-
+
+ {/if}
-
-
File Naming
-
-
- {#if naming === "overwrite"}
-
Original file will be replaced and cannot be recovered.
- {/if}
+
+
+ {#if naming === "overwrite"}
+
+
+
Original replaced; cannot be recovered.
+
+ {/if}
+
+
+
+ {#if advancedOpen}
+
+
+
Default preset
+
+ {#each (["max", "balanced", "minimal"] as Preset[]) as p}
+
+ {/each}
+
+
+
+
+
+
Applied when you right-click a PDF in Finder and choose Open With → compress[pdf]. In-app files keep the per-file Quality preset above.
+
+
+
+
Quick Action
+
+
+ {quickActionInstalled ? "Installed" : "Not installed"}
+
+
+
+
+
+
+
+
Adds a top-level Compress with compress[pdf] entry to Finder's right-click menu so you don't have to navigate the Open With submenu.
+
+
+ {/if}
@@ -416,39 +510,190 @@
.onboard-title { font-size: 13px; color: var(--text-secondary); font-weight: var(--weight-medium); }
.onboard-sub { font-size: 11px; color: var(--text-tertiary); text-align: center; line-height: 1.5; }
- .settings-section { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid var(--border); margin-top: auto; }
- .field { display: flex; flex-direction: column; gap: 6px; }
- .field-label {
+ .settings-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--border-subtle);
+ margin-top: auto;
+ }
+
+ .setting-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ }
+ .setting-label {
+ flex: 0 0 88px;
font-size: var(--text-sm);
- font-weight: var(--weight-semibold);
- letter-spacing: 0.01em;
color: var(--text-tertiary);
+ letter-spacing: 0.01em;
}
- .radio-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 12px; }
- .radio-label input[type="radio"] { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
- .radio-dot {
- width: 14px;
- height: 14px;
- border-radius: 50%;
- border: 1.5px solid var(--border);
- background: var(--bg-primary);
- flex-shrink: 0;
- transition: border-color 0.15s;
+ .setting-row--detail {
+ margin-top: -6px;
}
- .radio-label input[type="radio"]:checked + .radio-dot {
- border-color: var(--accent);
- background: var(--accent);
- box-shadow: inset 0 0 0 3px var(--bg-secondary);
+
+ .segmented {
+ flex: 1;
+ display: flex;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ padding: 2px;
+ gap: 2px;
+ }
+ .segment {
+ flex: 1;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 5px 8px;
+ font-size: var(--text-sm);
+ color: var(--text-tertiary);
+ cursor: pointer;
+ border-radius: 3px;
+ text-align: center;
+ transition: background 120ms ease, color 120ms ease;
+ user-select: none;
+ }
+ .segment input[type="radio"] {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ pointer-events: none;
+ }
+ .segment:hover { color: var(--text-secondary); }
+ .segment.active {
+ background: var(--bg-overlay);
+ color: var(--text-primary);
+ box-shadow: inset 0 0 0 1px var(--border);
+ }
+ .segment code {
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ background: none;
+ padding: 0;
}
- .radio-label:hover .radio-dot { border-color: var(--accent); }
+
.overwrite-warn {
- font-size: 10px;
+ font-size: var(--text-xs);
color: var(--warning);
- margin-top: -2px;
- margin-left: 22px;
line-height: 1.4;
+ margin: 0;
+ }
+ .setting-hint {
+ font-size: var(--text-xs);
+ color: var(--text-tertiary);
+ line-height: 1.5;
+ margin: 0;
+ }
+ .setting-hint em {
+ font-style: normal;
+ color: var(--text-secondary);
+ }
+
+ .advanced-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ align-self: flex-start;
+ background: none;
+ border: 0;
+ padding: 2px 0;
+ font-family: inherit;
+ font-size: var(--text-sm);
+ color: var(--text-tertiary);
+ cursor: pointer;
+ transition: color 120ms ease;
+ margin-top: var(--space-1);
+ }
+ .advanced-toggle:hover { color: var(--text-secondary); }
+ .chevron {
+ width: 8px;
+ height: 8px;
+ transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
+ }
+ .chevron.open { transform: rotate(90deg); }
+
+ .advanced-content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ .quick-action-row {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ min-width: 0;
+ }
+ .quick-action-status {
+ font-size: var(--text-sm);
+ color: var(--text-tertiary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .quick-action-status.installed {
+ color: var(--accent);
+ }
+ .quick-action-btn {
+ padding: 3px 12px;
+ border-radius: var(--radius-sm);
+ font-size: var(--text-sm);
+ font-family: inherit;
+ cursor: pointer;
+ border: 1px solid var(--border);
+ background: var(--bg-overlay);
+ color: var(--text-primary);
+ transition: background 120ms ease, border-color 120ms ease;
+ min-width: 72px;
+ }
+ .quick-action-btn:hover:not(:disabled) {
+ background: var(--bg-tertiary);
+ border-color: var(--accent);
+ }
+ .quick-action-btn:disabled {
+ cursor: default;
+ opacity: 0.5;
+ }
+
+ .folder-row {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+ }
+ .folder-path {
+ flex: 1;
+ font-size: var(--text-sm);
+ color: var(--accent);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ direction: rtl;
+ text-align: left;
+ }
+ .folder-row button {
+ padding: 3px 10px;
+ border-radius: 3px;
+ font-size: var(--text-sm);
+ font-family: inherit;
+ cursor: pointer;
+ border: 1px solid var(--border);
+ background: var(--bg-overlay);
+ color: var(--text-primary);
+ transition: background 120ms ease, border-color 120ms ease;
+ }
+ .folder-row button:hover {
+ background: var(--bg-tertiary);
+ border-color: var(--accent);
}
- .folder-row { display: flex; align-items: center; gap: 8px; }
- .folder-path { flex: 1; font-size: 11px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .folder-row button { padding: 5px 10px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); }
diff --git a/src/lib/stores/settingsStore.ts b/src/lib/stores/settingsStore.ts
index 6981e6f..4b8b593 100644
--- a/src/lib/stores/settingsStore.ts
+++ b/src/lib/stores/settingsStore.ts
@@ -1,11 +1,14 @@
import { writable } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
+export type Preset = "max" | "balanced" | "minimal";
+
export interface AppSettings {
output_mode: "same_as_source" | "custom_folder";
output_folder: string | null;
naming: "suffix" | "overwrite";
auto_update_check: boolean;
+ default_preset: Preset;
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -13,6 +16,7 @@ const DEFAULT_SETTINGS: AppSettings = {
output_folder: null,
naming: "suffix",
auto_update_check: false,
+ default_preset: "balanced",
};
function createSettingsStore() {
diff --git a/src/test/DetailPanel.test.ts b/src/test/DetailPanel.test.ts
index 312003a..2e55af4 100644
--- a/src/test/DetailPanel.test.ts
+++ b/src/test/DetailPanel.test.ts
@@ -19,7 +19,7 @@ describe("DetailPanel", () => {
beforeEach(async () => {
queue.clear();
selectedFileId.set(null);
- await settings.save({ output_mode: "same_as_source", output_folder: null, naming: "suffix", auto_update_check: false });
+ await settings.save({ output_mode: "same_as_source", output_folder: null, naming: "suffix", auto_update_check: false, default_preset: "balanced" });
vi.clearAllMocks();
});
@@ -69,16 +69,33 @@ describe("DetailPanel", () => {
it("shows settings section when no file is selected", () => {
render(DetailPanel);
- expect(screen.getByText(/output folder/i)).toBeInTheDocument();
- expect(screen.getByText(/file naming/i)).toBeInTheDocument();
+ expect(screen.getByText("Output")).toBeInTheDocument();
+ expect(screen.getByText("Naming")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /advanced/i })).toBeInTheDocument();
});
it("shows settings section when a file is selected", () => {
queue.addFile({ path: "/tmp/a.pdf", name: "a.pdf", size: 1000 });
selectedFileId.set(get(queue)[0].id);
render(DetailPanel);
- expect(screen.getByText(/output folder/i)).toBeInTheDocument();
- expect(screen.getByText(/file naming/i)).toBeInTheDocument();
+ expect(screen.getByText("Output")).toBeInTheDocument();
+ expect(screen.getByText("Naming")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /advanced/i })).toBeInTheDocument();
+ });
+
+ it("Default preset is hidden inside the Advanced drawer by default", () => {
+ render(DetailPanel);
+ expect(screen.queryByText("Default preset")).not.toBeInTheDocument();
+ const toggle = screen.getByRole("button", { name: /advanced/i });
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("clicking Advanced reveals the Default preset section", async () => {
+ const user = userEvent.setup();
+ render(DetailPanel);
+ await user.click(screen.getByRole("button", { name: /advanced/i }));
+ expect(screen.getByText("Default preset")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /advanced/i })).toHaveAttribute("aria-expanded", "true");
});
it("changing output mode to custom_folder saves immediately", async () => {
@@ -122,4 +139,96 @@ describe("DetailPanel", () => {
settings: expect.objectContaining({ output_folder: "/Users/me/Documents" }),
});
});
+
+ it("Advanced drawer reveals Default preset radios after expand", async () => {
+ const user = userEvent.setup();
+ render(DetailPanel);
+ await user.click(screen.getByRole("button", { name: /advanced/i }));
+ expect(screen.getByText("Default preset")).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: /^Max/i })).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: /^Balanced/i })).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: /^Minimal/i })).toBeInTheDocument();
+ });
+
+ it("changing default preset to max saves immediately", async () => {
+ const user = userEvent.setup();
+ render(DetailPanel);
+ await user.click(screen.getByRole("button", { name: /advanced/i }));
+ await user.click(screen.getByRole("radio", { name: /^Max/i }));
+ expect(invoke).toHaveBeenCalledWith("save_settings", {
+ settings: expect.objectContaining({ default_preset: "max" }),
+ });
+ });
+
+ it("changing default preset to minimal saves immediately", async () => {
+ const user = userEvent.setup();
+ render(DetailPanel);
+ await user.click(screen.getByRole("button", { name: /advanced/i }));
+ await user.click(screen.getByRole("radio", { name: /^Minimal/i }));
+ expect(invoke).toHaveBeenCalledWith("save_settings", {
+ settings: expect.objectContaining({ default_preset: "minimal" }),
+ });
+ });
+
+ // ── Quick Action install row ──────────────────────────────────────────────
+
+ it("Quick Action row shows 'Not installed' when backend reports false", async () => {
+ vi.mocked(invoke).mockImplementation(async (cmd: string) => {
+ if (cmd === "is_quick_action_installed") return false;
+ return undefined;
+ });
+ const user = userEvent.setup();
+ render(DetailPanel);
+ await user.click(screen.getByRole("button", { name: /advanced/i }));
+ expect(await screen.findByText("Not installed")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^Install$/ })).toBeInTheDocument();
+ });
+
+ it("Quick Action row shows 'Installed' when backend reports true", async () => {
+ vi.mocked(invoke).mockImplementation(async (cmd: string) => {
+ if (cmd === "is_quick_action_installed") return true;
+ return undefined;
+ });
+ const user = userEvent.setup();
+ render(DetailPanel);
+ await user.click(screen.getByRole("button", { name: /advanced/i }));
+ expect(await screen.findByText("Installed")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^Remove$/ })).toBeInTheDocument();
+ });
+
+ it("clicking Install invokes install_quick_action and re-reads state", async () => {
+ let installed = false;
+ vi.mocked(invoke).mockImplementation(async (cmd: string) => {
+ if (cmd === "is_quick_action_installed") return installed;
+ if (cmd === "install_quick_action") {
+ installed = true;
+ return undefined;
+ }
+ return undefined;
+ });
+ const user = userEvent.setup();
+ render(DetailPanel);
+ await user.click(screen.getByRole("button", { name: /advanced/i }));
+ await user.click(await screen.findByRole("button", { name: /^Install$/ }));
+ expect(invoke).toHaveBeenCalledWith("install_quick_action");
+ expect(await screen.findByText("Installed")).toBeInTheDocument();
+ });
+
+ it("clicking Remove invokes uninstall_quick_action and re-reads state", async () => {
+ let installed = true;
+ vi.mocked(invoke).mockImplementation(async (cmd: string) => {
+ if (cmd === "is_quick_action_installed") return installed;
+ if (cmd === "uninstall_quick_action") {
+ installed = false;
+ return undefined;
+ }
+ return undefined;
+ });
+ const user = userEvent.setup();
+ render(DetailPanel);
+ await user.click(screen.getByRole("button", { name: /advanced/i }));
+ await user.click(await screen.findByRole("button", { name: /^Remove$/ }));
+ expect(invoke).toHaveBeenCalledWith("uninstall_quick_action");
+ expect(await screen.findByText("Not installed")).toBeInTheDocument();
+ });
});
diff --git a/src/test/settingsStore.test.ts b/src/test/settingsStore.test.ts
index 1fe17ad..ad37629 100644
--- a/src/test/settingsStore.test.ts
+++ b/src/test/settingsStore.test.ts
@@ -7,6 +7,7 @@ vi.mock("@tauri-apps/api/core", () => ({
output_folder: "/my/folder",
naming: "overwrite",
auto_update_check: true,
+ default_preset: "balanced",
}),
}));
@@ -40,6 +41,7 @@ describe("settingsStore", () => {
output_folder: null,
naming: "suffix" as const,
auto_update_check: false,
+ default_preset: "balanced" as const,
};
await settings.save(newSettings);
expect(invoke).toHaveBeenCalledWith("save_settings", { settings: newSettings });