Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(gh pr *)",
"command": "cmd=$(jq -r '.tool_input.command'); echo \"$cmd\" | grep -q 'gh pr create' || exit 0; (cd src-tauri && cargo fmt) 2>/dev/null; git diff --quiet src-tauri/src/ || { git add src-tauri/src/ && git commit -m 'style: apply cargo fmt'; }",
"statusMessage": "Running cargo fmt…",
"timeout": 60
}
]
}
]
}
}
36 changes: 36 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Product

## Register

product

## Users

Developers wanting a fast, local PDF compression tool — no account setup, no cloud dependency, no nonsense. Also "stumblers": people who find it on GitHub looking for the simplest solution that doesn't upload their files anywhere.

## Product Purpose

Compress PDFs entirely on-device. No internet, no account, no file size limits. Output goes where you tell it, stays on your machine. The value proposition is what it *doesn't* do: no uploads, no tracking, no friction.

## Brand Personality

Quiet competence. Unpretentious. Gets out of the way. A good tool doesn't announce itself — it just works. Three words: straightforward, capable, unobtrusive.

## Anti-references

- SaaS dashboards with hero metrics, gradient branding, and upsell nudges
- Consumer apps with mascots, confetti, and celebration copy
- Cloud services that make simple tasks feel like an onboarding flow
- Anything that feels like a web app bolted into a native shell

## Design Principles

1. **Function is the feature** — every element earns its place by helping the user compress files, nothing else
2. **Quiet when idle, clear when active** — feedback is proportional to what happened; don't over-celebrate routine tasks
3. **Native to the OS** — feels like it could have shipped with macOS developer tools, not like a web app in a wrapper
4. **Trust the user** — no hand-holding copy, no confirmation theater for reversible actions
5. **Invisible when it works** — the best interaction is the one the user doesn't consciously notice

## Accessibility & Inclusion

