diff --git a/.impeccable/critique/src-components-batchscoreboard-svelte.json b/.impeccable/critique/src-components-batchscoreboard-svelte.json new file mode 100644 index 0000000..c03ef76 --- /dev/null +++ b/.impeccable/critique/src-components-batchscoreboard-svelte.json @@ -0,0 +1,35 @@ +{ + "target": "src/components/BatchScoreboard.svelte", + "date": "2026-07-10", + "mode": "degraded-single-context", + "reason": "User requested sequential full pipeline; synthesis after detect+source review", + "score": 32, + "max": 40, + "p0": 0, + "p1": 2, + "p2": 3, + "strengths": [ + "Clear primary action (Open output)", + "Progressive disclosure for estimate method", + "Local privacy posture explicit" + ], + "priorityIssues": [ + {"sev": "P1", "id": "hero-metric-risk", "text": "Large number + label pattern can read as SaaS hero-metric if over-decorated; keep restrained"}, + {"sev": "P1", "id": "dest-option-count", "text": "Six destination choices is high decision load; optional should stay quiet"}, + {"sev": "P2", "id": "secondary-contrast", "text": "Watch text-secondary contrast on tinted surfaces in light mode"}, + {"sev": "P2", "id": "popover-density", "text": "Scoreboard + progress list + recent batches can stack tall in tray popover"}, + {"sev": "P2", "id": "chip-touch", "text": "Chip min-height 32 below 44px guideline for touch; acceptable for dense tray pointer UI"} + ], + "heuristics": { + "visibility_of_status": 3, + "match_system_world": 4, + "user_control": 4, + "consistency": 3, + "error_prevention": 3, + "recognition": 3, + "flexibility": 3, + "aesthetic_minimal": 3, + "error_recovery": 3, + "help": 3 + } +} diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..41a635f --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,149 @@ +--- +name: ParseKit +description: Menu-bar document converter — private, local, native macOS craft +colors: + ink: "#1B1713" + ink-secondary: "#6B5E52" + accent: "#3F3830" + accent-hover: "#322C26" + accent-fg: "#F9F7F2" + surface: "#F7F4EE" + surface-elevated: "#FFFFFF" + border: "#B8A99A" + link: "#5C4A3A" + status-success: "#2F8A4A" + status-error: "#C23B2E" + status-warning: "#C9892A" + dark-bg: "#1C1A17" + dark-ink: "#F5F2EB" + dark-accent: "#E8C99A" +typography: + display: + fontFamily: "Instrument Serif, Georgia, Times New Roman, serif" + fontSize: "1.25rem" + fontWeight: 400 + lineHeight: 1.2 + letterSpacing: "-0.015em" + body: + fontFamily: "-apple-system, BlinkMacSystemFont, SF Pro Text, Segoe UI, sans-serif" + fontSize: "0.875rem" + fontWeight: 400 + lineHeight: 1.4 + letterSpacing: "normal" + mono: + fontFamily: "JetBrains Mono, SF Mono, Menlo, monospace" + fontSize: "0.8125rem" + fontWeight: 500 + lineHeight: 1.3 + letterSpacing: "-0.01em" + label: + fontFamily: "-apple-system, BlinkMacSystemFont, SF Pro Text, Segoe UI, sans-serif" + fontSize: "0.72rem" + fontWeight: 500 + lineHeight: 1.25 + letterSpacing: "0.01em" +rounded: + sm: "6px" + md: "8px" + lg: "12px" + pill: "999px" +spacing: + xs: "4px" + sm: "8px" + md: "12px" + lg: "16px" + xl: "24px" +components: + button-primary: + backgroundColor: "{colors.accent}" + textColor: "{colors.accent-fg}" + rounded: "{rounded.md}" + padding: "8px 14px" + button-primary-hover: + backgroundColor: "{colors.accent-hover}" + textColor: "{colors.accent-fg}" + button-secondary: + backgroundColor: "transparent" + textColor: "{colors.ink}" + rounded: "{rounded.md}" + padding: "8px 12px" + chip: + backgroundColor: "transparent" + textColor: "{colors.ink}" + rounded: "{rounded.pill}" + padding: "5px 10px" + card: + backgroundColor: "{colors.surface-elevated}" + textColor: "{colors.ink}" + rounded: "{rounded.lg}" + padding: "14px" +--- + +## Overview + +ParseKit is a **product-register** macOS menu-bar tool: design serves conversion, not marketing spectacle. Visual system is **Bone White, Pressed** — warm restrained neutrals, charcoal primary, native SF Pro body, Instrument Serif only for display (app title / about). Motion is short (120–180ms), ease-out, interruptible, reduced-motion aware. Identity lives in craft density and privacy posture, not loud color. + +Canonical tokens: `src/index.css` (`:root`, `[data-theme]`, system dark). Brand narrative: `brand.md`. Product brief: `PRODUCT.md`. + +## Colors + +OKLCH is source of truth in CSS; hex above is sRGB approximation for tooling. + +| Role | Light (OKLCH) | Use | +|------|----------------|-----| +| Ink | `oklch(0.18 0.01 50)` | Body text | +| Secondary | `oklch(0.48 0.03 50)` | Hints, labels (keep ≥4.5:1 on surface) | +| Accent | `oklch(0.32 0.03 50)` | Primary buttons, focus | +| Accent FG | `oklch(0.98 0.005 85)` | Text on primary | +| Surface glass | `oklch(0.97 0.01 85)` + blur | Popover material | +| Border | `oklch(0.72 0.02 50 / 0.95)` | Hairlines | +| Success / Error / Warning | status-* tokens | Semantic only | + +Dark theme flips ink/surface and warms the accent (`oklch(0.78 0.08 75)`). Prefer **restrained** strategy: neutrals dominate; accent ≤10% of surface. + +## Typography + +- **UI / body:** system sans (`--font-sans`) — native macOS feel. +- **Display only:** Instrument Serif (`--font-serif`) for app name / about — not for buttons, data, or dense labels. +- **Data / counts:** JetBrains Mono (`--font-mono`), tabular-nums for token counts. +- Scale ratio ~1.15–1.2; display letter-spacing floor **≥ -0.04em** (hero metrics ~-0.03em). +- Prefer weight + size hierarchy over all-caps eyebrows on every section. + +## Elevation + +Mostly **tonal layering** (secondary-bg, glass) over heavy drop shadows. + +- Prefer: 1px border **or** soft shadow ≤8px blur — not both as decoration. +- Popover: glass material + restrained inset highlight. +- Focus: `--focus-ring` translucent accent ring (never remove). + +## Components + +| Component | Notes | +|-----------|--------| +| **Primary button** | `.run-parse-btn` / accent fill; `:active scale(0.97)` | +| **Secondary button** | Transparent / light border; quieter press scale | +| **Chips** | Pill outline for optional choices (destination) | +| **Scoreboard** | Post-convert result: one hero number, platter stats, progressive disclosure for method | +| **Drop zone** | Dominant affordance for first-run convert | +| **Segmented control** | Period toggle (today / month / lifetime) | +| **Banners** | Error/notice with fly enter; reduced-motion → fade | + +States required on interactive: default, hover, focus-visible, active, disabled. + +## Do's and Don'ts + +**Do** +- Keep conversion path obvious: drop → run → open output. +- Use local metrics language (estimate, privacy line). +- Animate transform/opacity only; honor `prefers-reduced-motion` and `prefers-reduced-transparency`. +- Align new UI to tokens in `src/index.css` before inventing hex. + +**Don't** +- Side-stripe accents (`border-left` >1px on cards/callouts). +- Hero-metric SaaS clichés with gradients and confetti. +- Nested cards; identical icon+title+body grids. +- Uppercase tracked kicker on every block. +- `scale(0)` entrances; bounce/elastic easing. +- Pair thick border + wide soft shadow (ghost-card). +- Put Instrument Serif on dense UI chrome. diff --git a/README.md b/README.md index 3aac313..e38b898 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,17 @@ More: [skills/parsekit/SKILL.md](skills/parsekit/SKILL.md) · [AGENTS.md](AGENTS Files, OCR, and conversion never leave your Mac. No telemetry. Network only for optional app updates and first-time OCR language packs. +## Product metrics (local-first) + +| Metric | Definition | Storage | +|--------|------------|---------| +| **Activation** | First batch with at least one successful file | `activatedAt` in the Tauri settings store | +| **Tokens saved** | Estimate: raw extract vs cleaned Markdown (~4 chars/token) | On-disk token stats | +| **Today / all-time** | Calendar-day and lifetime sums of token events | Same | +| **Success rate** | Successful vs failed files across the last 20 jobs | `jobOutcomesV1` in the settings store (counts only) | + +Stored only on this device. Never uploaded. See [`docs/TASKS.md`](docs/TASKS.md). + ## Develop ```bash diff --git a/docs/TASKS.md b/docs/TASKS.md new file mode 100644 index 0000000..a12afd2 --- /dev/null +++ b/docs/TASKS.md @@ -0,0 +1,24 @@ +# ParseKit — Flow + metrics tasks + +Branch: `feat/flow-metrics-2026-07` +Source: `~/Desktop/parsekit-task-list-plan.md` + +| ID | Task | Status | Notes | +|----|------|--------|-------| +| PK-1 | First successful convert as hero of first-run | **Done** | First-run hero on main panel; onboarding points to convert; activation constant | +| PK-2 | Today + all-time token summary scoreboard | **Done** | BatchScoreboard + banner period includes **today** | +| PK-3 | Failure path + success rate | **Done** | Job outcomes (last 20 jobs, local); partial/fail copy on scoreboard | +| PK-4 | Optional destination prompt | **Done** | Soft prompt after first success; skippable; local only | +| PK-5 | Privacy strip on stats surfaces | **Done** | Scoreboard + token panel + first-run copy | +| README | Novice + technical dual sections | **Done** | See README.md | +| Ship | PR against master | Pending | | + +## Activation metric + +- **Name:** `first_successful_convert_with_token_estimate` (`ACTIVATION_EVENT` in `src/lib/activation.ts`) +- **Rule:** ≥1 successful file in a batch (token estimate recorded for the batch, may be 0) +- **Storage:** `activatedAt` in local Tauri settings store — never uploaded + +## Privacy + +Token events and job outcome counts stay on this Mac. No file paths or names in aggregate metrics. diff --git a/docs/impeccable/PIPELINE-2026-07-10.md b/docs/impeccable/PIPELINE-2026-07-10.md new file mode 100644 index 0000000..9de363a --- /dev/null +++ b/docs/impeccable/PIPELINE-2026-07-10.md @@ -0,0 +1,45 @@ +# Impeccable pipeline — ParseKit — 2026-07-10 + +Target: `feat/flow-metrics-2026-07` (BatchScoreboard + first-run + tokens) + +## Order run + +| # | Command | Result | +|---|---------|--------| +| 1 | **document** | Wrote `DESIGN.md` from tokens + brand | +| 2 | **quieter** | Removed side-tab `border-left: 3px` on `.progress-now`; dropped uppercase kicker; removed ghost-card shadow | +| 3 | **critique** | Snapshot `.impeccable/critique/src-components-batchscoreboard-svelte.json` — ⚠️ single-context (sequential pipeline). Score ~32/40. P1: hero-metric restraint, destination option load | +| 4 | **audit** | See below | +| 5 | **polish** | Token-only colors, mono for numbers, secondary via `--text-secondary`, focus-visible chips | +| 6 | **distill** | Estimate/privacy under details; dest chips quieter | +| 7 | **clarify** | Copy: titles shorter, “Not now”, about estimate | +| 8 | **harden** | Tone states success/warn/fail; fail hint uses status tokens | +| 9 | **onboard** | First-run hero already present; kept purpose-first coach mark | + +### Skipped (with reason) + +| Command | Why | +|---------|-----| +| init | PRODUCT.md exists | +| craft / shape | Feature already built | +| extract | DESIGN.md just captured; extract later if needed | +| bolder / overdrive / colorize / delight | Product register — would add noise | +| live | Dev server not running | +| adapt | Fixed tray popover width, not multi-breakpoint web | +| animate | Motion already intentional; no decorative pass | +| typeset / layout | Folded into polish | +| optimize | No perf flags from audit | + +## Audit health (code-level) + +| Dimension | Score | Key finding | +|-----------|-------|-------------| +| Accessibility | 3 | Labels/aria present; chip height 32px (tray OK) | +| Performance | 4 | transform/opacity motion only | +| Theming | 3 | Hard-coded colors removed from scoreboard; rest of app OK | +| Responsive | 3 | Popover density if stack tall | +| Anti-patterns | 3 | Side-tab fixed; residual hero-number pattern is intentional metric | +| **Total** | **16/20** | **Good** | + +## Anti-patterns verdict +Not “AI made that” after quieter pass. Remaining risk: large metric number — mitigated with mono type, no gradient, no confetti, progressive disclosure. diff --git a/src/App.svelte b/src/App.svelte index fd13367..9f1c92d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -51,6 +51,19 @@ recordTokenSavingsFromSidecarEvent, type TokenStats, } from "./lib/tokenStats"; + import { + ACTIVATION_AT_KEY, + CONVERT_DESTINATION_KEY, + DESTINATION_PROMPT_KEY, + qualifiesForActivation, + type ConvertDestination, + } from "./lib/activation"; + import { + loadJobOutcomes, + persistJobOutcome, + successRate, + type JobOutcomesState, + } from "./lib/jobOutcomes"; import { applyTheme, DEFAULT_THEME, normalizeThemeMode } from "./lib/theme"; import DropZone from "./components/DropZone.svelte"; import OutputFolderPicker from "./components/OutputFolderPicker.svelte"; @@ -63,6 +76,7 @@ import UpdateBanner from "./components/UpdateBanner.svelte"; import TokenSavingsBanner from "./components/TokenSavingsBanner.svelte"; + import BatchScoreboard from "./components/BatchScoreboard.svelte"; import { updateState } from "./lib/updateState.svelte"; import { finderActionState } from "./lib/finderActionState.svelte"; import { pickOutputFolder } from "./lib/picker"; @@ -140,6 +154,10 @@ let showFloatingHud = $state(true); let isBackgroundBatch = $state(false); let hudActive = $state(false); + let activatedAt = $state(null); + let jobOutcomes = $state({ jobs: [] }); + let showDestinationPrompt = $state(false); + let showBatchScoreboard = $state(false); /** Instant chrome for keyboard convert (Emil: keyboard-initiated = no animation). */ let parseChromeInstant = $state(false); /** @@ -501,6 +519,8 @@ recentBatches = await getSetting("recentBatches", []); hasSuccessfulParse = await getSetting("hasSuccessfulParse", false); configCollapsed = hasSuccessfulParse; + activatedAt = await getSetting(ACTIVATION_AT_KEY, null); + jobOutcomes = await loadJobOutcomes(); // Popover opens from tray click after onboarding — not auto-shown on launch. await resolveDefaultWorkers(await getSetting("workers", 0)); launchAtLogin = await getSetting("launchAtLogin", false); @@ -526,7 +546,11 @@ "tokenStatsPeriod", DEFAULT_TOKEN_STATS_PERIOD, ); - if (tokenStatsPeriod !== "month" && tokenStatsPeriod !== "lifetime") { + if ( + tokenStatsPeriod !== "today" && + tokenStatsPeriod !== "month" && + tokenStatsPeriod !== "lifetime" + ) { tokenStatsPeriod = DEFAULT_TOKEN_STATS_PERIOD; await setSetting("tokenStatsPeriod", tokenStatsPeriod); } @@ -759,6 +783,8 @@ startParseStallWatchdog(); lastParsingId = null; batchTokenSavings = createBatchTokenSavings(); + showBatchScoreboard = false; + showDestinationPrompt = false; totalFiles = filesToParse.length; files = filesToParse.map((path) => ({ id: path, @@ -839,13 +865,38 @@ errorMsg = null; } void addToHistory(); - if (!hasSuccessfulParse) { + const parsed = files.filter((f) => f.status === "done").length; + const notifyErrors = files.filter((f) => f.status === "error").length; + if (!hasSuccessfulParse && parsed > 0) { hasSuccessfulParse = true; configCollapsed = true; void setSetting("hasSuccessfulParse", true); } - const parsed = files.filter((f) => f.status === "done").length; - const notifyErrors = files.filter((f) => f.status === "error").length; + // PK-2/3: local job outcomes + activation (no paths stored) + const tokensInBatch = batchTokenSavings.tokensSaved; + void persistJobOutcome({ + success: parsed, + failed: notifyErrors, + tokensSaved: tokensInBatch, + }).then((next) => { + jobOutcomes = next; + }); + if ( + qualifiesForActivation({ + alreadyActivated: !!activatedAt, + successCount: parsed, + tokensSavedInBatch: tokensInBatch, + }) + ) { + const at = new Date().toISOString(); + activatedAt = at; + void setSetting(ACTIVATION_AT_KEY, at); + } + // PK-4: soft destination prompt once after first success + void getSetting(DESTINATION_PROMPT_KEY, false).then((seen) => { + showDestinationPrompt = !seen && parsed > 0; + }); + showBatchScoreboard = parsed > 0 || notifyErrors > 0; void refreshTokenStats(); void syncHudIfActive(); void invoke("show_completion_notification", { @@ -933,6 +984,32 @@ await invoke("open_in_finder", { path }); } + function handleConvertMore() { + showBatchScoreboard = false; + files = []; + lastParsingId = null; + batchTokenSavings = createBatchTokenSavings(); + selectedFiles = []; + inputDir = ""; + inputFileCount = null; + errorMsg = null; + noticeMsg = null; + } + + async function handleDestination(value: ConvertDestination) { + showDestinationPrompt = false; + await setSetting(DESTINATION_PROMPT_KEY, true); + await setSetting(CONVERT_DESTINATION_KEY, value); + } + + const lastBatchSuccessCount = $derived( + files.filter((f) => f.status === "done").length, + ); + const lastBatchFailedCount = $derived( + files.filter((f) => f.status === "error").length, + ); + const jobSuccessRateLabel = $derived(successRate(jobOutcomes).label); + function handleKeydown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === "r") { e.preventDefault(); @@ -1143,7 +1220,19 @@ {/if} {/if} - {#if !isParsing && files.length > 0 && files.some((f) => f.status === "done")} + {#if !isParsing && showBatchScoreboard && files.length > 0} + openFolder(outputDir)} + onConvertMore={handleConvertMore} + onDestination={handleDestination} + /> + {:else if !isParsing && files.length > 0 && files.some((f) => f.status === "done")}
{/if} + + {#if !isParsing && !hasSuccessfulParse && !showBatchScoreboard} +
+

{t("firstRun.title")}

+

{t("firstRun.body")}

+

{t("scoreboard.privacy")}

+
+ {/if} {#if !isParsing} diff --git a/src/components/BatchScoreboard.svelte b/src/components/BatchScoreboard.svelte new file mode 100644 index 0000000..ed91a7a --- /dev/null +++ b/src/components/BatchScoreboard.svelte @@ -0,0 +1,403 @@ + + +
+
+

+ {#if allFailed} + {t("scoreboard.titleFailed")} + {:else if partialFail} + {t("scoreboard.titlePartial")} + {:else} + {t("scoreboard.titleSuccess")} + {/if} +

+
+ + +
+ + {formatTokenCount(batchTokens)} + + {t("scoreboard.thisBatch")} + {#if batchTokens > 0} +

+ {t("scoreboard.chatApprox", { + count: formatTokenCount(approximateChatGptMessages(batchTokens)), + })} +

+ {/if} +
+ + +
+
+
{t("scoreboard.today")}
+
{formatTokenCount(todayTokens)}
+
+
+
{t("scoreboard.allTime")}
+
{formatTokenCount(lifetimeTokens)}
+
+
+
{t("scoreboard.filesAllTime")}
+
{formatTokenCount(lifetimeFiles)}
+
+
+
{t("scoreboard.successRate")}
+
{successRateLabel}
+
+
+ + {#if partialFail || allFailed} +

+ {t("scoreboard.failHint", { + ok: successCount, + fail: failedCount, + })} +

+ {/if} + + +
+ + +
+ + {#if showDestinationPrompt && successCount > 0} +
+

{t("scoreboard.destTitle")}

+
+ {#each [ + ["claude", "Claude"], + ["chatgpt", "ChatGPT"], + ["gemini", "Gemini"], + ["obsidian", "Obsidian"], + ] as [id, label]} + + {/each} + + +
+
+ {/if} + + +
+ {t("scoreboard.aboutEstimate")} +

{t("scoreboard.method")}

+

{t("scoreboard.privacy")}

+ {#if lifetimeTokens > 0} +

+ {t("tokenSavings.chatGptApprox", { count: formatTokenCount(chatApprox) })} + {t("tokenSavings.approximateLabel")} +

+ {/if} +
+
+ + diff --git a/src/components/OnboardingScreen.svelte b/src/components/OnboardingScreen.svelte index 568c8e5..b4f4703 100644 --- a/src/components/OnboardingScreen.svelte +++ b/src/components/OnboardingScreen.svelte @@ -201,6 +201,13 @@ +
+
+

{t("onboarding.firstConvertTitle")}

+

{t("onboarding.firstConvertBody")}

+
+
+