Respects `prefers-reduced-motion` (already implemented). Supports system dark/light mode (already implemented). No additional requirements stated. WCAG AA color contrast as a baseline.
14 changes: 11 additions & 3 deletions src/lib/components/ActionBar.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script lang="ts">
import { get, derived } from "svelte/store";
import { scale } from "svelte/transition";
import { cubicOut } from "svelte/easing";
const reducedMotion = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { onDestroy, onMount } from "svelte";
Expand Down Expand Up @@ -124,7 +127,7 @@
{/if}
{#if $doneCount > 0 || $errorCount > 0}
<div class="status-summary">
{#if $doneCount > 0}<span class="done-count">{$doneCount} done</span>{/if}
{#if $doneCount > 0}<span class="done-count" in:scale={{ duration: reducedMotion ? 0 : 200, start: 0.6, easing: cubicOut }}>{$doneCount} done</span>{/if}
{#if $errorCount > 0}<span class="error-count">{$errorCount} error{$errorCount > 1 ? "s" : ""}</span>{/if}
</div>
{/if}
Expand All @@ -147,8 +150,13 @@

<style>
.action-bar { height: var(--action-bar-height); padding: 8px 12px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.compress-btn { flex: 1; height: 36px; background: var(--accent); color: white; border: none; border-radius: var(--radius-md); font-size: 13px; font-weight: 600; font-family: var(--font-ui); cursor: pointer; transition: background 0.1s, opacity 0.15s; }
.compress-btn:hover:not(:disabled) { background: var(--accent-hover); }
.compress-btn { flex: 1; height: 36px; background: var(--accent); color: white; border: none; border-radius: var(--radius-md); font-size: 13px; font-weight: 600; font-family: var(--font-ui); cursor: pointer; transition: background 0.1s, opacity 0.15s, transform 0.08s, box-shadow 0.12s; }
.compress-btn:hover:not(:disabled) { background: var(--accent-hover); box-shadow: 0 2px 10px oklch(59% 0.17 251 / 0.38); transform: translateY(-1px); }
.compress-btn:active:not(:disabled) { transform: scale(0.97); box-shadow: none; }
@media (prefers-reduced-motion: reduce) {
.compress-btn:hover:not(:disabled) { transform: none; }
.compress-btn { transition: background 0.1s, opacity 0.15s; }
}
.compress-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.clear-btn { height: 36px; padding: 0 14px; background: none; border: 1px solid var(--border); border-radius: var(--radius-md); color: var(--text-secondary); font-size: 13px; cursor: pointer; white-space: nowrap; transition: background 0.1s; }
.clear-btn:hover { background: var(--bg-tertiary); }
Expand Down
47 changes: 45 additions & 2 deletions src/lib/components/DetailPanel.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script lang="ts">
import { derived } from "svelte/store";
import { tweened } from "svelte/motion";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import { open } from "@tauri-apps/plugin-dialog";
import { queue, type Preset } from "$lib/stores/queueStore";
import { selectedFileId } from "$lib/stores/selectionStore";
Expand All @@ -9,6 +12,10 @@

const selectedFile = derived([queue, selectedFileId], ([$q, $id]) => $q.find((e) => e.id === $id) ?? null);

const reducedMotion = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const animatedPct = tweened(0, { duration: reducedMotion ? 0 : 700, easing: cubicOut });
const animatedRatio = tweened(0, { duration: reducedMotion ? 0 : 750, easing: cubicOut });

$: hasFiles = $queue.length > 0;

function savingsPct(original: number, compressed: number): string {
Expand Down Expand Up @@ -37,6 +44,24 @@
$: sliderMax = presetDpiRanges[currentPreset][2];
$: sliderValue = $selectedFile?.dpiOverride ?? presetDpiRanges[currentPreset][1];

$: {
if ($selectedFile?.status === "done" && $selectedFile.compressedSize !== undefined) {
const pct = Math.round((($selectedFile.size - $selectedFile.compressedSize) / $selectedFile.size) * 100);
const ratio = ($selectedFile.size - $selectedFile.compressedSize) / $selectedFile.size;
(async () => {
await Promise.all([
animatedPct.set(0, { duration: 0 }),
animatedRatio.set(0, { duration: 0 }),
]);
animatedPct.set(pct);
animatedRatio.set(ratio);
})();
} else {
animatedPct.set(0, { duration: 0 });
animatedRatio.set(0, { duration: 0 });
}
}

let outputMode: "same_as_source" | "custom_folder" = $settings.output_mode;
let naming: "suffix" | "overwrite" = $settings.naming;

Expand Down Expand Up @@ -78,8 +103,11 @@
<div class="sizes">
<div class="size-row"><span class="label">Original</span><span>{formatBytes($selectedFile.size)}</span></div>
{#if $selectedFile.status === "done" && $selectedFile.compressedSize !== undefined}
<div class="result-block">
<div class="savings-pct">{savingsPct($selectedFile.size, $selectedFile.compressedSize)}</div>
<div class="result-block" in:fly={{ y: 6, duration: reducedMotion ? 0 : 280, easing: cubicOut }}>
<div class="savings-pct">−{Math.round($animatedPct)}%</div>
<div class="savings-bar-track" aria-hidden="true">
<div class="savings-bar-fill" style="transform: scaleX({$animatedRatio})"></div>
</div>
<div class="size-story">
{formatBytes($selectedFile.size)} → {formatBytes($selectedFile.compressedSize)}
· saved {formatBytes($selectedFile.size - $selectedFile.compressedSize)}
Expand Down Expand Up @@ -217,6 +245,21 @@
.label { color: var(--text-tertiary); }

.result-block { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
.savings-bar-track {
height: 2px;
background: var(--border);
border-radius: 1px;
overflow: hidden;
margin: 0 0 2px;
}
.savings-bar-fill {
height: 100%;
width: 100%;
background: var(--success);
border-radius: 1px;
transform-origin: left;
transform: scaleX(0);
}
.savings-pct {
font-family: var(--font-display);
font-size: 28px;
Expand Down
54 changes: 47 additions & 7 deletions src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { scale, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
const reducedMotion = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
import { listen } from "@tauri-apps/api/event";
import { queue } from "$lib/stores/queueStore";
import { selectedFileId } from "$lib/stores/selectionStore";
import { addFiles, addPath } from "$lib/fileActions";

let isDragOver = false;
let dragDepth = 0;
let unlistenDrop: (() => void) | undefined;

function onDragEnter() {
dragDepth++;
isDragOver = true;
}

function onDragLeave() {
if (--dragDepth <= 0) {
dragDepth = 0;
isDragOver = false;
}
}

onMount(async () => {
unlistenDrop = await listen<{ paths: string[] }>("tauri://drag-drop", (event) => {
isDragOver = false;
dragDepth = 0;
for (const path of event.payload.paths) addPath(path);
});
});
Expand All @@ -21,14 +38,15 @@
<aside
class="sidebar"
class:drag-over={isDragOver}
on:dragover|preventDefault={() => (isDragOver = true)}
on:dragleave={() => (isDragOver = false)}
on:dragenter={onDragEnter}
on:dragleave={onDragLeave}
on:dragover|preventDefault={() => {}}
role="region"
aria-label="File queue"
>
<div class="header">
Queue
{#if $queue.length > 0}<span class="count">{$queue.length}</span>{/if}
{#if $queue.length > 0}<span class="count" in:scale={{ duration: reducedMotion ? 0 : 200, start: 0.5, easing: cubicOut }}>{$queue.length}</span>{/if}
</div>

{#if $queue.length === 0}
Expand All @@ -47,9 +65,11 @@
on:click={() => selectedFileId.set(entry.id)}
tabindex="0"
on:keydown={(e) => e.key === "Enter" && selectedFileId.set(entry.id)}
in:fly={{ y: reducedMotion ? 0 : -6, duration: reducedMotion ? 0 : 180, easing: cubicOut }}
out:fly={{ x: reducedMotion ? 0 : -12, duration: reducedMotion ? 0 : 130, easing: cubicOut }}
>
<span class="status-icon" class:done={entry.status === "done"} class:error={entry.status === "error"} class:processing={entry.status === "processing"}>
{#if entry.status === "done"}{:else if entry.status === "error"}✕{:else if entry.status === "processing"}<span class="spinner"></span>{:else}○{/if}
{#if entry.status === "done"}<span in:scale={{ duration: reducedMotion ? 0 : 220, start: 0.4, easing: cubicOut }}>✓</span>{:else if entry.status === "error"}✕{:else if entry.status === "processing"}<span class="spinner"></span>{:else}○{/if}
</span>
<span class="filename">{entry.name}</span>
<button
Expand All @@ -75,9 +95,23 @@
border-top: 1px solid var(--border);
overflow: hidden;
flex-shrink: 0;
transition: background 0.2s, border-top-color 0.2s;
}
.sidebar.drag-over {
background: color-mix(in oklch, var(--bg-secondary), var(--accent) 8%);
border-top-color: var(--accent);
}
.sidebar.drag-over .empty {
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent), transparent 80%);
animation: drop-zone-enter 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes drop-zone-enter {
0% { transform: scale(1); }
55% { transform: scale(1.022); }
100% { transform: scale(1.01); }
}
.sidebar.drag-over { background: color-mix(in oklch, var(--bg-secondary), var(--accent) 8%); }
.sidebar.drag-over .empty { border-color: var(--accent); color: var(--accent); }
.header {
padding: 8px 12px 4px;
font-size: var(--text-sm);
Expand Down Expand Up @@ -112,7 +146,13 @@
margin: 8px;
border: 1.5px dashed var(--border);
border-radius: var(--radius-md);
transition: border-color 0.15s, color 0.15s;
transform-origin: center;
transition: border-color 0.2s, color 0.2s, box-shadow 0.2s, transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@media (prefers-reduced-motion: reduce) {
.sidebar { transition: none; }
.empty { transition: border-color 0.15s, color 0.15s; }
.sidebar.drag-over .empty { animation: none; box-shadow: none; }
}
.empty-title { font-size: 12px; color: var(--text-secondary); font-weight: var(--weight-medium); }
.empty-sub { font-size: 10px; color: var(--text-tertiary); margin-top: 4px; }
Expand Down
Loading