From 56d4eba1ea00f3bf082d5c6e3e78fa2c9a0170ae Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:40:43 +0700 Subject: [PATCH 01/71] docs: define independent feature branch workflow --- AGENTS.md | 32 ++++++++++++++++++++++++++++++++ docs/features/README.md | 15 +++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/features/README.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..93bf04c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Working on OpenCull + +OpenCull is an offline photo-culling desktop application: React/TypeScript in `src/`, Rust/Tauri in `src-tauri/src/`. Read `README.md` and `docs/HOW-IT-WORKS.md` before changing behavior. + +## Branches and review + +- Implement one feature per short-lived `feature/` branch. Use `fix/`, `docs/`, or `chore/` for other work. Never implement directly on `main`. +- Independent features start from current `upstream/main` so the original maintainer can select them independently. Default PR target is `wispdevon/OpenCull:main`; submit upstream only when requested. +- Fetch before choosing a base. Keep upstream synchronization in a separate PR. Do not bundle other feature commits into a standalone feature branch. +- Combine features only on a separate `integration/` branch. Document integration-only fixes there, including conflict resolutions. +- Inspect Git status first. Preserve unrelated edits and use separate worktrees when another branch is dirty or work runs concurrently. Never reset or stash someone else's work. +- Keep commits and PRs focused; separate unrelated refactoring, formatting, dependency changes, and lockfile changes. Review staged diffs before committing. +- Do not force-push shared branches. Resolve conflicts deliberately and rerun affected checks. Merge only when requested; remove merged feature branches after completion. + +## Verification + +- `npm ci` installs the locked dependencies. Use Node 22.12+ (CI uses Node 22). +- `npm run test:web` runs frontend tests; `npm run test:rust` runs Rust library tests; `npm test` runs both. +- `npm run build` checks TypeScript and builds the frontend. There is no lint script: do not claim one passed. +- For desktop changes, also run `npm run tauri build -- --debug --no-bundle` and relevant runtime checks. Linux requires GTK/WebKitGTK and the Tauri prerequisites; see the README and CI. +- Add focused regression tests for behavior, using existing Vitest and inline Rust test conventions. Do not weaken checks to make a branch pass. +- Documentation-only work needs diff/link review, not an application rebuild. +- PR descriptions explain the problem, resulting behavior, validation, platform limitations, and any prerequisites. Report blocked checks honestly. + +## Product boundaries + +- Keep photos private: processing stays local; no remote font or media requests. +- Preserve original files. Card review, rejecting, and copying must never delete sources. Source deletion must be a distinct explicitly confirmed action. +- Preserve sidecar compatibility, RAW/JPEG associations, keyboard navigation, and existing normal-folder behavior. +- Settings belong behind the shared preferences interface. Retain existing defaults in upstream-facing features; personal defaults belong in local configuration. +- Support Linux without introducing macOS/Windows regressions. Test on available platforms and state what was not tested. +- Keep browser demo and native Tauri paths working. Use semantic theme tokens, accessible labels, and the existing UI component system. diff --git a/docs/features/README.md b/docs/features/README.md new file mode 100644 index 0000000..d3f49cf --- /dev/null +++ b/docs/features/README.md @@ -0,0 +1,15 @@ +# Independent feature initiative + +Approved feature branches (each starts at upstream main): + +- `feature/cull-before-ingest`: reuse culling before copying; local source-safe marks; choose files at finish; copy or import in place. +- `feature/multi-card-session`: combine sources; conservatively pair RAW/JPEG across cards; one session. +- `feature/configurable-ui`: settings for panel visibility and sizes, preserving defaults. +- `feature/appearance-modes`: dark, light, 18% grey; preserve default dark. +- `feature/font-selection`: system, offline Space Grotesk, custom local family; preserve default system. +- `feature/video-culling`: ignore toggle, supported playback, import selection; default ignore. +- `feature/xdg-config`: atomic native settings, Linux XDG path, migration from webview preferences. + +No feature requires another. Combined behavior and overlap resolutions belong on `integration/linux-features`. No automatic merges or upstream submissions. + +Personal configuration: Space Grotesk, 18% grey, existing panel visibility/sizes, videos ignored. Card workflow reviews first, then chooses Copy (initially all non-rejects) or Import in place (only selected members). No card writes during review or implicit deletion. From a4263b7f6481bfb3cb96ea47ba632dea73a52f65 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:41:42 +0700 Subject: [PATCH 02/71] feat: expose workspace visibility and dimensions in settings --- docs/features/configurable-ui.md | 5 +++++ src/components/cull/Dialogs.tsx | 26 ++++++++++++++++++++++++-- src/components/cull/ExifPanel.tsx | 5 +++-- src/lib/store.ts | 3 +++ 4 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 docs/features/configurable-ui.md diff --git a/docs/features/configurable-ui.md b/docs/features/configurable-ui.md new file mode 100644 index 0000000..969a21c --- /dev/null +++ b/docs/features/configurable-ui.md @@ -0,0 +1,5 @@ +# Configurable workspace + +Settings → Layout controls filmstrip, face panel, EXIF panel, histogram, information overlay, rating bar, and filter bar. Size controls share the existing persisted dimensions with drag handles; side panels remain constrained to the workspace. Defaults and shortcuts are unchanged. + +Validation: frontend unit suite and production build; visually check small windows and restarting after a layout change. No other feature branch is required. diff --git a/src/components/cull/Dialogs.tsx b/src/components/cull/Dialogs.tsx index 81fd146..58c54b3 100644 --- a/src/components/cull/Dialogs.tsx +++ b/src/components/cull/Dialogs.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { HardDrive, Keyboard, ScanFace, Star, ZoomIn, type LucideIcon } from "lucide-react"; +import { HardDrive, PanelsTopLeft, Keyboard, ScanFace, Star, ZoomIn, type LucideIcon } from "lucide-react"; import { AlertDialog, @@ -191,9 +191,10 @@ export function SettingsDialog() { /** How long the cache limit is left alone before it is acted on. */ const LIMIT_SETTLE_MS = 800; -type SettingsTab = "culling" | "zoom" | "ai" | "storage" | "keyboard"; +type SettingsTab = "layout" | "culling" | "zoom" | "ai" | "storage" | "keyboard"; const SETTINGS_TABS: [SettingsTab, string, LucideIcon][] = [ ["culling", "Culling", Star], + ["layout", "Layout", PanelsTopLeft], ["zoom", "Zoom", ZoomIn], ["ai", "AI", ScanFace], ["storage", "Storage", HardDrive], @@ -279,6 +280,27 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT {/* One height for every tab, so the window does not jump about as they are stepped through; a tab with more than fits scrolls. */}
+ + {([ + ["showFilmstrip", "Filmstrip"], ["showFaces", "Face panel"], + ["showExif", "EXIF panel"], ["showHistogram", "Histogram in EXIF panel"], + ["showInfo", "Information overlay"], ["showRatingBar", "Rating bar"], + ["showFilterBar", "Filter bar"], + ] as const).map(([key, title]) => ( + set(key, on)} />} /> + ))} + {([ + ["filmstripHeight", "Filmstrip height", 80, 300], + ["exifPanelWidth", "EXIF panel width", 160, 640], + ["facePanelWidth", "Face panel width", 160, 800], + ] as const).map(([key, title, min, max]) => ( + set(key, Number(e.target.value))} className="w-24 accent-primary" /> + {s[key]}} /> + ))} + s.showHistogram); const photo = useStore((s) => (s.current >= 0 ? s.photos[s.current] : undefined)); return ( @@ -19,9 +20,9 @@ export function ExifPanel() {
-
+ {showHistogram &&
-
+
} {photo ?
: } ); diff --git a/src/lib/store.ts b/src/lib/store.ts index 0cafabf..6254823 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -63,6 +63,7 @@ interface State { viewMode: ViewMode; gridColumns: number; showExif: boolean; + showHistogram: boolean; /** How wide the EXIF panel is, in pixels. */ exifPanelWidth: number; showFilmstrip: boolean; @@ -339,6 +340,7 @@ const PERSISTED = [ "sortAscending", "gridColumns", "showExif", + "showHistogram", "exifPanelWidth", "showFilmstrip", "showInfo", @@ -674,6 +676,7 @@ export const useStore = create((set, get) => { viewMode: "loupe", gridColumns: load("gridColumns", 4), showExif: load("showExif", false), + showHistogram: load("showHistogram", true), exifPanelWidth: load("exifPanelWidth", 250), showFilmstrip: load("showFilmstrip", true), showInfo: load("showInfo", false), From c3b2fad384a55559db4b30cd84f89e46ab3a29cb Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:42:32 +0700 Subject: [PATCH 03/71] feat: select offline Space Grotesk or an installed interface font --- docs/features/font-selection.md | 5 ++ public/fonts/SpaceGrotesk-OFL.txt | 93 ++++++++++++++++++++++++++++++ public/fonts/SpaceGrotesk.ttf | Bin 0 -> 136676 bytes src/App.tsx | 3 + src/components/FontSettings.tsx | 35 +++++++++++ src/components/cull/Dialogs.tsx | 2 + src/index.css | 11 +++- src/lib/font.test.ts | 15 +++++ src/lib/font.ts | 16 +++++ 9 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 docs/features/font-selection.md create mode 100644 public/fonts/SpaceGrotesk-OFL.txt create mode 100644 public/fonts/SpaceGrotesk.ttf create mode 100644 src/components/FontSettings.tsx create mode 100644 src/lib/font.test.ts create mode 100644 src/lib/font.ts diff --git a/docs/features/font-selection.md b/docs/features/font-selection.md new file mode 100644 index 0000000..0783f3a --- /dev/null +++ b/docs/features/font-selection.md @@ -0,0 +1,5 @@ +# Interface fonts + +Settings → Culling → Interface font offers System (unchanged default), bundled Space Grotesk, or a single installed font family. Selection previews and applies immediately and persists. Space Grotesk is included under the SIL Open Font License in `public/fonts/SpaceGrotesk-OFL.txt`; no network font service is used. Technical values retain monospace styling. + +Verify with `npm run test:web`, `npm run build`, and a restart with Space Grotesk and an unavailable custom font selected. This branch requires no other feature. diff --git a/public/fonts/SpaceGrotesk-OFL.txt b/public/fonts/SpaceGrotesk-OFL.txt new file mode 100644 index 0000000..cb512b9 --- /dev/null +++ b/public/fonts/SpaceGrotesk-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/SpaceGrotesk.ttf b/public/fonts/SpaceGrotesk.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a1b2e6c26093066510a31147e7aec9abdc8d6c5e GIT binary patch literal 136676 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 IH;+H=zZFcdV*mgE literal 0 HcmV?d00001 diff --git a/src/App.tsx b/src/App.tsx index b13dafe..2071623 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useAppFont } from "@/components/FontSettings"; + import { CullingView } from "@/components/cull/CullingView"; import { NoticeDialog, SettingsDialog, ShortcutsDialog, TrashDialog } from "@/components/cull/Dialogs"; import { ShipDialog } from "@/components/cull/ShipDialog"; @@ -12,6 +14,7 @@ import { installShortcuts, loadBindings, saveBindings, type Action, type Combo } import { useStore } from "@/lib/store"; export default function App() { + useAppFont(); const open = useStore((s) => s.session !== null); const ingesting = useStore((s) => s.ingestOpen); const [bindings, setBindings] = useState(loadBindings); diff --git a/src/components/FontSettings.tsx b/src/components/FontSettings.tsx new file mode 100644 index 0000000..8b96dba --- /dev/null +++ b/src/components/FontSettings.tsx @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import { create } from "zustand"; +import { load, save } from "@/lib/prefs"; +import { fontStack, validFont, type FontChoice, type FontSetting } from "@/lib/font"; + +const useFont = create<{ value: FontSetting; set: (value: FontSetting) => void }>((set) => ({ + value: validFont(load("font", null)), + set(value) { const safe = validFont(value); save("font", safe); set({ value: safe }); }, +})); + +export function useAppFont() { + const value = useFont((s) => s.value); + useEffect(() => { document.documentElement.style.setProperty("--app-font", fontStack(value.choice, value.family)); }, [value]); +} + +export function FontSettings() { + const { value, set } = useFont(); + return ( +
+ Interface font + + {value.choice === "custom" && } +

Space Grotesk is included offline. Unavailable fonts fall back to the system font.

+

The quick brown fox · IMG_0001.RAW · 0123456789

+
+ ); +} diff --git a/src/components/cull/Dialogs.tsx b/src/components/cull/Dialogs.tsx index 81fd146..3d027e1 100644 --- a/src/components/cull/Dialogs.tsx +++ b/src/components/cull/Dialogs.tsx @@ -11,6 +11,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { FontSettings } from "@/components/FontSettings"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; @@ -280,6 +281,7 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT they are stepped through; a tab with more than fits scrolls. */}
+ { + it("keeps the system default and a bundled offline choice", () => { + expect(fontStack("system", "")).toContain("sans-serif"); + expect(fontStack("space-grotesk", "")).toMatch(/^"Space Grotesk"/); + }); + it("treats custom families as one quoted family, with system fallback", () => { + expect(fontStack("custom", "A, B")).toMatch(/^"A, B",/); + expect(fontStack("custom", "")).toBe(fontStack("system", "")); + expect(validFont({ choice: "bad", family: "" }).choice).toBe("system"); + expect(validFont(null).choice).toBe("system"); + }); +}); diff --git a/src/lib/font.ts b/src/lib/font.ts new file mode 100644 index 0000000..be87593 --- /dev/null +++ b/src/lib/font.ts @@ -0,0 +1,16 @@ +export type FontChoice = "system" | "space-grotesk" | "custom"; +export interface FontSetting { choice: FontChoice; family: string } +const SYSTEM = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; + +export function validFont(value: unknown): FontSetting { + const v = value as Partial | null; + return { + choice: v && ["system", "space-grotesk", "custom"].includes(String(v.choice)) ? v.choice as FontChoice : "system", + family: typeof v?.family === "string" ? v.family.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200) : "", + }; +} + +export function fontStack(choice: FontChoice, family: string): string { + const name = choice === "space-grotesk" ? "Space Grotesk" : choice === "custom" ? family.trim() : ""; + return name ? `${JSON.stringify(name)}, ${SYSTEM}` : SYSTEM; +} From 402ff3c5e9cf05aba63a2054a5575925c3bc0ea1 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:44:01 +0700 Subject: [PATCH 04/71] feat: add light and neutral grey appearance modes --- docs/features/appearance-modes.md | 5 + src/App.tsx | 3 + src/components/ThemeSettings.tsx | 29 ++++++ src/components/UpdateDialog.tsx | 2 +- src/components/cull/Crop.tsx | 20 ++-- src/components/cull/CullingView.tsx | 22 ++--- src/components/cull/Dialogs.tsx | 18 ++-- src/components/cull/ExifPanel.tsx | 26 ++--- src/components/cull/FacePanel.tsx | 14 +-- src/components/cull/Filmstrip.tsx | 12 +-- src/components/cull/FilterBar.tsx | 8 +- src/components/cull/Loupe.tsx | 16 +-- src/components/cull/SceneView.tsx | 8 +- src/components/cull/ShipDialog.tsx | 44 ++++----- src/components/cull/Thumb.tsx | 6 +- src/components/cull/Toolbar.tsx | 20 ++-- src/components/cull/symbols.tsx | 8 +- src/components/home/HomeView.tsx | 44 ++++----- src/components/ingest/IngestView.tsx | 58 +++++------ src/components/ui/tabs.tsx | 2 +- src/index.css | 143 ++++++++++++++++++++++++++- src/lib/theme.test.ts | 14 +++ src/lib/theme.ts | 5 + 23 files changed, 363 insertions(+), 164 deletions(-) create mode 100644 docs/features/appearance-modes.md create mode 100644 src/components/ThemeSettings.tsx create mode 100644 src/lib/theme.test.ts create mode 100644 src/lib/theme.ts diff --git a/docs/features/appearance-modes.md b/docs/features/appearance-modes.md new file mode 100644 index 0000000..a3d6cd0 --- /dev/null +++ b/docs/features/appearance-modes.md @@ -0,0 +1,5 @@ +# Appearance modes + +Settings → Culling → Appearance selects Dark (unchanged default), Light, or 18% grey. Selection persists and applies immediately. The neutral grey canvas is sRGB #767676, approximately 18% linear luminance; panels use neutral darker tones. Interface neutrals use semantic CSS roles while image pixels and rating colours remain unchanged. Native controls follow the explicit colour scheme. + +Verify all three modes on home, card ingest, culling, settings, menus, and dialogs, including a small desktop window. Run frontend unit tests and production build. No other feature branch is required. diff --git a/src/App.tsx b/src/App.tsx index b13dafe..b27b1e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useAppTheme } from "@/components/ThemeSettings"; + import { CullingView } from "@/components/cull/CullingView"; import { NoticeDialog, SettingsDialog, ShortcutsDialog, TrashDialog } from "@/components/cull/Dialogs"; import { ShipDialog } from "@/components/cull/ShipDialog"; @@ -12,6 +14,7 @@ import { installShortcuts, loadBindings, saveBindings, type Action, type Combo } import { useStore } from "@/lib/store"; export default function App() { + useAppTheme(); const open = useStore((s) => s.session !== null); const ingesting = useStore((s) => s.ingestOpen); const [bindings, setBindings] = useState(loadBindings); diff --git a/src/components/ThemeSettings.tsx b/src/components/ThemeSettings.tsx new file mode 100644 index 0000000..94d46c2 --- /dev/null +++ b/src/components/ThemeSettings.tsx @@ -0,0 +1,29 @@ +import { useEffect } from "react"; +import { create } from "zustand"; +import { load, save } from "@/lib/prefs"; +import { validTheme, type Theme } from "@/lib/theme"; +const useTheme = create<{ theme: Theme; set: (theme: Theme) => void }>((set) => ({ + theme: validTheme(load("theme", "dark")), + set(theme) { const safe = validTheme(theme); save("theme", safe); set({ theme: safe }); }, +})); +export function useAppTheme() { + const theme = useTheme((s) => s.theme); + useEffect(() => { + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme === "light" ? "light" : "dark"; + document.documentElement.classList.toggle("dark", theme !== "light"); + }, [theme]); +} +export function ThemeSettings() { + const { theme, set } = useTheme(); + return
+ Appearance + +

18% grey uses a neutral photographic surround. Photo pixels and rating colours are unchanged.

+
; +} diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx index 50380f6..d46bcf6 100644 --- a/src/components/UpdateDialog.tsx +++ b/src/components/UpdateDialog.tsx @@ -80,7 +80,7 @@ export function UpdateDialog() {
-
{progress < 1 ? `Downloading… ${Math.round(progress * 100)}%` : "Installing…"}
+
{progress < 1 ? `Downloading… ${Math.round(progress * 100)}%` : "Installing…"}
)} diff --git a/src/components/cull/Crop.tsx b/src/components/cull/Crop.tsx index 429efd4..68d5fbf 100644 --- a/src/components/cull/Crop.tsx +++ b/src/components/cull/Crop.tsx @@ -308,8 +308,8 @@ function CropBar() { const c = useCrop(); const s = useStore.getState(); const mod = isMac ? "⌘" : "Ctrl+"; - const divider = ; - const tool = "flex size-7 shrink-0 items-center justify-center rounded-md text-[#bdbdbd] hover:bg-white/10 hover:text-white [&_svg]:size-[15px]"; + const divider = ; + const tool = "flex size-7 shrink-0 items-center justify-center rounded-md text-[var(--ui-text-bdbdbd)] hover:bg-white/10 hover:text-white [&_svg]:size-[15px]"; const lit = "bg-brand/20 text-brand hover:bg-brand/25 hover:text-brand"; const [custom, setCustom] = useState({ w: "", h: "" }); @@ -323,7 +323,7 @@ function CropBar() {
e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} - className="absolute bottom-5 left-1/2 flex -translate-x-1/2 cursor-default items-center gap-2.5 rounded-xl border border-white/10 bg-[#2a2a30]/90 px-3 py-2 text-xs text-[#d9d9d9] shadow-[0_4px_12px_rgba(0,0,0,.4)] backdrop-blur-md" + className="absolute bottom-5 left-1/2 flex -translate-x-1/2 cursor-default items-center gap-2.5 rounded-xl border border-white/10 bg-[var(--ui-surface-2a2a30)]/90 px-3 py-2 text-xs text-[var(--ui-text-d9d9d9)] shadow-[0_4px_12px_rgba(0,0,0,.4)] backdrop-blur-md" > @@ -343,10 +343,10 @@ function CropBar() { {/* Typed, so the keys go to the boxes and not to the menu or the app. */}
e.stopPropagation()}> - Custom + Custom {(["w", "h"] as const).map((side, i) => ( - {i === 1 && ×} + {i === 1 && ×} setCustom({ ...custom, [side]: e.target.value.replace(/[^0-9.]/g, "") })} onKeyDown={(e) => e.key === "Enter" && applyCustom()} onBlur={applyCustom} - className="h-6 w-10 rounded bg-white/[0.08] px-1 text-center text-xs text-white outline-none focus:ring-1 focus:ring-brand" + className="h-6 w-10 rounded bg-white/[0.08] px-1 text-center text-xs text-foreground outline-none focus:ring-1 focus:ring-brand" /> ))} @@ -400,7 +400,7 @@ function CropBar() { c.tilt(0); c.tiltDone(); }} - className="w-11 shrink-0 text-right font-mono tabular-nums text-[#bdbdbd]" + className="w-11 shrink-0 text-right font-mono tabular-nums text-[var(--ui-text-bdbdbd)]" > {tilt > 0 ? "+" : ""} {tilt.toFixed(2)}° @@ -423,7 +423,7 @@ function CropBar() { {divider} - @@ -439,10 +439,10 @@ function CropBar() { {divider} - -
{showFilmstrip && viewMode === "loupe" && !empty && ( -
+
)} @@ -137,7 +137,7 @@ function PanelHandle({ panel, direction }: { panel: PanelWidth; direction: 1 | - // could be dragged; what is grabbed is a wider strip laid over it, seen // only when the pointer is on it. return ( -
+
); @@ -162,12 +162,12 @@ function EmptyFolder() { return (
- -
No photos in this folder
-
{session.name}
+ +
No photos in this folder
+
{session.name}
{otherKind && ( <> -

+

It holds {session.leftOut === 1 ? `1 ${unwanted} photo` : `${session.leftOut.toLocaleString()} ${unwanted} photos`}, and Settings is set to show only {wanted}.

-

Stays that way for folders you open from now on. Change it any time in Settings.

+

Stays that way for folders you open from now on. Change it any time in Settings.

)} {otherKind ? null : hidden ? ( <> -

+

It contains {session.subfolderCount === 1 ? "1 subfolder" : `${session.subfolderCount} subfolders`}. Photos inside subfolders are not opened unless you ask for them.

-

Stays on for folders you open from now on. Change it any time in Settings.

+

Stays on for folders you open from now on. Change it any time in Settings.

) : ( - session.includeSubfolders &&

Nothing was found here or in any of its subfolders.

+ session.includeSubfolders &&

Nothing was found here or in any of its subfolders.

)} -
diff --git a/src/components/cull/Dialogs.tsx b/src/components/cull/Dialogs.tsx index 81fd146..7328960 100644 --- a/src/components/cull/Dialogs.tsx +++ b/src/components/cull/Dialogs.tsx @@ -11,6 +11,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { ThemeSettings } from "@/components/ThemeSettings"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; @@ -115,16 +116,16 @@ export function ShortcutsDialog({ bindings, onChange, onRecording }: { bindings:
{SECTIONS.map((section) => (
-
{section.title}
+
{section.title}
{section.actions.map(([action, label]) => ( -
+
{label} @@ -394,7 +396,7 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT {isMac ? "⌘ drag" : "Ctrl + drag"}} + control={{isMac ? "⌘ drag" : "Ctrl + drag"}} /> @@ -438,7 +440,7 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT - {s.cacheLimitGb} GB + {s.cacheLimitGb} GB @@ -499,8 +501,8 @@ function Setting({ title, detail, control }: { title: string; detail: string; co return (
-
{title}
-
{detail}
+
{title}
+
{detail}
{control}
diff --git a/src/components/cull/ExifPanel.tsx b/src/components/cull/ExifPanel.tsx index c89ccc3..4c5688a 100644 --- a/src/components/cull/ExifPanel.tsx +++ b/src/components/cull/ExifPanel.tsx @@ -14,12 +14,12 @@ export function ExifPanel() {
- EXIF Data -
-
+
{photo ?
: } @@ -29,8 +29,8 @@ export function ExifPanel() { function Empty() { return ( -
- +
+ No image selected
); @@ -51,7 +51,7 @@ function Details({ photo }: { photo: Photo }) { } /> } />
- {comp && comp !== "0 EV" &&
± Exposure Comp: {comp}
} + {comp && comp !== "0 EV" &&
± Exposure Comp: {comp}
}
@@ -81,12 +81,12 @@ function Details({ photo }: { photo: Photo }) { ); } -const Rule = () =>
; +const Rule = () =>
; function Section({ title, children }: { title: string; children: ReactNode }) { return (
-
{title}
+
{title}
{children}
); @@ -95,11 +95,11 @@ function Section({ title, children }: { title: string; children: ReactNode }) { function Badge({ title, value, icon }: { title: string; value: string | null; icon: ReactNode }) { return (
-
+
{icon} {title}
-
{value ?? "—"}
+
{value ?? "—"}
); } @@ -109,8 +109,8 @@ function Row({ icon, title, value }: { icon: ReactNode; title: string; value: st
{icon}
-
{title}
-
{value}
+
{title}
+
{value}
); @@ -196,5 +196,5 @@ function Histogram({ photo }: { photo: Photo | undefined }) { draw(data.luma, "rgba(255, 255, 255, .16)"); }, [data, rgb, width]); - return ; + return ; } diff --git a/src/components/cull/FacePanel.tsx b/src/components/cull/FacePanel.tsx index 3122ac6..e51a192 100644 --- a/src/components/cull/FacePanel.tsx +++ b/src/components/cull/FacePanel.tsx @@ -169,7 +169,7 @@ export function FacePanel() { ))}
) : ( -
+
{!enabled ? "OpenCull AI is off" : result ? "No faces detected" : "Analyzing…"}
@@ -249,7 +249,7 @@ function FaceCell({ face, eyes, photo, width, height, softSrc, sharpSrc, fullSrc type="button" title={face.reason ?? "Zoom to this face"} onClick={() => useStore.getState().zoomToFace(face)} - className={cn("relative shrink-0 cursor-pointer overflow-hidden rounded-md bg-[#333]", !face.subject && "opacity-40")} + className={cn("relative shrink-0 cursor-pointer overflow-hidden rounded-md bg-[var(--ui-surface-333333)]", !face.subject && "opacity-40")} style={{ width, height }} > @@ -268,7 +268,7 @@ function KeyCell({ region, photo, width, height, softSrc, sharpSrc, fullSrc }: { const [hovering, setHovering] = useState(false); const k = Math.min(Math.max(Math.min(width, height) / 150, 0.62), 1); return ( - ); } @@ -54,7 +54,7 @@ export function FilterBar() { }, [photos, order]); const flag = (filter: FlagFilter) => () => s.setFlagFilter(flagFilter === filter ? "all" : filter); - const sep = ; + const sep = ; return (
@@ -82,7 +82,7 @@ export function FilterBar() { s.toggleColorFilter("none")}> - + {COLORS.map((c) => ( s.toggleColorFilter(c)}> @@ -93,7 +93,7 @@ export function FilterBar() { {filtering && ( <> {sep} - diff --git a/src/components/cull/Loupe.tsx b/src/components/cull/Loupe.tsx index 9cb0bd6..3ca5e06 100644 --- a/src/components/cull/Loupe.tsx +++ b/src/components/cull/Loupe.tsx @@ -130,7 +130,7 @@ export function Loupe() { ) : photo ? ( ) : ( -
+
{loading && } {loading ? `Opening ${folder}…` : "No image selected"}
@@ -723,7 +723,7 @@ function ZoomBadge({ percent, sharp, loading }: { percent: number | null; sharp:
{percent === null ? "Fit" : `${percent}%`} {loading && ( - + HD )} @@ -738,7 +738,7 @@ function InfoOverlay({ photo }: { photo: Photo }) { return (
{fmt.displayName(photo)}
- {parts.length > 0 &&
{parts.map((p) => {p})}
} + {parts.length > 0 &&
{parts.map((p) => {p})}
}
); } @@ -746,24 +746,24 @@ function InfoOverlay({ photo }: { photo: Photo }) { /** Flag, stars and colour for the photo in the loupe, along its bottom edge. */ function RatingBar({ photo }: { photo: Photo }) { const s = useStore.getState(); - const divider = ; + const divider = ; return (
e.stopPropagation()} - className="absolute bottom-5 left-1/2 flex -translate-x-1/2 items-center gap-3.5 rounded-xl border border-white/10 bg-[#2a2a30]/80 px-4.5 py-2.5 shadow-[0_4px_12px_rgba(0,0,0,.4)] backdrop-blur-md" + className="absolute bottom-5 left-1/2 flex -translate-x-1/2 items-center gap-3.5 rounded-xl border border-white/10 bg-[var(--ui-surface-2a2a30)]/80 px-4.5 py-2.5 shadow-[0_4px_12px_rgba(0,0,0,.4)] backdrop-blur-md" > {divider} {[1, 2, 3, 4, 5].map((n) => ( - ))} diff --git a/src/components/cull/SceneView.tsx b/src/components/cull/SceneView.tsx index 4f2a090..971baa9 100644 --- a/src/components/cull/SceneView.tsx +++ b/src/components/cull/SceneView.tsx @@ -50,7 +50,7 @@ export function SceneView() {
-
+
@@ -74,7 +74,7 @@ function SceneStrip({ scenes, active }: { scenes: Scene[]; active: Scene | undef const { scroller, length, from, to, onScroll, onWheel } = useStrip({ starts, pad: 10 - CARD_GAP, position: active ? active.number - 1 : -1, anchor: active?.frames[0] }); return ( -
+
{scenes.slice(from, to).map((scene, i) => (
@@ -98,11 +98,11 @@ function SceneCard({ scene, active }: { scene: Scene; active: boolean }) { // Opens on the scene's first frame, with any selection dropped: a // selection made in one scene means nothing in the next. onClick={() => useStore.getState().select(scene.frames[0], { meta: false, shift: false })} - className={cn("relative overflow-hidden rounded-md bg-[#141414]", active ? "ring-2 ring-[#d9d9d9]" : "opacity-75 hover:opacity-100")} + className={cn("relative overflow-hidden rounded-md bg-[var(--ui-surface-141414)]", active ? "ring-2 ring-[#d9d9d9]" : "opacity-75 hover:opacity-100")} style={{ width: CARD_W, height: CARD_H }} > - {scene.number} + {scene.number} {scene.frames.length > 1 && ( ×{scene.frames.length} )} diff --git a/src/components/cull/ShipDialog.tsx b/src/components/cull/ShipDialog.tsx index c3923cb..c249f28 100644 --- a/src/components/cull/ShipDialog.tsx +++ b/src/components/cull/ShipDialog.tsx @@ -119,7 +119,7 @@ export function ShipDialog() { const open = useStore((s) => s.shipOpen); return ( useStore.getState().set("shipOpen", o)}> - e.preventDefault()}> + e.preventDefault()}> {/* Only exists while Ship is open, so it reads the folder's ratings when asked to and not on every change while culling. */} @@ -292,13 +292,13 @@ function Ship() { }; return ( -
+
Ship - Export, copy, or open images in your editing app + Export, copy, or open images in your editing app
-
@@ -314,9 +314,9 @@ function Ship() { key={mode} type="button" onClick={() => change({ mode })} - className={cn("flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px]", settings.mode === mode ? "bg-[#59a6ff]/15 text-white" : "text-[#b3b3b3] hover:bg-white/[0.04]")} + className={cn("flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px]", settings.mode === mode ? "bg-[#59a6ff]/15 text-white" : "text-[var(--ui-text-b3b3b3)] hover:bg-white/[0.04]")} > - + {label} ))} @@ -324,7 +324,7 @@ function Ship() { {settings.mode === "ratings" && (
-
Select ratings to include
+
Select ratings to include
{/* The filter bar's symbols, in the filter bar's order. */} {(["pick", "none", "reject"] as Flag[]).map((flag) => ( @@ -348,7 +348,7 @@ function Ship() { count={count((p) => p.rating === r)} onClick={() => change({ ratings: toggleIn(settings.ratings, r) })} > - + ))}
@@ -364,7 +364,7 @@ function Ship() {
-
+
{images.length.toLocaleString()} of {everything.length.toLocaleString()} selected @@ -384,7 +384,7 @@ function Ship() { {apps.map((app) => ( change({ editor: app.id, choseEditor: true })}> {app.name} - {!app.installed && not installed} + {!app.installed && not installed} ))} @@ -407,11 +407,11 @@ function Ship() { change({ operation: "move" })}>Move files to…
- + {/* Cut from the left, so the folder's own name is what stays in view. */} {settings.destination ?? "Choose a destination folder"} -
@@ -421,26 +421,26 @@ function Ship() {
- {blocked ? : } - {blocked ?? `${images.length.toLocaleString()} image${images.length === 1 ? "" : "s"} ready to ship`} + {blocked ? : } + {blocked ?? `${images.length.toLocaleString()} image${images.length === 1 ? "" : "s"} ready to ship`} {shipping ? ( <> - Shipping… + Shipping… ) : ( <> - @@ -548,7 +548,7 @@ function Chip({ title, on, count, onClick, children }: { title: string; on: bool className={cn("flex h-7 items-center gap-2 rounded-md border px-2 [&_svg]:size-[13px]", on ? "border-[#59a6ff] bg-[#59a6ff]/20" : "border-transparent bg-white/[0.06] hover:bg-white/[0.1]")} > {children} - {count.toLocaleString()} + {count.toLocaleString()} ); } diff --git a/src/components/cull/Thumb.tsx b/src/components/cull/Thumb.tsx index 0611790..861702b 100644 --- a/src/components/cull/Thumb.tsx +++ b/src/components/cull/Thumb.tsx @@ -82,7 +82,7 @@ export function FramedThumb({ photo, size, box, inset = 0, cover = false, classN {photo.framing.crop && ( - + )} @@ -126,7 +126,7 @@ export function FlagBadge({ flag, small }: { flag: Photo["flag"]; small?: boolea return ( {flag === "pick" ? ( - + ) : ( )} @@ -139,7 +139,7 @@ export function StarRow({ rating, small }: { rating: number; small?: boolean }) return ( {[1, 2, 3, 4, 5].map((n) => ( - + ★ ))} diff --git a/src/components/cull/Toolbar.tsx b/src/components/cull/Toolbar.tsx index 0a8ad76..db1a140 100644 --- a/src/components/cull/Toolbar.tsx +++ b/src/components/cull/Toolbar.tsx @@ -48,7 +48,7 @@ function Tool({ label, active, onClick, children, className }: { label: string; onClick={onClick} className={cn( "flex size-8 shrink-0 items-center justify-center rounded-md transition-colors [&_svg]:size-[15px]", - active ? "bg-brand/15 text-brand" : "text-[#737373] hover:text-[#b3b3b3]", + active ? "bg-brand/15 text-brand" : "text-[var(--ui-text-737373)] hover:text-[var(--ui-text-b3b3b3)]", className, )} > @@ -61,7 +61,7 @@ function Tool({ label, active, onClick, children, className }: { label: string; } export function Divider({ className }: { className?: string }) { - return ; + return ; } const SORTS: { key: SortKey; title: string; up: string; down: string }[] = [ @@ -93,17 +93,17 @@ export function Toolbar() { const s = useStore.getState(); return ( -
+
s.closeFolder()} className="w-9"> - - + + {name} {kinds !== "both" && ( - + {kinds === "raw" ? "RAW only" : "JPEG only"} )} @@ -198,7 +198,7 @@ function Mode({ mode, label, current, children }: { mode: ViewMode; label: strin aria-label={label} aria-pressed={current === mode} onClick={() => useStore.getState().set("viewMode", mode)} - className={cn("flex h-6 w-[26px] items-center justify-center rounded [&_svg]:size-[13px]", current === mode ? "bg-[#383838] text-white" : "text-[#737373] hover:text-[#b3b3b3]")} + className={cn("flex h-6 w-[26px] items-center justify-center rounded [&_svg]:size-[13px]", current === mode ? "bg-[var(--ui-surface-383838)] text-white" : "text-[var(--ui-text-737373)] hover:text-[var(--ui-text-b3b3b3)]")} > {children} @@ -235,10 +235,10 @@ function Status() { // Counted from how many there were when this round began. Never shown // as finished while any are left. const done = toPrepare > 0 ? Math.min(0.99, Math.max(0, 1 - preparing / toPrepare)) : 0; - return ; + return ; } return ( - + Ready @@ -275,7 +275,7 @@ function SortMenu() { diff --git a/src/components/cull/symbols.tsx b/src/components/cull/symbols.tsx index 35b8a31..3997427 100644 --- a/src/components/cull/symbols.tsx +++ b/src/components/cull/symbols.tsx @@ -9,9 +9,9 @@ import type { Flag } from "@/lib/types"; /** A flag state: flagged, unflagged or rejected. */ export function FlagSymbol({ flag, lit }: { flag: Flag; lit: boolean }) { - if (flag === "pick") return ; - if (flag === "none") return ; - return ; + if (flag === "pick") return ; + if (flag === "none") return ; + return ; } /** @@ -21,7 +21,7 @@ export function FlagSymbol({ flag, lit }: { flag: Flag; lit: boolean }) { */ export function RatingSymbol({ rating, lit, behind }: { rating: number; lit: boolean; behind: string }) { return ( - + {rating === 0 ? : } {rating > 0 && {rating}} diff --git a/src/components/home/HomeView.tsx b/src/components/home/HomeView.tsx index bd4bb76..572ca82 100644 --- a/src/components/home/HomeView.tsx +++ b/src/components/home/HomeView.tsx @@ -98,25 +98,25 @@ export function HomeView({ dropping }: { dropping: boolean }) {

OpenCull

-

Culling Software made for Photographers

+

Culling Software made for Photographers

- - @@ -125,7 +125,7 @@ export function HomeView({ dropping }: { dropping: boolean }) { {arrived && (
- “{arrived.name}” detected + “{arrived.name}” detected -
@@ -148,20 +148,20 @@ export function HomeView({ dropping }: { dropping: boolean }) { {recents.length === 0 ? (
-
Open a folder to start culling
-

Choose a folder of photos, or drop one anywhere on this window. RAW files open straight from the previews your camera already made.

-
) : ( <>
-

Recent Folders ({recents.length})

- - {selected.size > 0 && ( @@ -237,17 +237,17 @@ function FolderCard({ onClick={open} onKeyDown={(e) => e.key === "Enter" && open()} className={cn( - "group relative cursor-pointer overflow-hidden rounded-xl border bg-[#1f1f1f] shadow-[0_4px_8px_rgba(0,0,0,.3)] transition-transform duration-150 hover:scale-[1.02] hover:bg-[#292929]", - selected ? "border-2 border-[#5999ff]" : "border-[#383838] hover:border-[#4d80ff]/60", + "group relative cursor-pointer overflow-hidden rounded-xl border bg-[var(--ui-surface-1f1f1f)] shadow-[0_4px_8px_rgba(0,0,0,.3)] transition-transform duration-150 hover:scale-[1.02] hover:bg-[var(--ui-surface-292929)]", + selected ? "border-2 border-[#5999ff]" : "border-[var(--ui-line-383838)] hover:border-[#4d80ff]/60", )} >
{missing ? : }
-
+
{missing && } {baseName(path)}
-
{missing ? "Not where it was — click to find it" : path}
+
{missing ? "Not where it was — click to find it" : path}
@@ -274,8 +274,8 @@ function FolderCard({ function Placeholder() { return ( -
- +
+
); } @@ -297,7 +297,7 @@ function Cover({ path }: { path: string }) { const tile = (image: Scanned, size: number) => ; return ( -
+
0 ? "w-3/5" : "w-full")}>{tile(first, 640)}
{rest.length > 0 &&
{rest.slice(0, 3).map((image) => tile(image, 320))}
}
diff --git a/src/components/ingest/IngestView.tsx b/src/components/ingest/IngestView.tsx index 1256b9d..38e2e2f 100644 --- a/src/components/ingest/IngestView.tsx +++ b/src/components/ingest/IngestView.tsx @@ -395,8 +395,8 @@ export function IngestView() { const current = levels[levels.length - 1]; return ( -
-
+
+
Ingest from Card @@ -406,7 +406,7 @@ export function IngestView() { if (copying) cancelCopy(); close(); }} - className="rounded-md px-3 py-1.5 text-[13px] text-[#b3b3b3] hover:bg-white/[0.07] hover:text-white" + className="rounded-md px-3 py-1.5 text-[13px] text-[var(--ui-text-b3b3b3)] hover:bg-white/[0.07] hover:text-white" > Cancel @@ -415,13 +415,13 @@ export function IngestView() {
{/* Where from. */} -
-
Files to be copied (ingested) to destination folder
+
Files to be copied (ingested) to destination folder
{phase.is === "scanning" ? ( @@ -516,7 +516,7 @@ export function IngestView() {
-
{status}
+
{status}
@@ -526,18 +526,18 @@ export function IngestView() { {/* Where to. */} -
{/* Where to. */}
- !open && setPhase((p) => (p.is === "conflict" ? { is: "ready" } : p))}> - - - Files Already Exist - - {phase.is === "conflict" && - `${phase.count} selected file${phase.count === 1 ? "" : "s"} already exist in the destination, or share a name with another selected file. Apply one action to all conflicts.`} - - - - {( - [ - ["uniqueName", "Copy with Unique Name"], - ["overwrite", "Overwrite"], - ["skip", "Skip Existing"], - ] as [IngestPolicy, string][] - ).map(([policy, label]) => ( - phase.is === "conflict" && void copy(phase.items, policy)}> - {label} - - ))} - Cancel - - - - !open && setPhase({ is: "ready" })}> - Ingest Failed + Could not open card - {phase.is === "failed" && `${phase.message}\n\nKeep the card as it is. Photos copied before this one are in the destination; nothing after it was started.`} + {phase.is === "failed" && `${phase.message}\n\nKeep the card as it is. No files were copied or deleted.`} diff --git a/src/components/ingest/ReviewBar.tsx b/src/components/ingest/ReviewBar.tsx new file mode 100644 index 0000000..3833b8e --- /dev/null +++ b/src/components/ingest/ReviewBar.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { api } from "@/lib/api"; +import { initialSelection, selectedReview } from "@/lib/review"; +import { copyReview, loadReview, saveReview } from "@/lib/review-api"; +import { useStore } from "@/lib/store"; + +export function ResumeCardSession() { + const [available, setAvailable] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { void loadReview().then((r) => setAvailable(r !== null)).catch((e) => setError(String(e))); }, []); + const resume = async () => { + try { + const review = await loadReview(); if (!review) return; + const paths = review.items.flatMap((p) => [p.path, p.display]); + const present = await api.pathsExist(paths); + if (present.some((p) => !p)) throw new Error("Reconnect the source cards before resuming this session."); + await useStore.getState().openFolder(review.sources[0], review); + } catch (e) { setError(String(e)); } + }; + return <>{available && }{error &&

{error}

}; +} + +export function ReviewBar() { + const review = useStore((s) => s.review); + const photos = useStore((s) => s.photos); + const loading = useStore((s) => s.loading); + const [open, setOpen] = useState(false); + const [chosen, setChosen] = useState([]); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(0); + const [error, setError] = useState(""); + if (!review) return null; + const finish = () => { setChosen(review.chosen ?? initialSelection(photos)); setError(""); setOpen(true); }; + const selected = selectedReview(photos, chosen); + const setSelection = (next: string[]) => { + setChosen(next); useStore.setState({ review: { ...review, chosen: next } }); + }; + const inPlace = async () => { + try { + const next = { ...review, items: selected, chosen, stage: "in-place" as const }; + await saveReview(next); setOpen(false); + await useStore.getState().openFolder(review.sources[0], next); + } catch (e) { setError(String(e)); } + }; + const copy = async () => { + const destination = review.destination ?? await api.pickFolder("Copy selected card files to:"); if (!destination) return; + setBusy(true); setError(""); setDone(0); + useStore.setState({ review: { ...review, destination } }); + try { + const pending = selected.filter((p) => !review.completed[p.path]); + const result = await copyReview(pending, destination, review.sources, setDone); + const next = { ...review, destination, chosen, completed: { ...review.completed, ...result.completed } }; + useStore.setState({ review: next }); await saveReview(next); + if (result.failed.length || result.sidecarFailed.length) throw new Error([...result.failed, ...result.sidecarFailed].join("\n")); + if (pending.some((p) => !next.completed[p.path])) throw new Error("Copy cancelled. Completed files are remembered; you can retry the remaining files."); + const destinations = selected.map((p) => next.completed[p.path]); + const copied = (await api.ingestScan(destination)).filter((p) => destinations.includes(p.path)); + setOpen(false); + const imported = { ...next, sources: [destination], items: copied, chosen: copied.map((p) => p.path), decisions: {}, completed: {}, stage: "in-place" as const }; + await useStore.getState().openFolder(destination, imported); + } catch (e) { setError(String(e)); } finally { setBusy(false); } + }; + return <> +
+ {review.stage === "review" ? "Card review — source files stay untouched" : "Imported in place — only chosen files"} + +
+ !busy && setOpen(value)}> + busy && e.preventDefault()}> + Choose files to importStarts with all non-rejected photos. Copy keeps every source file. Import in place opens only chosen files without copying or writing to the cards. +
+
+ {photos.map((p) => )} +
+

{selected.length} selected{busy ? ` · ${done} copied` : ""}

+ {error &&

{error}

} +
+ {busy ? : <>} +
+
+
+ ; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index b881c5c..c94c9d0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -115,7 +115,7 @@ export type Tier = "thumb" | "fit" | "screen" | "full"; export interface Backend { appInfo(): Promise; - openFolder(path: string, includeSubfolders: boolean, kinds: FileKinds): Promise; + openFolder(path: string, includeSubfolders: boolean, kinds: FileKinds, members?: Scanned[]): Promise; closeFolder(session: number): Promise; loadMetadata(session: number, onEvent: (e: MetaEvent) => void): Promise; prefetch(items: { path: string; version: string }[], size: number): Promise; @@ -209,7 +209,7 @@ const previewBase = /Windows|Android/.test(navigator.userAgent) ? "http://opencu const tauri: Backend = { appInfo: () => invoke("app_info"), - openFolder: (path, includeSubfolders, kinds) => invoke("open_folder", { path, includeSubfolders, kinds }), + openFolder: (path, includeSubfolders, kinds, members) => invoke("open_folder", { path, includeSubfolders, kinds, members }), closeFolder: (session) => invoke("close_folder", { session }), loadMetadata: (session, onEvent) => { const channel = new Channel(); diff --git a/src/lib/mock.ts b/src/lib/mock.ts index e8fafe6..f9e7a09 100644 --- a/src/lib/mock.ts +++ b/src/lib/mock.ts @@ -5,6 +5,7 @@ import type { Backend } from "./api"; import type { Exif, Meta, Scanned } from "./types"; const COUNT = 240; +let activeImages: Scanned[]; const images: Scanned[] = Array.from({ length: COUNT }, (_, i) => { const name = `A7V${String(2751 + i).padStart(5, "0")}.ARW`; @@ -55,7 +56,7 @@ export const mock: Backend = { appInfo: () => later({ platform: "macos", version: "dev", cacheDir: "/tmp/opencull", openPath: null, selfTest: false }), // Untimed, so the slower path — filename order first, shooting order once // the metadata is in — is the one a browser tab exercises. - openFolder: () => later({ session: 1, images, subfolderCount: 2, leftOut: 0, times: images.map(() => null), timed: false, groups: null }, 150), + openFolder: (_path, _include, _kinds, members) => { activeImages = members ?? images; return later({ session: 1, images: activeImages, subfolderCount: 2, leftOut: 0, times: activeImages.map(() => null), timed: false, groups: null }, 150); }, closeFolder: () => later(undefined), loadMetadata: async (_session, onEvent) => { // Bursts of one to six frames, a few seconds apart. @@ -64,9 +65,9 @@ export const mock: Backend = { const items: Meta[] = []; let i = 0; let group = 0; - while (i < COUNT) { + while (i < activeImages.length) { const run = 1 + ((i * 7) % 6); - for (let k = 0; k < run && i < COUNT; k++, i++) { + for (let k = 0; k < run && i < activeImages.length; k++, i++) { ms += k === 0 ? 4000 + (i % 5) * 3000 : 120; const portrait = i % 7 === 3; items.push({ diff --git a/src/lib/review-api.ts b/src/lib/review-api.ts new file mode 100644 index 0000000..2456f62 --- /dev/null +++ b/src/lib/review-api.ts @@ -0,0 +1,21 @@ +import { Channel, invoke } from "@tauri-apps/api/core"; +import { inTauri, type Shipped } from "./api"; +import type { ReviewSession } from "./review"; +import type { Photo } from "./types"; +let saving = Promise.resolve(); +export function saveReview(review: ReviewSession): Promise { + const snapshot = JSON.parse(JSON.stringify(review)); + if (!inTauri) { localStorage.setItem("opencull.session.latest", JSON.stringify(snapshot)); return Promise.resolve(); } + saving = saving.catch(() => {}).then(() => invoke("review_save", { value: snapshot })); + return saving; +} +export async function loadReview(): Promise { + const value: ReviewSession | null = inTauri ? await invoke("review_load") : JSON.parse(localStorage.getItem("opencull.session.latest") ?? "null"); + if (value && (value.version !== 1 || !Array.isArray(value.items) || !Array.isArray(value.sources) || typeof value.decisions !== "object")) throw new Error("Saved card session is not supported"); + return value; +} +export async function copyReview(items: Photo[], destination: string, sources: string[], onProgress: (done: number) => void): Promise }> { + if (!inTauri) throw new Error("Copying requires the desktop application"); + const progress = new Channel(); progress.onmessage = onProgress; + return invoke("review_copy", { items: items.map((p) => ({ path: p.path, display: p.display, marks: { rating: p.rating, flag: p.flag, color: p.color }, framing: p.framing, upright: [p.fullWidth, p.fullHeight] })), destination, sources, progress }); +} diff --git a/src/lib/review-store.test.ts b/src/lib/review-store.test.ts new file mode 100644 index 0000000..72cf09b --- /dev/null +++ b/src/lib/review-store.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +const api = vi.hoisted(() => ({ setMarks: vi.fn(), setFraming: vi.fn() })); +vi.mock("./api", () => ({ api, inTauri: false })); +import { useStore } from "./store"; +import { newReview } from "./review"; +import { PLAIN } from "./framing"; +import type { Photo } from "./types"; +const photo: Photo = { path: "/card/A.JPG", display: "/card/A.JPG", name: "A.JPG", subpath: null, version: "1", size: 10, modified: 0, index: 0, rating: 0, flag: "none", color: "none", exif: null, captureMs: null, fullWidth: 100, fullHeight: 100, framing: PLAIN, framable: true, group: null }; +beforeEach(() => { + vi.clearAllMocks(); + useStore.setState({ review: newReview(["/card"], [photo]), photos: [{ ...photo }], current: 0, order: [0], view: [0], loading: false, undoStack: [], redoStack: [], selected: new Set(), selfTest: false, embedInJpeg: true }); +}); +describe("source-safe review", () => { + it("keeps flags and undo local even when embedded JPEG writing is enabled", () => { + useStore.getState().setFlag("reject"); + expect(useStore.getState().review?.decisions[photo.path].flag).toBe("reject"); + expect(api.setMarks).not.toHaveBeenCalled(); + useStore.getState().undo(); + expect(useStore.getState().photos[0].flag).toBe("none"); + expect(api.setMarks).not.toHaveBeenCalled(); + }); + it("keeps crops local", () => { + useStore.getState().setFraming(0, { turn: 6, crop: null }); + expect(useStore.getState().review?.decisions[photo.path].framing.turn).toBe(6); + expect(api.setFraming).not.toHaveBeenCalled(); + }); + it("normal folder marks still write sidecars", () => { + useStore.setState({ review: null }); useStore.getState().setRating(3); + expect(api.setMarks).toHaveBeenCalledOnce(); + }); + it("always confirms source deletion during card review", () => { + useStore.setState({ askBeforeTrash: false }); useStore.getState().requestTrash([0]); + expect(useStore.getState().trashPending).toEqual([0]); + }); +}); diff --git a/src/lib/review.test.ts b/src/lib/review.test.ts new file mode 100644 index 0000000..7244445 --- /dev/null +++ b/src/lib/review.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { initialSelection, selectedReview, sourceWritable } from "./review"; +const files = [{ path: "/card/A.RAW", flag: "none" }, { path: "/card/B.RAW", flag: "reject" }, { path: "/card/C.JPG", flag: "pick" }] as const; +describe("card review decisions", () => { + it("initially selects all non-rejects, including unmarked files", () => { + expect(initialSelection(files)).toEqual(["/card/A.RAW", "/card/C.JPG"]); + }); + it("imports only explicitly selected members, regardless of visible filters", () => { + expect(selectedReview(files, ["/card/C.JPG", "/elsewhere"])).toEqual([files[2]]); + }); + it("never permits source writes in review or in-place sessions", () => { + expect(sourceWritable(null)).toBe(true); + expect(sourceWritable({ stage: "review" })).toBe(false); + expect(sourceWritable({ stage: "in-place" })).toBe(false); + }); +}); diff --git a/src/lib/review.ts b/src/lib/review.ts new file mode 100644 index 0000000..bc4410d --- /dev/null +++ b/src/lib/review.ts @@ -0,0 +1,17 @@ +import type { Framing, Marks, Scanned } from "./types"; +export interface ReviewSession { + version: 1; id: string; name: string; sources: string[]; items: Scanned[]; + decisions: Record; + chosen: string[] | null; stage: "review" | "in-place"; + completed: Record; destination?: string; +} +export function initialSelection(photos: readonly T[]): string[] { + return photos.filter((p) => p.flag !== "reject").map((p) => p.path); +} +export function selectedReview(photos: readonly T[], paths: readonly string[]): T[] { + const selected = new Set(paths); return photos.filter((p) => selected.has(p.path)); +} +export const sourceWritable = (review: { stage: string } | null) => review === null; +export function newReview(sources: string[], items: Scanned[]): ReviewSession { + return { version: 1, id: String(Date.now()), name: sources.map((s) => s.split(/[\\/]/).filter(Boolean).pop()).join(" + "), sources, items, decisions: {}, chosen: null, stage: "review", completed: {} }; +} diff --git a/src/lib/store.ts b/src/lib/store.ts index 0cafabf..29aeac8 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -5,6 +5,8 @@ import { create } from "zustand"; import { api, type UpdateFound } from "./api"; import { useCrop } from "./crop"; import { cropFromShare, isPlain, MIRROR_ACROSS, MIRROR_DOWN, PLAIN, QUARTER_LEFT, QUARTER_RIGHT, sameFraming, shareOf, turned, viewSize, type Share } from "./framing"; +import { sourceWritable, type ReviewSession } from "./review"; +import { saveReview } from "./review-api"; import { load, save } from "./prefs"; import type { AiFace, AiResult, Color, Compared, FileKinds, Flag, FlagFilter, Framing, Marks, MetaEvent, Photo, Rank, SortKey, ViewMode } from "./types"; @@ -37,6 +39,7 @@ interface State { version: string; session: Session | null; + review: ReviewSession | null; /** The folder is still being listed. */ loading: boolean; /** How much of the folder's ratings and EXIF has been read, 0–1. */ @@ -173,7 +176,7 @@ interface State { interface Actions { init(): Promise; - openFolder(path: string): Promise; + openFolder(path: string, review?: ReviewSession): Promise; closeFolder(): void; reloadForSubfolders(): void; @@ -442,9 +445,10 @@ export const useStore = create((set, get) => { // them: the photo's own file if it is one, and the JPEG it is drawn from // if it is a RAW with one beside it. const jpegs = (p: Photo) => (s.embedInJpeg ? [...new Set([p.path, p.display])].filter((file) => /\.jpe?g$/i.test(file)) : []); - void api.setMarks(edit.map((e) => ({ path: photos[e.index].path, into: jpegs(photos[e.index]), ...e.after }))); + if (sourceWritable(s.review)) void api.setMarks(edit.map((e) => ({ path: photos[e.index].path, into: jpegs(photos[e.index]), ...e.after }))); set({ photos, + ...(s.review ? { review: { ...s.review, decisions: { ...s.review.decisions, ...Object.fromEntries(edit.map((e) => { const p = photos[e.index]; return [p.path, { rating: p.rating, flag: p.flag, color: p.color, framing: p.framing }]; })) } } } : {}), // A photo that no longer passes the filter leaves the view at once, // which is what filtering to "unrated" and rating your way through // the folder relies on. @@ -473,7 +477,7 @@ export const useStore = create((set, get) => { if (edit.length === 0) return; // A self-test frames nothing in anyone's folder. - if (!s.selfTest) { + if (!s.selfTest && sourceWritable(s.review)) { const jpegs = (p: Photo) => (s.embedInJpeg ? [...new Set([p.path, p.display])].filter((file) => /\.jpe?g$/i.test(file)) : []); void api.setFraming( edit.map((e) => { @@ -482,7 +486,7 @@ export const useStore = create((set, get) => { }), ); } - set({ photos, ...(record ? { undoStack: [...s.undoStack.slice(-MAX_UNDO + 1), edit], redoStack: [] } : {}) }); + set({ photos, ...(s.review ? { review: { ...s.review, decisions: { ...s.review.decisions, ...Object.fromEntries(edit.map((e) => { const p = photos[e.index]; return [p.path, { rating: p.rating, flag: p.flag, color: p.color, framing: p.framing }]; })) } } } : {}), ...(record ? { undoStack: [...s.undoStack.slice(-MAX_UNDO + 1), edit], redoStack: [] } : {}) }); } /** @@ -655,6 +659,7 @@ export const useStore = create((set, get) => { selfTest: false, version: "", session: null, + review: null, loading: false, metaProgress: 0, openError: null, @@ -752,13 +757,14 @@ export const useStore = create((set, get) => { if (info.openPath) await get().openFolder(info.openPath); }, - async openFolder(path) { + async openFolder(path, review) { const before = get(); if (before.session) get().closeFolder(); const { includeSubfolders, fileKinds } = get(); set({ - session: { id: -1, folder: path, name: baseName(path), includeSubfolders, subfolderCount: 0, fileKinds, leftOut: 0, timed: false }, + review: review ?? null, + session: { id: -1, folder: path, name: review?.name ?? baseName(path), includeSubfolders, subfolderCount: 0, fileKinds, leftOut: 0, timed: false }, loading: true, openError: null, photos: [], @@ -777,7 +783,7 @@ export const useStore = create((set, get) => { let reply; try { - reply = await api.openFolder(path, includeSubfolders, fileKinds); + reply = await api.openFolder(path, includeSubfolders, fileKinds, review?.items); } catch (e) { set({ session: null, loading: false, openError: String(e) }); return; @@ -801,6 +807,7 @@ export const useStore = create((set, get) => { framing: PLAIN, framable: false, group: reply.groups?.[index] ?? null, + ...review?.decisions[im.path], })); // When every capture time came back with the listing, the folder opens // in the order it was shot and on its true first frame. That matters @@ -822,9 +829,9 @@ export const useStore = create((set, get) => { const resume = exact >= 0 || !remembered ? exact : photos.findIndex((p) => frame(p.path) === frame(remembered)); const current = photos.length === 0 ? -1 : resume >= 0 ? resume : order[0]; - const recents = [path, ...get().recents.filter((r) => r !== path)].slice(0, MAX_RECENTS); + const recents = review ? get().recents : [path, ...get().recents.filter((r) => r !== path)].slice(0, MAX_RECENTS); set({ - session: { id: reply.session, folder: path, name: baseName(path), includeSubfolders, subfolderCount: reply.subfolderCount, fileKinds, leftOut: reply.leftOut, timed: reply.timed }, + session: { id: reply.session, folder: path, name: review?.name ?? baseName(path), includeSubfolders, subfolderCount: reply.subfolderCount, fileKinds, leftOut: reply.leftOut, timed: reply.timed }, loading: false, photos, order, @@ -852,7 +859,7 @@ export const useStore = create((set, get) => { if (event.type === "batch") { const next = s.photos.slice(); for (const m of event.items) { - next[m.index] = { ...next[m.index], ...m.marks, exif: m.exif, captureMs: m.exif.captureMs ?? next[m.index].captureMs, fullWidth: m.fullWidth, fullHeight: m.fullHeight, framing: isPlain(m.framing) ? PLAIN : m.framing, framable: m.framable }; + next[m.index] = { ...next[m.index], ...m.marks, exif: m.exif, captureMs: m.exif.captureMs ?? next[m.index].captureMs, fullWidth: m.fullWidth, fullHeight: m.fullHeight, framing: isPlain(m.framing) ? PLAIN : m.framing, framable: m.framable, ...(get().review?.decisions[next[m.index].path] ?? {}) }; } read += event.items.length; set({ photos: next, view: filtered(s.order, next, s), metaProgress: read / next.length }); @@ -889,12 +896,12 @@ export const useStore = create((set, get) => { if (!session) return; rememberPosition(); if (session.id >= 0) void api.closeFolder(session.id); - set({ session: null, loading: false, photos: [], order: [], view: [], current: -1, selected: new Set(), trashPending: null, cropOpen: false, warmRemaining: 0, ai: [], compared: [], ranks: [], poses: [], aiDone: 0, aiRunning: false, faceZoom: null }); + set({ session: null, review: null, loading: false, photos: [], order: [], view: [], current: -1, selected: new Set(), trashPending: null, cropOpen: false, warmRemaining: 0, ai: [], compared: [], ranks: [], poses: [], aiDone: 0, aiRunning: false, faceZoom: null }); }, reloadForSubfolders() { const folder = get().session?.folder; - if (folder) void get().openFolder(folder); + if (folder) void get().openFolder(folder, get().review ?? undefined); }, select(index, mods) { @@ -1106,7 +1113,7 @@ export const useStore = create((set, get) => { const s = get(); const list = targets ?? (s.selected.size > 0 ? [...s.selected] : currentOnly()); if (list.length === 0) return; - if (s.askBeforeTrash) set({ trashPending: list }); + if (s.review || s.askBeforeTrash) set({ trashPending: list }); else void trash(list); }, @@ -1119,6 +1126,7 @@ export const useStore = create((set, get) => { async transfer(targets, moving) { const s = get(); + if (s.review) { set({ notice: { title: "Finish card review to import", body: "Use Finish review to copy selected files with their local marks, or import them in place. Sources are not moved during card review." } }); return; } if (!s.session || targets.length === 0) return; const count = `${targets.length} photo${targets.length === 1 ? "" : "s"}`; const destination = await api.pickFolder(moving ? `Move ${count} to:` : `Copy ${count} to:`); @@ -1175,6 +1183,7 @@ export const useStore = create((set, get) => { }, openShip(targets) { + if (get().review) { set({ notice: { title: "Finish card review to import", body: "Use Finish review to choose which card files to import." } }); return; } if (!get().session) return; // Ship works from the selection, so shipping these means selecting them. if (targets && targets.length > 0) set({ selected: new Set(targets) }); @@ -1185,6 +1194,8 @@ export const useStore = create((set, get) => { const gone = new Set(paths); const { photos, order } = get(); drop(new Set(order.filter((i) => gone.has(photos[i].path)))); + const review = get().review; + if (review) set({ review: { ...review, items: review.items.filter((p) => !gone.has(p.path)), chosen: review.chosen?.filter((path) => !gone.has(path)) ?? null } }); }, removeRecents: (paths) => set((s) => ({ recents: s.recents.filter((r) => !paths.includes(r)) })), @@ -1198,6 +1209,9 @@ useStore.subscribe((state) => { for (const key of PERSISTED) { if (state[key] !== last[key]) save(key, state[key]); } + if (state.review && (state.photos !== last.photos || state.review !== last.review) && !state.loading) { + void saveReview(state.review).catch((error) => useStore.setState({ notice: { title: "Card session could not be saved", body: String(error) } })); + } last = state; }); From aacac1a3d1bd7f847cf6344156a12ba90285e5cf Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:52:26 +0700 Subject: [PATCH 07/71] feat: combine card sources and conservatively pair RAW and JPEG frames --- docs/features/multi-card-session.md | 9 ++++ src-tauri/src/commands.rs | 6 +++ src-tauri/src/lib.rs | 2 + src-tauri/src/multicard.rs | 73 ++++++++++++++++++++++++++++ src/components/ingest/IngestView.tsx | 21 ++++++-- src/lib/api.ts | 2 + src/lib/mock.ts | 1 + 7 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 docs/features/multi-card-session.md create mode 100644 src-tauri/src/multicard.rs diff --git a/docs/features/multi-card-session.md b/docs/features/multi-card-session.md new file mode 100644 index 0000000..b5a55cf --- /dev/null +++ b/docs/features/multi-card-session.md @@ -0,0 +1,9 @@ +# Multiple cards, one shoot + +Ingest from Card now accumulates selected cards/folders in a removable source list. All selected sources are scanned recursively, including nested DCIM folders, and displayed in one combined selection with source labels. Overlapping folders do not duplicate photos. Removed/disconnected sources are reported before copying. + +A cross-source RAW/JPEG pair requires one RAW and one JPEG with matching filename stem, camera make/model, and capture timestamp. Missing metadata, differing camera/time, or multiple candidates keep entries separate. Existing same-folder pairs remain intact. The JPEG supplies the preview and both files travel together under the existing collision/cancellation rules. + +Standalone: copies the selected combined shoot through existing ingest and opens the destination. Integration with cull-before-ingest is implemented separately, so this feature has no prerequisite branch. + +Validation: `npm test`, `npm run build`; Rust tests cover cross-card pairs, ambiguous/missing metadata, differing timestamps/cameras, overlapping roots, and disconnected sources. Native checks should include actual JPEG-only and RAW-only cards. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 97e84ce..84be7aa 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1093,3 +1093,9 @@ pub async fn trim_cache(app: AppState<'_>, limit: u64) -> Result { let cache = app.previews.cache.clone(); tauri::async_runtime::spawn_blocking(move || cache.trim(limit)).await.map_err(|e| e.to_string()) } + +/// Selected cards are scanned as one shoot; disconnected sources are reported. +#[tauri::command] +pub async fn ingest_sources(sources: Vec) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || crate::multicard::scan_sources(&sources)).await.map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 959b058..c57ab44 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ pub mod embed; pub mod faces; pub mod framing; pub mod ingest; +pub mod multicard; pub mod pool; pub mod preview; pub mod raw; @@ -168,6 +169,7 @@ pub fn run() { commands::ingest_volumes, commands::ingest_browse, commands::ingest_scan, + commands::ingest_sources, commands::ingest_details, commands::ingest_conflicts, commands::ingest_copy, diff --git a/src-tauri/src/multicard.rs b/src-tauri/src/multicard.rs new file mode 100644 index 0000000..069b7d0 --- /dev/null +++ b/src-tauri/src/multicard.rs @@ -0,0 +1,73 @@ +//! Combining independent source cards without guessing from names alone. +use std::{collections::{HashMap, HashSet}, path::{Path, PathBuf}}; +use crate::{raw, scan::{self, Scanned}}; + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +struct Facts { make: String, model: String, time: u64 } + +fn pair(mut images: Vec, facts: impl Fn(&Path) -> Option) -> Vec { + let mut groups: HashMap<(String, Facts), Vec> = HashMap::new(); + for (i, image) in images.iter().enumerate() { + if image.path != image.display || !(raw::is_raw(&image.path) || raw::is_jpeg(&image.path)) { continue; } + let Some(stem) = image.path.file_stem().and_then(|s| s.to_str()) else { continue }; + if let Some(meta) = facts(&image.path) { groups.entry((stem.to_ascii_lowercase(), meta)).or_default().push(i); } + } + let mut remove = HashSet::new(); + for group in groups.values() { + if group.len() != 2 { continue; } + let raws: Vec<_> = group.iter().copied().filter(|i| raw::is_raw(&images[*i].path)).collect(); + let jpegs: Vec<_> = group.iter().copied().filter(|i| raw::is_jpeg(&images[*i].path)).collect(); + if let ([r], [j]) = (raws.as_slice(), jpegs.as_slice()) { + images[*r].display = images[*j].path.clone(); images[*r].version = images[*j].version.clone(); remove.insert(*j); + } + } + images.into_iter().enumerate().filter_map(|(i, image)| (!remove.contains(&i)).then_some(image)).collect() +} + +pub fn scan_sources(sources: &[PathBuf]) -> Result, String> { + let mut seen = HashSet::new(); let mut images = Vec::new(); + for source in sources { + let root = source.canonicalize().map_err(|e| format!("{}: {e}", source.display()))?; + if !root.is_dir() { return Err(format!("{} is not a folder", root.display())); } + for mut image in scan::scan_folder(&root, true).images { + if !seen.insert(image.path.clone()) { continue; } + let label = root.file_name().unwrap_or_default().to_string_lossy(); + image.subpath = Some(match image.subpath { Some(sub) => format!("{label}/{sub}"), None => label.to_string() }); + images.push(image); + } + } + let paired: HashSet<_> = images.iter().filter(|p| p.path != p.display).map(|p| p.display.clone()).collect(); + images.retain(|p| !paired.contains(&p.path)); + Ok(pair(images, |path| { + let e = raw::probe(path).ok()?.exif; + let make = e.make?.trim().to_lowercase(); let model = e.model?.trim().to_lowercase(); + if make.is_empty() || model.is_empty() { return None; } + Some(Facts { make, model, time: e.capture_ms?.to_bits() }) + })) +} + +#[cfg(test)] mod tests { + use super::*; + fn image(path: &str) -> Scanned { Scanned { path: path.into(), display: path.into(), name: Path::new(path).file_name().unwrap().to_string_lossy().into_owned(), subpath: None, version: "1".into(), size: 4, modified: 0 } } + fn facts(_: &Path) -> Option { Some(Facts { make: "Sony".into(), model: "A7".into(), time: 1234 }) } + #[test] fn raw_and_jpeg_on_two_cards_become_one_frame() { + let result = pair(vec![image("/raw/A.ARW"), image("/jpeg/A.JPG")], facts); + assert_eq!(result.len(), 1); assert_eq!(result[0].display, Path::new("/jpeg/A.JPG")); + } + #[test] fn ambiguous_and_missing_metadata_are_never_paired() { + let images = vec![image("/r1/A.ARW"), image("/r2/A.ARW"), image("/j/A.JPG")]; + assert_eq!(pair(images, facts).len(), 3); + assert_eq!(pair(vec![image("/r/A.ARW"), image("/j/A.JPG")], |_| None).len(), 2); + } + #[test] fn different_camera_or_time_is_not_a_match() { + let files = vec![image("/r/A.ARW"), image("/j/A.JPG")]; + assert_eq!(pair(files.clone(), |p| { let mut f = facts(p)?; if raw::is_jpeg(p) { f.time += 1; } Some(f) }).len(), 2); + assert_eq!(pair(files, |p| { let mut f = facts(p)?; if raw::is_jpeg(p) { f.model = "Other".into(); } Some(f) }).len(), 2); + } + #[test] fn overlapping_sources_do_not_duplicate_files() { + let root = std::env::temp_dir().join(format!("opencull-multicard-{}", std::process::id())); + std::fs::create_dir_all(root.join("DCIM")).unwrap(); std::fs::write(root.join("DCIM/A.JPG"), b"fake").unwrap(); + let result = scan_sources(&[root.clone(), root.join("DCIM")]).unwrap(); assert_eq!(result.len(), 1); + assert!(scan_sources(&[root.join("missing")]).is_err()); std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/components/ingest/IngestView.tsx b/src/components/ingest/IngestView.tsx index 1256b9d..df1691f 100644 --- a/src/components/ingest/IngestView.tsx +++ b/src/components/ingest/IngestView.tsx @@ -102,6 +102,8 @@ export function IngestView() { const [children, setChildren] = useState([]); const [browsing, setBrowsing] = useState(false); const [source, setSource] = useState(null); + const [sources, setSources] = useState([]); + const sourceList = useRef([]); const [items, setItems] = useState([]); const [selected, setSelected] = useState>(new Set()); @@ -151,17 +153,20 @@ export function IngestView() { useEffect(() => save("ingestDestination", destination), [destination]); /** Lists the photos in a folder and makes it the one being ingested from. */ - const choose = useCallback((path: string) => { + const scanSources = useCallback((paths: string[]) => { + sourceList.current = paths; setSources(paths); + const path = paths[paths.length - 1] ?? null; const mine = ++listing.current; setSource(path); setSelected(new Set()); anchor.current = 0; setItems([]); setPhase({ is: "scanning" }); - void api.ingestScan(path).then( + void api.ingestSources(paths).then( (found) => { if (listing.current !== mine) return; setItems(found.map((f) => ({ ...f, captureMs: null, rating: 0 }))); + setSelected(new Set(found.map((_, i) => i))); setPhase({ is: "ready" }); // Capture times and in-camera stars are read afterwards, so the // card's photos are on screen without waiting for either. @@ -180,6 +185,8 @@ export function IngestView() { ); }, []); + const choose = useCallback((path: string) => scanSources([...new Set([...sourceList.current, path])]), [scanSources]); + /** Shows the folders inside `path`, one level further into the browser. */ const goInto = useCallback((path: string, title: string, known?: IngestFolder[]) => { setLevels((l) => [...l, { path, title }]); @@ -398,7 +405,7 @@ export function IngestView() {
- Ingest from Card + Ingest from Card · {sources.length} source{sources.length === 1 ? "" : "s"}
)} +
{current ? (
diff --git a/src/components/cull/Loupe.tsx b/src/components/cull/Loupe.tsx index 9cb0bd6..a749ef5 100644 --- a/src/components/cull/Loupe.tsx +++ b/src/components/cull/Loupe.tsx @@ -1,3 +1,5 @@ +import { isVideo } from "@/lib/media"; +import { VideoPlayer } from "./VideoPlayer"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Flag, Loader2, X } from "lucide-react"; @@ -125,7 +127,7 @@ export function Loupe() { return (
- {photo && cropping ? ( + {photo && isVideo(photo.path) ? : photo && cropping ? ( ) : photo ? ( @@ -620,7 +622,7 @@ function Canvas({ photo }: { photo: Photo }) { if (at + d < view.length) near.push(view[at + d]); if (d <= BEHIND && at - d >= 0) near.push(view[at - d]); } - void api.prefetch(near.map((i) => ({ path: photos[i].display, version: photos[i].version })), fitSize); + void api.prefetch(near.filter((i) => !isVideo(photos[i].path)).map((i) => ({ path: photos[i].display, version: photos[i].version })), fitSize); }); return () => cancelAnimationFrame(handle); }, [photo.index, photo.display, photo.version, fitSize, box.w]); diff --git a/src/components/cull/Thumb.tsx b/src/components/cull/Thumb.tsx index 0611790..5d9e06d 100644 --- a/src/components/cull/Thumb.tsx +++ b/src/components/cull/Thumb.tsx @@ -1,3 +1,5 @@ +import { Film } from "lucide-react"; +import { isVideo } from "@/lib/media"; import { memo, useEffect, useState, type CSSProperties } from "react"; import { Crop, Flag, X } from "lucide-react"; @@ -15,7 +17,12 @@ import type { Photo } from "@/lib/types"; * a folder closed — and a cell that gave up at the first refusal would stay * blank for as long as it was on screen. */ -export function ThumbImage({ photo, size, className, style }: { photo: Pick; size: number; className?: string; style?: CSSProperties }) { +export function ThumbImage(props: { photo: Pick; size: number; className?: string; style?: CSSProperties }) { + if (isVideo(props.photo.display)) return
; + return ; +} + +function StillThumbImage({ photo, size, className, style }: { photo: Pick; size: number; className?: string; style?: CSSProperties }) { const [attempt, setAttempt] = useState(0); const [failed, setFailed] = useState(false); diff --git a/src/components/cull/VideoPlayer.tsx b/src/components/cull/VideoPlayer.tsx new file mode 100644 index 0000000..a0291de --- /dev/null +++ b/src/components/cull/VideoPlayer.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { inTauri } from "@/lib/api"; + +export function VideoPlayer({ path }: { path: string }) { + const [url, setUrl] = useState(""); + const [error, setError] = useState(""); + useEffect(() => { + let stale = false; setUrl(""); setError(""); + if (!inTauri) { setError("Video playback is available in the desktop application."); return; } + void invoke("video_source", { path }).then((file) => { if (!stale) setUrl(convertFileSrc(file)); }, (e) => { if (!stale) setError(String(e)); }); + return () => { stale = true; }; + }, [path]); + return
e.stopPropagation()}> + {url && !error &&
; +} diff --git a/src/components/ingest/IngestView.tsx b/src/components/ingest/IngestView.tsx index 1256b9d..dda9f69 100644 --- a/src/components/ingest/IngestView.tsx +++ b/src/components/ingest/IngestView.tsx @@ -17,6 +17,7 @@ import { ThumbImage } from "@/components/cull/Thumb"; import { api, bucket, THUMB_SIZES, type IngestFolder, type IngestPolicy, type IngestProgress, type Volume } from "@/lib/api"; import { load, save } from "@/lib/prefs"; import { isMac } from "@/lib/shortcuts"; +import { Switch } from "@/components/ui/switch"; import { useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; import type { Scanned } from "@/lib/types"; @@ -94,6 +95,7 @@ const SELECTED_ROW = "bg-[#4073f2]/45"; * and the destination opens for culling. */ export function IngestView() { + const ignoreVideos = useStore((s) => s.ignoreVideos); const startAt = useStore((s) => s.ingestFrom); const platform = useStore((s) => s.platform); @@ -158,7 +160,7 @@ export function IngestView() { anchor.current = 0; setItems([]); setPhase({ is: "scanning" }); - void api.ingestScan(path).then( + void api.ingestScan(path, ignoreVideos).then( (found) => { if (listing.current !== mine) return; setItems(found.map((f) => ({ ...f, captureMs: null, rating: 0 }))); @@ -178,7 +180,8 @@ export function IngestView() { }, (e) => listing.current === mine && setPhase({ is: "failed", message: String(e) }), ); - }, []); + }, [ignoreVideos]); + useEffect(() => { if (source) choose(source); }, [ignoreVideos]); /** Shows the folders inside `path`, one level further into the browser. */ const goInto = useCallback((path: string, title: string, known?: IngestFolder[]) => { @@ -195,7 +198,7 @@ export function IngestView() { } setChildren([]); setBrowsing(true); - void api.ingestBrowse(path).then( + void api.ingestBrowse(path, ignoreVideos).then( (found) => { if (browse.current !== mine) return; setChildren(found.folders); @@ -220,7 +223,7 @@ export function IngestView() { if (isCameraMedia(folder.name)) return choose(folder.path); const mine = ++browse.current; setBrowsing(true); - void api.ingestBrowse(folder.path).then( + void api.ingestBrowse(folder.path, ignoreVideos).then( (found) => { if (browse.current !== mine) return; setBrowsing(false); @@ -527,6 +530,7 @@ export function IngestView() { {/* Where to. */}
From 06fce9244865e4eaac71aae404f8e3036c310e56 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:09:16 +0700 Subject: [PATCH 15/71] fix: stream selected Linux videos over bounded authenticated loopback HTTP --- docs/features/video-culling.md | 2 +- src-tauri/src/commands.rs | 5 +- src-tauri/src/lib.rs | 2 + src-tauri/src/video_stream.rs | 107 ++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- src/components/cull/VideoPlayer.tsx | 4 +- 6 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/video_stream.rs diff --git a/docs/features/video-culling.md b/docs/features/video-culling.md index cc5af52..17bcaec 100644 --- a/docs/features/video-culling.md +++ b/docs/features/video-culling.md @@ -2,7 +2,7 @@ Ignore videos is enabled initially, in Settings → Culling and the card browser. Disable it to list camera video containers alongside photos, keep/reject/rate them, copy them, and play supported codecs in the loupe. Native playback controls provide pause, seek, and volume; their keyboard events stay with the player. Unsupported codecs show a message and remain selectable for import. No transcoding or codec downloads are performed. -Playback uses Tauri's built-in range-capable asset protocol, with an empty initial scope. Only a video already belonging to an open session is granted individual file access. Videos use a thumbnail icon and skip image preview warming, histogram processing, and face analysis. Normal folder/video sidecars follow existing mark behavior; integration with source-safe card review is separate. +Playback uses a bounded, token-protected loopback stream on Linux because WebKitGTK's media backend rejects custom asset schemes. Other platforms use Tauri's range-capable asset protocol, with an empty initial scope. Only a video already belonging to an open session is granted individual file access. Videos use a thumbnail icon and skip image preview warming, histogram processing, and face analysis. Normal folder/video sidecars follow existing mark behavior; integration with source-safe card review is separate. Linux playback depends on system WebKitGTK/GStreamer codec support. Formats are recognized as containers, not promises that every codec inside them will decode. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7ef1b44..66486f2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1107,5 +1107,8 @@ pub fn video_source(handle: tauri::AppHandle, app: AppState, path: String) -> Re let sessions = app.sessions.lock().map_err(|e| e.to_string())?; if !sessions.values().any(|s| s.images.iter().any(|p| p.path.canonicalize().ok().as_ref() == Some(&requested))) { return Err("Video is not in the selected session".into()); } handle.asset_protocol_scope().allow_file(&requested).map_err(|e| e.to_string())?; - Ok(requested.to_string_lossy().into_owned()) + #[cfg(target_os = "linux")] + { crate::video_stream::url(requested) } + #[cfg(not(target_os = "linux"))] + { Ok(requested.to_string_lossy().into_owned()) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b91750a..16217df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,8 @@ pub mod times; pub mod transfer; pub mod update; pub mod xmp; +#[cfg(target_os = "linux")] +pub mod video_stream; use std::sync::Arc; diff --git a/src-tauri/src/video_stream.rs b/src-tauri/src/video_stream.rs new file mode 100644 index 0000000..fd3f564 --- /dev/null +++ b/src-tauri/src/video_stream.rs @@ -0,0 +1,107 @@ +//! Linux WebKit's media backend cannot open Tauri custom schemes. Serve only +//! explicitly selected videos over a token-protected loopback stream instead. +use std::{collections::HashMap, fs::File, io::{Read, Seek, SeekFrom, Write}, net::{TcpListener, TcpStream}, path::PathBuf, sync::{Arc, Mutex, OnceLock, mpsc}}; +type Files = Arc>>; +struct Server { port: u16, secret: String, files: Files } +static SERVER: OnceLock> = OnceLock::new(); + +pub fn url(path: PathBuf) -> Result { + let server = SERVER.get_or_init(start).as_ref().map_err(Clone::clone)?; + let key = format!("{:x}", xxhash_rust::xxh3::xxh3_64(path.to_string_lossy().as_bytes())); + server.files.lock().map_err(|e| e.to_string())?.insert(key.clone(), path); + Ok(format!("http://127.0.0.1:{}/{}/{key}", server.port, server.secret)) +} + +fn start() -> Result { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).map_err(|e| e.to_string())?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + let mut random = [0u8; 32]; File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut random)).map_err(|e| e.to_string())?; + let secret: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let files: Files = Default::default(); + let (send, receive) = mpsc::sync_channel::(8); + let receive = Arc::new(Mutex::new(receive)); + for n in 0..4 { + let (receive, files, secret) = (receive.clone(), files.clone(), secret.clone()); + std::thread::Builder::new().name(format!("opencull-video-{n}")).spawn(move || loop { + let stream = receive.lock().ok().and_then(|r| r.recv().ok()); + let Some(mut stream) = stream else { break }; + let _ = respond(&mut stream, &files, &secret); + }).map_err(|e| e.to_string())?; + } + std::thread::Builder::new().name("opencull-video-listener".into()).spawn(move || { + for stream in listener.incoming().flatten() { let _ = send.try_send(stream); } + }).map_err(|e| e.to_string())?; + Ok(Server { port, secret, files }) +} + +fn range(value: Option<&str>, len: u64) -> Option<(u64, u64)> { + let value = value?.strip_prefix("bytes=")?; + if len == 0 || value.contains(',') { return None; } + let (start, end) = value.split_once('-')?; + if start.is_empty() { + let count: u64 = end.parse().ok()?; if count == 0 { return None; } + return Some((len.saturating_sub(count), len - 1)); + } + let start = start.parse::().ok()?; + let end = if end.is_empty() { len - 1 } else { end.parse::().ok()?.min(len - 1) }; + (start < len && end >= start).then_some((start, end)) +} +fn error(stream: &mut TcpStream, status: &str) -> std::io::Result<()> { + write!(stream, "HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") +} +fn respond(stream: &mut TcpStream, files: &Files, secret: &str) -> std::io::Result<()> { + stream.set_read_timeout(Some(std::time::Duration::from_secs(3)))?; + stream.set_write_timeout(Some(std::time::Duration::from_secs(15)))?; + let mut header = Vec::new(); + let mut byte = [0]; + while header.len() < 8192 && !header.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte)? == 0 { return Ok(()); } header.push(byte[0]); + } + if !header.ends_with(b"\r\n\r\n") { return error(stream, "431 Request Header Fields Too Large"); } + let text = String::from_utf8_lossy(&header); + let mut lines = text.split("\r\n"); let mut request = lines.next().unwrap_or_default().split_whitespace(); + let method = request.next().unwrap_or_default(); let uri = request.next().unwrap_or_default(); + if method != "GET" && method != "HEAD" { return error(stream, "405 Method Not Allowed"); } + let prefix = format!("/{secret}/"); + let Some(key) = uri.split('?').next().unwrap_or_default().strip_prefix(&prefix) else { return error(stream, "404 Not Found"); }; + let path = files.lock().ok().and_then(|f| f.get(key).cloned()); + let Some(path) = path else { return error(stream, "404 Not Found"); }; + let Ok(mut file) = File::open(&path) else { return error(stream, "404 Not Found"); }; + let len = file.metadata()?.len(); + let wanted = lines.find_map(|line| line.split_once(':').filter(|(k, _)| k.eq_ignore_ascii_case("range")).map(|(_, v)| v.trim())); + let (start, end) = if wanted.is_some() { + let Some(bounds) = range(wanted, len) else { + return write!(stream, "HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */{len}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }; bounds + } else { (0, len.saturating_sub(1)) }; + let count = if len == 0 { 0 } else { end - start + 1 }; + let mime = match crate::raw::extension(&path).as_str() { "mp4" | "m4v" => "video/mp4", "mov" => "video/quicktime", "webm" => "video/webm", "mkv" => "video/x-matroska", "mts" | "m2ts" => "video/mp2t", "avi" => "video/x-msvideo", "3gp" => "video/3gpp", _ => "video/mpeg" }; + let status = if wanted.is_some() { "206 Partial Content" } else { "200 OK" }; + write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {mime}\r\nContent-Length: {count}\r\nAccept-Ranges: bytes\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n")?; + if wanted.is_some() { write!(stream, "Content-Range: bytes {start}-{end}/{len}\r\n")?; } + write!(stream, "\r\n")?; + if method == "GET" { file.seek(SeekFrom::Start(start))?; std::io::copy(&mut file.take(count), stream)?; } + Ok(()) +} + +#[cfg(test)] mod tests { + use super::*; + #[test] fn valid_and_invalid_ranges() { + assert_eq!(range(Some("bytes=2-5"), 10), Some((2,5))); + assert_eq!(range(Some("bytes=7-"), 10), Some((7,9))); + assert_eq!(range(Some("bytes=-3"), 10), Some((7,9))); + assert_eq!(range(Some("bytes=0-999"), 10), Some((0,9))); + for value in ["bytes=10-", "bytes=5-2", "bytes=-0", "bytes=1-2,4-5", "bad"] { assert_eq!(range(Some(value),10),None); } + } + #[test] fn loopback_requires_token_and_serves_selected_byte_ranges() { + let path = std::env::temp_dir().join(format!("opencull-stream-{}.mp4",std::process::id())); std::fs::write(&path,b"0123456789").unwrap(); + let files = Arc::new(Mutex::new(HashMap::from([("file".into(),path.clone())]))); + let listener=TcpListener::bind("127.0.0.1:0").unwrap(); let addr=listener.local_addr().unwrap(); + let worker=std::thread::spawn(move || { for _ in 0..3 { let (mut stream,_) = listener.accept().unwrap(); respond(&mut stream,&files,"secret").unwrap(); } }); + let request=|url:&str,range:&str| { let mut stream=TcpStream::connect(addr).unwrap(); write!(stream,"GET {url} HTTP/1.1\r\nHost: localhost\r\n{range}\r\n").unwrap(); let mut out=String::new(); stream.read_to_string(&mut out).unwrap(); out }; + let partial=request("/secret/file","Range: bytes=2-5\r\n"); assert!(partial.starts_with("HTTP/1.1 206")); assert!(partial.ends_with("2345")); + assert!(request("/wrong/file", "").starts_with("HTTP/1.1 404")); + assert!(request("/secret/file","Range: bytes=99-\r\n").starts_with("HTTP/1.1 416")); + worker.join().unwrap(); std::fs::remove_file(path).unwrap(); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index de64485..3201bf8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,7 +32,7 @@ "style-src": "'self' 'unsafe-inline'", "font-src": "'self' data:", "connect-src": "'self' ipc: http://ipc.localhost", - "media-src": "'self' asset: http://asset.localhost blob:" + "media-src": "'self' asset: http://asset.localhost http://127.0.0.1:* blob:" }, "devCsp": null, "assetProtocol": { diff --git a/src/components/cull/VideoPlayer.tsx b/src/components/cull/VideoPlayer.tsx index a0291de..ab45743 100644 --- a/src/components/cull/VideoPlayer.tsx +++ b/src/components/cull/VideoPlayer.tsx @@ -8,12 +8,12 @@ export function VideoPlayer({ path }: { path: string }) { useEffect(() => { let stale = false; setUrl(""); setError(""); if (!inTauri) { setError("Video playback is available in the desktop application."); return; } - void invoke("video_source", { path }).then((file) => { if (!stale) setUrl(convertFileSrc(file)); }, (e) => { if (!stale) setError(String(e)); }); + void invoke("video_source", { path }).then((file) => { if (!stale) setUrl(file.startsWith("http://127.0.0.1:") ? file : convertFileSrc(file)); }, (e) => { if (!stale) setError(String(e)); }); return () => { stale = true; }; }, [path]); return
e.stopPropagation()}> {url && !error &&
; From 48a63866b3ac17ad231c9dbc04e2ac91dd40672c Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:09:18 +0700 Subject: [PATCH 16/71] test: exercise integrated Linux settings and card review in the native app --- src/lib/linux-features.selftest.ts | 86 ++++++++++++++++++++++++++++++ src/lib/selftest.ts | 1 + 2 files changed, 87 insertions(+) create mode 100644 src/lib/linux-features.selftest.ts diff --git a/src/lib/linux-features.selftest.ts b/src/lib/linux-features.selftest.ts new file mode 100644 index 0000000..32fb638 --- /dev/null +++ b/src/lib/linux-features.selftest.ts @@ -0,0 +1,86 @@ +// Integration-only native checks. All copies land in the self-test scratch folder. +import { invoke, convertFileSrc } from "@tauri-apps/api/core"; +import { api } from "./api"; +import { isVideo } from "./media"; +import { newReview } from "./review"; +import { copyReview, loadReview, saveReview } from "./review-api"; +import { useStore } from "./store"; + +const assert = (ok: unknown, why: string) => { if (!ok) throw new Error(why); }; +const say = (s: string) => api.report(`features: ${s}`); +async function until(check: () => boolean, message: string, limit = 10_000) { + const start = performance.now(); + while (!check()) { if (performance.now() - start > limit) throw new Error(message); await api.pause(20); } +} + +export async function checkLinuxFeatures(folder: string, scratch: string) { + const s = useStore.getState; + const configured = await invoke>("config_load", { legacy: {} }); + assert(configured && typeof configured === "object", "Native config did not load"); + if (typeof configured.theme === "string") assert(document.documentElement.dataset.theme === configured.theme, "Theme did not hydrate before startup"); + await document.fonts.load('14px "Space Grotesk"'); + assert(document.fonts.check('14px "Space Grotesk"'), "Offline Space Grotesk did not load"); + say("native configuration and offline font loaded"); + + const items = await api.ingestSources([folder, folder], false); + assert(new Set(items.map((p) => p.path)).size === items.length, "Overlapping sources duplicated a file"); + const stills = items.filter((p) => !isVideo(p.path)); + assert(stills.length >= 2, "Feature checks need two still images"); + const before = await api.pathsExist(stills.map((p) => p.path.replace(/\.[^./\\]+$/, ".xmp"))); + const review = newReview([folder], stills.slice(0, 2)); + await s().openFolder(folder, review); + await until(() => s().metaProgress >= 1, "Card metadata did not finish"); + s().setFlag("reject", [0]); s().setRating(4, [1]); + await saveReview(s().review!); + const restored = await loadReview(); + assert(restored?.decisions[stills[0].path].flag === "reject", "Local rejection did not persist"); + assert(restored?.decisions[stills[1].path].rating === 4, "Local rating did not persist"); + const copied = await copyReview([s().photos[1]], scratch, [folder], () => {}); + assert(copied.failed.length === 0 && copied.sidecarFailed.length === 0, "Selected card copy failed"); + assert(Object.keys(copied.completed).length === 1, "Import copied the wrong membership"); + const after = await api.pathsExist(stills.map((p) => p.path.replace(/\.[^./\\]+$/, ".xmp"))); + assert(JSON.stringify(before) === JSON.stringify(after), "Review wrote a sidecar onto the source"); + const chosen = { ...s().review!, items: [stills[1]], chosen: [stills[1].path], stage: "in-place" as const }; + await s().openFolder(folder, chosen); + await until(() => s().metaProgress >= 1, "In-place metadata did not finish"); + assert(s().photos.length === 1 && s().photos[0].rating === 4, "In-place import did not preserve selected membership and marks"); + say("card review, local decisions, selected copying, and in-place membership passed"); + + s().set("settingsOpen", true); + await until(() => !!document.querySelector('select[aria-label="Theme"]'), "Appearance settings did not render"); + const theme = document.querySelector('select[aria-label="Theme"]')!; + const original = theme.value; theme.value = "light"; theme.dispatchEvent(new Event("change", { bubbles: true })); + await until(() => document.documentElement.dataset.theme === "light", "Theme control did not apply"); + theme.value = original; theme.dispatchEvent(new Event("change", { bubbles: true })); + const layout = [...document.querySelectorAll('[role="tab"]')].find((tab) => tab.textContent?.includes("Layout")); + assert(layout, "Layout tab missing"); layout!.focus(); layout!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + await until(() => !!document.querySelector('[aria-label="Face panel width"]'), "Layout size controls missing"); + s().set("settingsOpen", false); + say("settings render and theme interaction passed"); + + const video = items.find((p) => p.path.toLowerCase().endsWith(".mp4")); + if (video) { + s().set("ignoreVideos", false); + await s().openFolder(folder, newReview([folder], [video])); + await until(() => s().metaProgress >= 1, "Video metadata did not finish"); + const allowed = await invoke("video_source", { path: video.path }); + const probeUrl = convertFileSrc(allowed); + const response = await fetch(probeUrl); + const blob = await response.blob(); + say(`video delivery ${response.status}, ${response.headers.get("content-type")}, ${blob.size} bytes; native source ${probeUrl}`); + const player = document.createElement("video"); + say(`codec support MP4=${player.canPlayType('video/mp4; codecs="avc1.64001e"')} WebM=${player.canPlayType('video/webm; codecs="vp8"')}`); player.muted = true; player.preload = "auto"; player.src = URL.createObjectURL(blob); document.body.append(player); + try { + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => reject(new Error("Video metadata timed out")), 10_000); + player.onloadedmetadata = () => { clearTimeout(timeout); resolve(); }; + player.onerror = () => { clearTimeout(timeout); reject(new Error(`System could not decode the MP4 fixture (${player.error?.message})`)); }; + }); + assert(player.duration > 0, "Video has no duration"); + player.currentTime = Math.min(1, player.duration / 2); + await until(() => !player.seeking, "Video seeking failed"); + await player.play(); await api.pause(200); player.pause(); assert(player.currentTime > 0, "Video did not advance"); + say(`native video metadata, seeking and playback passed (${player.duration.toFixed(1)}s)`); + } finally { player.pause(); player.removeAttribute("src"); player.load(); player.remove(); } + } else say("video fixture absent; native playback check skipped"); +} diff --git a/src/lib/selftest.ts b/src/lib/selftest.ts index 5c4ff5f..bcf8d90 100644 --- a/src/lib/selftest.ts +++ b/src/lib/selftest.ts @@ -407,6 +407,7 @@ export async function runSelfTest(folder: string): Promise { say(`navigation ${moved ? "moves" : "DOES NOT MOVE"}; histogram ${(await api.histogram(sample[0].display, sample[0].version)).luma.length === 256 ? "ok" : "BAD"}`); if (!moved) throw new Error("the arrow keys' action did not change the photo"); + await (await import("./linux-features.selftest")).checkLinuxFeatures(folder, scratch); say("PASSED"); api.finishSelfTest(true); } catch (e) { From 05a722ab0f464e549d7d4c49f5ad3aeb3e3513d5 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:10:00 +0700 Subject: [PATCH 17/71] test: validate direct Linux video streaming and seeking --- src/lib/linux-features.selftest.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/linux-features.selftest.ts b/src/lib/linux-features.selftest.ts index 32fb638..7a24d1f 100644 --- a/src/lib/linux-features.selftest.ts +++ b/src/lib/linux-features.selftest.ts @@ -64,12 +64,8 @@ export async function checkLinuxFeatures(folder: string, scratch: string) { await s().openFolder(folder, newReview([folder], [video])); await until(() => s().metaProgress >= 1, "Video metadata did not finish"); const allowed = await invoke("video_source", { path: video.path }); - const probeUrl = convertFileSrc(allowed); - const response = await fetch(probeUrl); - const blob = await response.blob(); - say(`video delivery ${response.status}, ${response.headers.get("content-type")}, ${blob.size} bytes; native source ${probeUrl}`); const player = document.createElement("video"); - say(`codec support MP4=${player.canPlayType('video/mp4; codecs="avc1.64001e"')} WebM=${player.canPlayType('video/webm; codecs="vp8"')}`); player.muted = true; player.preload = "auto"; player.src = URL.createObjectURL(blob); document.body.append(player); + say(`codec support MP4=${player.canPlayType('video/mp4; codecs="avc1.64001e"')} WebM=${player.canPlayType('video/webm; codecs="vp8"')}`); player.muted = true; player.preload = "auto"; player.src = allowed.startsWith("http://127.0.0.1:") ? allowed : convertFileSrc(allowed); document.body.append(player); try { await new Promise((resolve, reject) => { const timeout = window.setTimeout(() => reject(new Error("Video metadata timed out")), 10_000); From 7f90224d830128fd799d9378f8d76467350e1325 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:12:20 +0700 Subject: [PATCH 18/71] fix: preserve contrast for light-theme folder cards and primary actions --- src/components/home/HomeView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/home/HomeView.tsx b/src/components/home/HomeView.tsx index c3567f5..2031e18 100644 --- a/src/components/home/HomeView.tsx +++ b/src/components/home/HomeView.tsx @@ -116,7 +116,7 @@ export function HomeView({ dropping }: { dropping: boolean }) { @@ -150,7 +150,7 @@ export function HomeView({ dropping }: { dropping: boolean }) {
Open a folder to start culling

Choose a folder of photos, or drop one anywhere on this window. RAW files open straight from the previews your camera already made.

-
@@ -243,7 +243,7 @@ function FolderCard({ >
{missing ? : }
-
+
{missing && } {baseName(path)}
From 5587a075eb1eef69446b7f3af5deda79258885ad Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:12:21 +0700 Subject: [PATCH 19/71] test: cover separate RAW and JPEG cards in the native integration checks --- src/lib/linux-features.selftest.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/linux-features.selftest.ts b/src/lib/linux-features.selftest.ts index 7a24d1f..0c1e211 100644 --- a/src/lib/linux-features.selftest.ts +++ b/src/lib/linux-features.selftest.ts @@ -22,26 +22,28 @@ export async function checkLinuxFeatures(folder: string, scratch: string) { assert(document.fonts.check('14px "Space Grotesk"'), "Offline Space Grotesk did not load"); say("native configuration and offline font loaded"); - const items = await api.ingestSources([folder, folder], false); + const sources = Array.isArray(configured.selfTestSources) && configured.selfTestSources.every((p) => typeof p === "string") ? configured.selfTestSources as string[] : [folder]; + const items = await api.ingestSources([...sources, sources[0]], false); + if (sources.length > 1) assert(items.some((p) => p.path !== p.display), "Separate RAW/JPEG cards did not produce paired frames"); assert(new Set(items.map((p) => p.path)).size === items.length, "Overlapping sources duplicated a file"); const stills = items.filter((p) => !isVideo(p.path)); assert(stills.length >= 2, "Feature checks need two still images"); const before = await api.pathsExist(stills.map((p) => p.path.replace(/\.[^./\\]+$/, ".xmp"))); - const review = newReview([folder], stills.slice(0, 2)); - await s().openFolder(folder, review); + const review = newReview(sources, stills.slice(0, 2)); + await s().openFolder(sources[0], review); await until(() => s().metaProgress >= 1, "Card metadata did not finish"); s().setFlag("reject", [0]); s().setRating(4, [1]); await saveReview(s().review!); const restored = await loadReview(); assert(restored?.decisions[stills[0].path].flag === "reject", "Local rejection did not persist"); assert(restored?.decisions[stills[1].path].rating === 4, "Local rating did not persist"); - const copied = await copyReview([s().photos[1]], scratch, [folder], () => {}); + const copied = await copyReview([s().photos[1]], scratch, sources, () => {}); assert(copied.failed.length === 0 && copied.sidecarFailed.length === 0, "Selected card copy failed"); assert(Object.keys(copied.completed).length === 1, "Import copied the wrong membership"); const after = await api.pathsExist(stills.map((p) => p.path.replace(/\.[^./\\]+$/, ".xmp"))); assert(JSON.stringify(before) === JSON.stringify(after), "Review wrote a sidecar onto the source"); const chosen = { ...s().review!, items: [stills[1]], chosen: [stills[1].path], stage: "in-place" as const }; - await s().openFolder(folder, chosen); + await s().openFolder(sources[0], chosen); await until(() => s().metaProgress >= 1, "In-place metadata did not finish"); assert(s().photos.length === 1 && s().photos[0].rating === 4, "In-place import did not preserve selected membership and marks"); say("card review, local decisions, selected copying, and in-place membership passed"); @@ -61,7 +63,7 @@ export async function checkLinuxFeatures(folder: string, scratch: string) { const video = items.find((p) => p.path.toLowerCase().endsWith(".mp4")); if (video) { s().set("ignoreVideos", false); - await s().openFolder(folder, newReview([folder], [video])); + await s().openFolder(sources[0], newReview(sources, [video])); await until(() => s().metaProgress >= 1, "Video metadata did not finish"); const allowed = await invoke("video_source", { path: video.path }); const player = document.createElement("video"); From 5fad15b759eee85067620487a38995430994ab20 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:14:27 +0700 Subject: [PATCH 20/71] fix: respect an empty card selection before starting review --- src/components/ingest/IngestView.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/ingest/IngestView.tsx b/src/components/ingest/IngestView.tsx index ddf9ec5..df6c19a 100644 --- a/src/components/ingest/IngestView.tsx +++ b/src/components/ingest/IngestView.tsx @@ -126,6 +126,7 @@ export function IngestView() { (found) => { if (listing.current !== mine) return; setItems(found.map((f) => ({ ...f, captureMs: null, rating: 0 }))); + setSelected(new Set(found.map((_, i) => i))); setPhase({ is: "ready" }); // Capture times and in-camera stars are read afterwards, so the // card's photos are on screen without waiting for either. @@ -276,12 +277,12 @@ export function IngestView() { // ── Copying ─────────────────────────────────────────────────────────────── - const canIngest = items.length > 0 && !busy; + const canIngest = selected.size > 0 && !busy; const requestIngest = async () => { if (!canIngest || !source) return; close(); - const chosen = selected.size ? order.filter((i) => selected.has(i)).map((i) => items[i]) : items; + const chosen = order.filter((i) => selected.has(i)).map((i) => items[i]); await useStore.getState().openFolder(source, newReview([source], chosen)); }; From 6c687942f050729435690ab5cdb80c335e7674b7 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:16:54 +0700 Subject: [PATCH 21/71] docs: record feature review units and native Linux verification --- docs/features/VALIDATION.md | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/features/VALIDATION.md diff --git a/docs/features/VALIDATION.md b/docs/features/VALIDATION.md new file mode 100644 index 0000000..eb9d8d4 --- /dev/null +++ b/docs/features/VALIDATION.md @@ -0,0 +1,49 @@ +# Linux feature integration validation + +The seven feature branches start independently at upstream release 0.4.1. Each has its own fork PR; none requires the others. `integration/linux-features` combines them and resolves their overlapping settings, session, and ingest interfaces. General Node/toolchain fixes are committed directly to fork `main` and merged into integration. + +## Review units + +| Change | Branch | Fork PR | +|---|---|---| +| Agent workflow | `docs/agent-workflow` | [#1](https://github.com/wispdevon/OpenCull/pull/1) | +| Workspace controls | `feature/configurable-ui` | [#2](https://github.com/wispdevon/OpenCull/pull/2) | +| Offline fonts | `feature/font-selection` | [#3](https://github.com/wispdevon/OpenCull/pull/3) | +| Dark/light/18% grey | `feature/appearance-modes` | [#4](https://github.com/wispdevon/OpenCull/pull/4) | +| XDG configuration | `feature/xdg-config` | [#5](https://github.com/wispdevon/OpenCull/pull/5) | +| Cull before import | `feature/cull-before-ingest` | [#6](https://github.com/wispdevon/OpenCull/pull/6) | +| Multi-card sources | `feature/multi-card-session` | [#7](https://github.com/wispdevon/OpenCull/pull/7) | +| Video playback | `feature/video-culling` | [#8](https://github.com/wispdevon/OpenCull/pull/8) | + +## Verified locally + +- Toolchain: Node 22.23.3, npm 11.21.1. Clean `npm ci`, then `npm run check`: 46 frontend tests, frontend production build, and 283 Rust library tests pass on the combined branch. Fork main separately passes its 31 frontend tests, production build, and 268 Rust tests. +- Desktop: `npm run build:desktop -- --debug --no-bundle` succeeds on Linux with libjpeg-turbo enabled. +- Native self-test: opens files, loads previews, transfers without changing originals, and exercises the new features through real Tauri IPC and the existing store. +- Separate synthetic RAW/JPEG cards pair into shared entries. Local marks survive save/load; reviewing creates no source sidecars. Selected-only copying includes companions, and import in place retains only selected members and their marks. +- MP4 metadata, playback, pause, and seeking work on Linux. WebKitGTK cannot play the custom `asset:` scheme directly, even with a working H.264 decoder. The Linux feature uses a bounded, token-protected loopback HTTP stream, with range/authorization tests. Other systems retain Tauri's scoped asset protocol. +- Offline Space Grotesk loads. Theme controls change the actual root theme; dark/light/grey appearances and layout controls render. Native screenshots were inspected outside the repository. +- A malformed config remains byte-for-byte intact and displays a recovery banner while the app remains usable. An unwritable config root creates no partial file and displays the same recovery path. +- SHA-256 hashes and file membership of all nine synthetic source files remain unchanged after native checks. Test photos, videos, snapshots, and session data are outside the repository. + +## Reproducing native feature checks + +Use an isolated XDG config/data/cache directory. The combined native `--selftest ` runs baseline checks and the additional integration checks. Supply two or more still images; include a playable MP4 to test video. An optional `selfTestSources` array in the temporary config can point at separate RAW and JPEG source folders to verify cross-card pairing. This option is read only by the integration self-test, not normal application flows. + +The browser plugin reported `Browser is not available: iab`; rendered validation used the real Linux GTK/WebKit application under a private X display instead. No external browser or existing user browser session was controlled. + +These fixtures validate session, pairing, copying, persistence, and media delivery. They are synthetic previews with RAW filenames, not real sensor-data captures. Original RAW parsing retains its existing Rust tests. Physical card-reader behavior and macOS/Windows runtime behavior still need reports from those systems; the fork's cross-platform build is separate from local Linux validation. + +## Personal configuration + +`$XDG_CONFIG_HOME/opencull/config.json` (normally `~/.config/opencull/config.json`): + +```json +{ + "theme": "grey", + "font": { "choice": "space-grotesk", "family": "" }, + "ignoreVideos": true +} +``` + +Existing panel defaults remain unchanged. These are personal values; standalone feature branches preserve the original first-run defaults. From fc0e4231136698c936b5c25b4de95be6bf15f863 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:28:11 +0700 Subject: [PATCH 22/71] feat: add a RapidRAW inspired welcome theme with offline typography --- docs/features/rapidraw-theme.md | 29 ++++ index.html | 1 + public/fonts/SpaceGrotesk-OFL.txt | 93 ++++++++++++ public/fonts/SpaceGrotesk.ttf | Bin 0 -> 136676 bytes public/theme-landscape.svg | 14 ++ src/App.tsx | 2 + src/components/VisualThemeSettings.tsx | 60 ++++++++ src/components/cull/Dialogs.tsx | 2 + src/components/home/HomeView.tsx | 41 +++++- src/lib/visual-theme.test.ts | 17 +++ src/lib/visual-theme.ts | 12 ++ src/visual-theme.css | 193 +++++++++++++++++++++++++ 12 files changed, 457 insertions(+), 7 deletions(-) create mode 100644 docs/features/rapidraw-theme.md create mode 100644 public/fonts/SpaceGrotesk-OFL.txt create mode 100644 public/fonts/SpaceGrotesk.ttf create mode 100644 public/theme-landscape.svg create mode 100644 src/components/VisualThemeSettings.tsx create mode 100644 src/lib/visual-theme.test.ts create mode 100644 src/lib/visual-theme.ts create mode 100644 src/visual-theme.css diff --git a/docs/features/rapidraw-theme.md b/docs/features/rapidraw-theme.md new file mode 100644 index 0000000..7b0a00e --- /dev/null +++ b/docs/features/rapidraw-theme.md @@ -0,0 +1,29 @@ +# RapidRAW inspired design preset + +Branch: `feature/rapidraw-theme`, based directly on upstream `a8c7f32`. +No other feature branch is required. Select **Settings → Culling → Design theme +→ RapidRAW inspired**. OpenCull's existing design remains the default. + +The reference is RapidRAW's first library screen at +[`MainLibrary.tsx`](https://github.com/CyberTimon/RapidRAW/blob/e90a9bba7422ed833892e4945f06da35a65470d2/src/components/panel/MainLibrary.tsx): +a split image/action layout, prominent identity, neutral surfaces, and direct +session actions. This is an original implementation using OpenCull's existing +folder, settings, keyboard, card detection, and recent-folder controls. +No RapidRAW code, photographs, or logos are distributed. + +The image panel uses the latest available recent folder's local preview, or +bundled original SVG artwork. Small windows show the action panel alone. +Space Grotesk is bundled offline under its SIL Open Font License. The preset +includes that font, with an option to use the normal interface font instead. +Selecting another design removes the preset font override without changing +the separate interface-font preference. + +Preferences `visualTheme` and `visualThemeFont` use the shared preferences API, +so they also persist through the optional XDG configuration feature. +With appearance modes installed, dark, light, and 18% grey remain independent +of the design preset. Rating colours and photograph pixels are unchanged. + +Validation: 33 frontend tests and production TypeScript/Vite build passed. +Chromium runtime checks cover selecting the theme, persistence after reload, +font override opt-out, returning to the default design, and desktop/small +window layouts with no horizontal overflow or JavaScript errors. diff --git a/index.html b/index.html index 43c5b34..fbb5e9f 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + OpenCull diff --git a/public/fonts/SpaceGrotesk-OFL.txt b/public/fonts/SpaceGrotesk-OFL.txt new file mode 100644 index 0000000..cb512b9 --- /dev/null +++ b/public/fonts/SpaceGrotesk-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/SpaceGrotesk.ttf b/public/fonts/SpaceGrotesk.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a1b2e6c26093066510a31147e7aec9abdc8d6c5e GIT binary patch literal 136676 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 IH;+H=zZFcdV*mgE literal 0 HcmV?d00001 diff --git a/public/theme-landscape.svg b/public/theme-landscape.svg new file mode 100644 index 0000000..b73d4da --- /dev/null +++ b/public/theme-landscape.svg @@ -0,0 +1,14 @@ + + Mountain ridges at dusk + + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx index b13dafe..031fc71 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useAppVisualTheme } from "@/components/VisualThemeSettings"; import { CullingView } from "@/components/cull/CullingView"; import { NoticeDialog, SettingsDialog, ShortcutsDialog, TrashDialog } from "@/components/cull/Dialogs"; @@ -12,6 +13,7 @@ import { installShortcuts, loadBindings, saveBindings, type Action, type Combo } import { useStore } from "@/lib/store"; export default function App() { + useAppVisualTheme(); const open = useStore((s) => s.session !== null); const ingesting = useStore((s) => s.ingestOpen); const [bindings, setBindings] = useState(loadBindings); diff --git a/src/components/VisualThemeSettings.tsx b/src/components/VisualThemeSettings.tsx new file mode 100644 index 0000000..7fa1039 --- /dev/null +++ b/src/components/VisualThemeSettings.tsx @@ -0,0 +1,60 @@ +import { useEffect } from "react"; +import { create } from "zustand"; +import { load, save } from "@/lib/prefs"; +import { validVisualTheme, visualThemeFont, type VisualTheme } from "@/lib/visual-theme"; +import "@/visual-theme.css"; + +const useVisualTheme = create<{ + theme: VisualTheme; + includedFont: boolean; + setTheme: (theme: VisualTheme) => void; + setIncludedFont: (included: boolean) => void; +}>((set) => ({ + theme: validVisualTheme(load("visualTheme", "default")), + includedFont: load("visualThemeFont", true) === true, + setTheme(theme) { + const safe = validVisualTheme(theme); + save("visualTheme", safe); + set({ theme: safe }); + }, + setIncludedFont(includedFont) { + save("visualThemeFont", includedFont); + set({ includedFont }); + }, +})); + +export function useDesignTheme() { + return useVisualTheme((s) => s.theme); +} + +export function useAppVisualTheme() { + const { theme, includedFont } = useVisualTheme(); + useEffect(() => { + const root = document.documentElement; + root.dataset.visualTheme = theme; + const font = visualThemeFont(theme, includedFont); + if (font) root.style.setProperty("--visual-theme-font", font); + else root.style.removeProperty("--visual-theme-font"); + }, [theme, includedFont]); +} + +export function VisualThemeSettings() { + const { theme, includedFont, setTheme, setIncludedFont } = useVisualTheme(); + return ( +
+ Design theme + +

A photographic split welcome screen, quiet neutral surfaces, and simple session actions. Your colour mode stays separate.

+ {theme === "rapidraw" && } +
+ ); +} diff --git a/src/components/cull/Dialogs.tsx b/src/components/cull/Dialogs.tsx index 81fd146..e6f9924 100644 --- a/src/components/cull/Dialogs.tsx +++ b/src/components/cull/Dialogs.tsx @@ -12,6 +12,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { VisualThemeSettings } from "@/components/VisualThemeSettings"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -280,6 +281,7 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT they are stepped through; a tab with more than fits scrolls. */}
+ s.recents); const platform = useStore((s) => s.platform); const openError = useStore((s) => s.openError); @@ -94,11 +96,14 @@ export function HomeView({ dropping }: { dropping: boolean }) { }; return ( -
-
-
+
+ {designTheme === "rapidraw" && !missing.has(path))} />} +
+
+

OpenCull

Culling Software made for Photographers

+

{recents.length ? "Welcome back. Continue a session or bring in your next shoot." : "Your next great photograph starts with a closer look."}

@@ -144,9 +149,9 @@ export function HomeView({ dropping }: { dropping: boolean }) { {openError &&
Could not open that folder: {openError}
} -
+
{recents.length === 0 ? ( -
+
Open a folder to start culling

Choose a folder of photos, or drop one anywhere on this window. RAW files open straight from the previews your camera already made.

@@ -192,6 +197,8 @@ export function HomeView({ dropping }: { dropping: boolean }) { )}
+
Local photos. A clear view. Your best frames.
+
{dropping && (
@@ -202,6 +209,26 @@ export function HomeView({ dropping }: { dropping: boolean }) { ); } +/** Uses only local session previews; original artwork fills the first-run state. */ +function WelcomeArtwork({ path }: { path?: string }) { + const [image, setImage] = useState(null); + useEffect(() => { + let stale = false; + setImage(null); + if (path) void api.folderCover(path).then( + (images) => { if (!stale) setImage(images[0] ?? null); }, + () => {}, + ); + return () => { stale = true; }; + }, [path]); + return ; +} + function FolderCard({ path, missing, diff --git a/src/lib/visual-theme.test.ts b/src/lib/visual-theme.test.ts new file mode 100644 index 0000000..36d31ee --- /dev/null +++ b/src/lib/visual-theme.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { validVisualTheme, visualThemeFont } from "./visual-theme"; + +describe("visual theme presets", () => { + it("keeps the original design for absent or unsupported preferences", () => { + for (const value of [null, undefined, {}, "unknown", "dark"]) { + expect(validVisualTheme(value)).toBe("default"); + } + expect(validVisualTheme("rapidraw")).toBe("rapidraw"); + }); + + it("supplies the preset font only while explicitly enabled", () => { + expect(visualThemeFont("rapidraw", true)).toContain('"Space Grotesk"'); + expect(visualThemeFont("rapidraw", false)).toBeNull(); + expect(visualThemeFont("default", true)).toBeNull(); + }); +}); diff --git a/src/lib/visual-theme.ts b/src/lib/visual-theme.ts new file mode 100644 index 0000000..635223d --- /dev/null +++ b/src/lib/visual-theme.ts @@ -0,0 +1,12 @@ +export type VisualTheme = "default" | "rapidraw"; + +export function validVisualTheme(value: unknown): VisualTheme { + return value === "rapidraw" ? value : "default"; +} + +/** A preset supplies typography without overwriting the user's interface font. */ +export function visualThemeFont(theme: VisualTheme, includedFont: boolean): string | null { + return theme === "rapidraw" && includedFont + ? '"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' + : null; +} diff --git a/src/visual-theme.css b/src/visual-theme.css new file mode 100644 index 0000000..1e273eb --- /dev/null +++ b/src/visual-theme.css @@ -0,0 +1,193 @@ +@font-face { + font-family: "Space Grotesk"; + src: url("/fonts/SpaceGrotesk.ttf") format("truetype"); + font-weight: 300 700; + font-style: normal; + font-display: swap; +} + +.rapidraw-intro, .rapidraw-footer { display: none; } + +:root[data-visual-theme="rapidraw"] { + color-scheme: dark; + --background: #181818; + --foreground: #e8eaed; + --card: #232323; + --card-foreground: #e8eaed; + --popover: #232323; + --popover-foreground: #e8eaed; + --primary: #e8eaed; + --primary-foreground: #181818; + --secondary: #2b2b2b; + --secondary-foreground: #e8eaed; + --muted: #232323; + --muted-foreground: #a6a6a6; + --accent: #353535; + --accent-foreground: #e8eaed; + --border: #3c3c3c; + --input: #4c4c4c; + --ring: #c6c6c6; + --opencull-canvas: #181818; + --opencull-toolbar: #232323; + --opencull-panel: #1c1c1c; + --opencull-strip: #181818; + --opencull-cell: #2b2b2b; + --opencull-accent: #dedede; + --opencull-select: #dedede; + font-family: var(--visual-theme-font, var(--app-font, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)); +} + +/* Colour mode is orthogonal to the layout/typography preset. */ +:root[data-visual-theme="rapidraw"][data-theme="light"] { + color-scheme: light; + --background: #f5f5f5; + --foreground: #141414; + --card: #fff; + --card-foreground: #141414; + --popover: #fff; + --popover-foreground: #141414; + --secondary: #e5e5e5; + --secondary-foreground: #141414; + --muted: #ededed; + --muted-foreground: #626262; + --accent: #dedede; + --accent-foreground: #141414; + --primary: #3c3c3c; + --primary-foreground: #fff; + --border: #d0d0d0; + --input: #b0b0b0; + --opencull-canvas: #e8e8e8; + --opencull-toolbar: #fff; + --opencull-panel: #f1f1f1; + --opencull-strip: #ededed; + --opencull-cell: #dedede; + --opencull-accent: #555; + --opencull-select: #555; +} +:root[data-visual-theme="rapidraw"][data-theme="grey"] { + --background: #303030; + --card: #383838; + --popover: #383838; + --secondary: #454545; + --muted: #383838; + --accent: #484848; + --border: #606060; + --input: #707070; + --opencull-canvas: #767676; + --opencull-toolbar: #383838; + --opencull-panel: #383838; + --opencull-strip: #303030; + --opencull-cell: #484848; +} + +:root[data-visual-theme="rapidraw"] .home-view { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(420px, 1fr); + padding: 8px; + background: var(--background); +} +:root[data-visual-theme="rapidraw"] .rapidraw-artwork { + position: relative; + min-height: 0; + overflow: hidden; + border-radius: 6px 0 0 6px; + background: var(--muted); +} +.rapidraw-artwork img { width: 100%; height: 100%; object-fit: cover; } +.rapidraw-artwork span { + position: absolute; + bottom: 24px; + left: 24px; + padding: 6px 10px; + color: #fff; + background: #0009; + border-radius: 4px; + font-size: 11px; +} +:root[data-visual-theme="rapidraw"] .home-content { + min-width: 0; + overflow-y: auto; + justify-content: center; + padding: 48px clamp(24px, 4vw, 64px) 24px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 0 6px 6px 0; +} +:root[data-visual-theme="rapidraw"] .home-header { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 0; + align-items: center; +} +:root[data-visual-theme="rapidraw"] .home-heading { flex: 1 0 100%; } +:root[data-visual-theme="rapidraw"] .home-heading h1 { + margin: 0 0 16px; + background: none; + color: var(--foreground); + font-size: 48px; + font-weight: 600; + letter-spacing: -2px; +} +:root[data-visual-theme="rapidraw"] .home-heading > p:first-of-type { display: none; } +:root[data-visual-theme="rapidraw"] .rapidraw-intro { + display: block; + max-width: 360px; + margin: 0 0 24px; + font-size: 18px; + line-height: 1.6; + color: var(--muted-foreground); +} +:root[data-visual-theme="rapidraw"] .home-header button { + min-height: 44px; + margin: 0; + border-radius: 6px; + color: var(--foreground); + background: var(--secondary); + box-shadow: none; +} +:root[data-visual-theme="rapidraw"] .home-header button:hover { background: var(--accent); } +:root[data-visual-theme="rapidraw"] .home-header .home-open { + order: 1; + justify-content: center; + flex: 1 0 100%; + color: var(--primary-foreground); + background: var(--primary); +} +:root[data-visual-theme="rapidraw"] .home-header .home-ingest { + order: 2; + flex: 1; + justify-content: center; +} +:root[data-visual-theme="rapidraw"] .home-header button[aria-label] { order: 3; padding: 12px; } +:root[data-visual-theme="rapidraw"] .home-header button:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 3px; +} +:root[data-visual-theme="rapidraw"] .home-recents { flex: 0 0 auto; max-height: 40vh; padding: 28px 0 0; } +:root[data-visual-theme="rapidraw"] .home-empty { display: none; } +:root[data-visual-theme="rapidraw"] .home-recents h2 { color: var(--muted-foreground); } +:root[data-visual-theme="rapidraw"] .home-recents > .grid { grid-template-columns: minmax(0, 1fr); } +:root[data-visual-theme="rapidraw"] .home-recents .group { + background: var(--secondary); + border-color: var(--border); + box-shadow: none; + border-radius: 6px; +} +:root[data-visual-theme="rapidraw"] .home-recents .group:hover { transform: none; background: var(--accent); } +:root[data-visual-theme="rapidraw"] .home-recents .group > div:first-child { display: none; } +:root[data-visual-theme="rapidraw"] .home-recents .group > div:nth-child(2) { padding-left: 42px; } +:root[data-visual-theme="rapidraw"] .home-recents .group > div:nth-child(2) > div { color: var(--foreground); } +:root[data-visual-theme="rapidraw"] .home-recents .group > div:nth-child(2) > div + div { color: var(--muted-foreground); } +:root[data-visual-theme="rapidraw"] .home-recents .group > button { display: block; top: 14px; } +:root[data-visual-theme="rapidraw"] .rapidraw-footer { + display: block; + margin-top: 32px; + font-size: 11px; + color: var(--muted-foreground); +} +@media (max-width: 800px) { + :root[data-visual-theme="rapidraw"] .home-view { grid-template-columns: minmax(0, 1fr); } + :root[data-visual-theme="rapidraw"] .rapidraw-artwork { display: none; } + :root[data-visual-theme="rapidraw"] .home-content { border-radius: 6px; padding: 40px 24px 24px; } +} From a9ae3c975dffd85b7c807e59cb4406bc587ef160 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:29:36 +0700 Subject: [PATCH 23/71] feat: browse camera card subfolders before selecting an ingest source --- docs/features/ingest-subfolders.md | 18 ++++++++++++++++++ src/components/ingest/IngestView.tsx | 27 +++++++++------------------ src/lib/mock.ts | 8 +++++--- 3 files changed, 32 insertions(+), 21 deletions(-) create mode 100644 docs/features/ingest-subfolders.md diff --git a/docs/features/ingest-subfolders.md b/docs/features/ingest-subfolders.md new file mode 100644 index 0000000..0e98edd --- /dev/null +++ b/docs/features/ingest-subfolders.md @@ -0,0 +1,18 @@ +# Choose a specific folder on a card + +Branch: `feature/ingest-subfolders`, based directly on upstream `a8c7f32`. + +Opening a card now browses its folders without selecting the whole card. +Open `CARD → DCIM → 100CANON` to select that leaf folder. Camera directory +names no longer stop navigation. Mixed folders containing both photographs +and subfolders can also be browsed. **Use this folder** explicitly selects +the currently displayed parent when a whole directory is wanted. +The system **Browse Folder** picker follows the same browse-first rule. + +This changes source selection only. It works independently of multi-card, +culling-session, and appearance features. Original files are untouched. + +Validation: 31 frontend tests and TypeScript/Vite build passed. Browser runtime +check verified zero scan requests while opening CARD/DCIM, a single scan of +`/DCIM/100CANON` when selected, and an explicit DCIM scan with **Use this folder**. +No browser JavaScript errors were observed. diff --git a/src/components/ingest/IngestView.tsx b/src/components/ingest/IngestView.tsx index 1256b9d..d27e9b3 100644 --- a/src/components/ingest/IngestView.tsx +++ b/src/components/ingest/IngestView.tsx @@ -48,12 +48,6 @@ interface Level { /** The way a file browser orders names: `IMG_9` before `IMG_10`. */ const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); -/** The folders a camera makes at the top of its card. Chosen whole, not gone into. */ -function isCameraMedia(name: string): boolean { - const lower = name.toLowerCase(); - return lower.startsWith("dcim") || lower === "m4root" || lower === "private" || lower === "misc"; -} - /** * When a photo was taken, for sorting and for showing. The camera's own time * once its file has been read; until then, and for a file that does not say, @@ -207,25 +201,21 @@ export function IngestView() { const openVolume = useCallback( (volume: Volume) => { - // A camera's card is listed whole straight away, and can still be - // looked through for one folder of it. A drive is only looked through: - // listing all of an archive would take minutes nobody asked for. - if (volume.cameraCard) choose(volume.path); + // Browse first so CARD → DCIM → 100CANON can be chosen precisely. goInto(volume.path, volume.name); }, - [choose, goInto], + [goInto], ); const openFolder = (folder: IngestFolder) => { - if (isCameraMedia(folder.name)) return choose(folder.path); const mine = ++browse.current; setBrowsing(true); void api.ingestBrowse(folder.path).then( (found) => { if (browse.current !== mine) return; setBrowsing(false); - // A folder of folders is gone into; a folder of photos is chosen. - if (found.folders.length > 0 && found.imageCount === 0) goInto(folder.path, folder.name, found.folders); + // Mixed folders may also contain a more specific source to choose. + if (found.folders.length > 0) goInto(folder.path, folder.name, found.folders); else choose(folder.path); }, () => browse.current === mine && setBrowsing(false), @@ -241,7 +231,7 @@ export function IngestView() { const browseForFolder = async () => { const picked = await api.pickFolder("Choose a folder of photos to ingest"); - if (picked) choose(picked); + if (picked) openFolder({ path: picked, name: picked.split(/[\\/]/).filter(Boolean).pop() ?? picked, imageCount: 0 }); }; // Opened from "card detected": go straight to that card once it is listed. @@ -421,8 +411,9 @@ export function IngestView() { -
) : ( @@ -442,7 +433,7 @@ export function IngestView() { {folder.name} {folder.imageCount > 0 && {folder.imageCount.toLocaleString()}} - {!isCameraMedia(folder.name) && } + )) ) @@ -622,7 +613,7 @@ function VolumeRow({ volume, selected, onClick }: { volume: Volume; selected: bo {volume.cameraCard ? : } {volume.name} - {!volume.cameraCard && } + ); } diff --git a/src/lib/mock.ts b/src/lib/mock.ts index e8fafe6..4413e04 100644 --- a/src/lib/mock.ts +++ b/src/lib/mock.ts @@ -199,9 +199,11 @@ export const mock: Backend = { ]), ingestBrowse: (path) => later( - path.endsWith("100MSDCF") - ? { folders: [], imageCount: images.length } - : { folders: [{ path: `${path}/DCIM`, name: "DCIM", imageCount: 0 }, { path: `${path}/Weddings`, name: "Weddings", imageCount: 0 }], imageCount: 0 }, + path.endsWith("DCIM") + ? { folders: [{ path: `${path}/100CANON`, name: "100CANON", imageCount: images.length }], imageCount: 0 } + : path.endsWith("100CANON") || path.endsWith("Weddings") + ? { folders: [], imageCount: images.length } + : { folders: [{ path: `${path}/DCIM`, name: "DCIM", imageCount: 0 }, { path: `${path}/Weddings`, name: "Weddings", imageCount: 0 }], imageCount: 0 }, 200, ), ingestScan: () => later(images, 300), From 33ada2255a2f214f04c78cf9700cdbd478021687 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:30:39 +0700 Subject: [PATCH 24/71] feat: clarify screen headings and move review guidance below the ingest title --- docs/features/ui-changes.md | 19 +++++++++++++++++++ src/components/cull/Toolbar.tsx | 4 ++-- src/components/home/HomeView.tsx | 4 ++-- src/components/ingest/IngestView.tsx | 20 +++++++++++--------- src/components/ui/alert-dialog.tsx | 2 +- src/components/ui/dialog.tsx | 2 +- 6 files changed, 36 insertions(+), 15 deletions(-) create mode 100644 docs/features/ui-changes.md diff --git a/docs/features/ui-changes.md b/docs/features/ui-changes.md new file mode 100644 index 0000000..82f7746 --- /dev/null +++ b/docs/features/ui-changes.md @@ -0,0 +1,19 @@ +# Clearer UI headings and ingest instructions + +Branch: `feature/ui-changes`. Prerequisite: `feature/cull-before-ingest`. +This branch starts from that feature because the moved **Review first** +instruction describes its cull-before-copy workflow. Review its PR against +`feature/cull-before-ingest` to see only the UI changes. + +- Ingest uses a semantic 24px heading. The 14px **Review first** instruction + sits immediately below it, above the whole workspace, rather than in the + destination sidebar. Header height follows the wrapped instruction text. +- Home branding increases to 32px, recent-folder headings to 18px, the current + culling folder to 16px, and shared dialog/confirmation titles to 20px. +- Source section labels increase to 14px, with 12px card/drive subheadings. + +These changes are independent of the optional design theme. Actions, photo +selection, keyboard shortcuts, and file operations retain their behavior. + +Validation: frontend tests, TypeScript/Vite build, and browser runtime checks +of the ingest heading/instruction placement and shared dialog title sizes. diff --git a/src/components/cull/Toolbar.tsx b/src/components/cull/Toolbar.tsx index 0a8ad76..5edb0fd 100644 --- a/src/components/cull/Toolbar.tsx +++ b/src/components/cull/Toolbar.tsx @@ -99,7 +99,7 @@ export function Toolbar() { - +

{name} {kinds !== "both" && ( @@ -107,7 +107,7 @@ export function Toolbar() { {kinds === "raw" ? "RAW only" : "JPEG only"} )} - +

diff --git a/src/components/home/HomeView.tsx b/src/components/home/HomeView.tsx index b849cf2..f3ac48b 100644 --- a/src/components/home/HomeView.tsx +++ b/src/components/home/HomeView.tsx @@ -98,7 +98,7 @@ export function HomeView({ dropping }: { dropping: boolean }) {
-

OpenCull

+

OpenCull

Culling Software made for Photographers

diff --git a/src/components/ingest/IngestView.tsx b/src/components/ingest/IngestView.tsx index df6c19a..893d6c8 100644 --- a/src/components/ingest/IngestView.tsx +++ b/src/components/ingest/IngestView.tsx @@ -293,10 +293,14 @@ export function IngestView() { return (
-
- - Ingest from Card - +
+
+

Ingest from Card

+

+ Review first.{" "} + Cull the card in the normal workspace. At the end, choose files to copy or import in place. Nothing is copied or deleted now. +

+
- -
+ !open && setPhase({ is: "ready" })}> From e1a1e8d4b3ea5ccafa9d2ea8691aa760d9d949f2 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:54:26 +0700 Subject: [PATCH 33/71] docs: describe the wider ingest workspace and bottom action bar --- docs/features/ui-changes.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/features/ui-changes.md b/docs/features/ui-changes.md index 82f7746..0b6879d 100644 --- a/docs/features/ui-changes.md +++ b/docs/features/ui-changes.md @@ -11,6 +11,9 @@ instruction describes its cull-before-copy workflow. Review its PR against - Home branding increases to 32px, recent-folder headings to 18px, the current culling folder to 16px, and shared dialog/confirmation titles to 20px. - Source section labels increase to 14px, with 12px card/drive subheadings. +- The unused destination sidebar is removed during card review. Previews use + the available width, with **Start Culling** in a bottom action bar. In the + combined app, the video inclusion toggle remains in Settings. These changes are independent of the optional design theme. Actions, photo selection, keyboard shortcuts, and file operations retain their behavior. From fa3be1aa8fa1f2ee0d6713c1a4238e37bbb27f2b Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:56:45 +0700 Subject: [PATCH 34/71] feat: align home controls with consistent sizing and grouped spacing --- src/components/home/HomeView.tsx | 23 +++++++++++++++-------- src/components/ingest/ReviewBar.tsx | 4 ++-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/components/home/HomeView.tsx b/src/components/home/HomeView.tsx index f3ac48b..ceee0e6 100644 --- a/src/components/home/HomeView.tsx +++ b/src/components/home/HomeView.tsx @@ -10,6 +10,7 @@ import { cn } from "@/lib/utils"; import type { Scanned } from "@/lib/types"; const GRADIENT = "bg-gradient-to-r from-[#4d80ff] to-[#804dff]"; +const HOME_ACTION = "inline-flex h-10 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg px-4 py-0 text-[13px] font-medium leading-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"; function baseName(path: string): string { const parts = path.split(/[\\/]/).filter(Boolean); @@ -96,32 +97,38 @@ export function HomeView({ dropping }: { dropping: boolean }) { return (
-
-
+
+

OpenCull

Culling Software made for Photographers

- - +
+
- + +
+
{arrived && ( diff --git a/src/components/ingest/ReviewBar.tsx b/src/components/ingest/ReviewBar.tsx index cb742fd..6437449 100644 --- a/src/components/ingest/ReviewBar.tsx +++ b/src/components/ingest/ReviewBar.tsx @@ -6,7 +6,7 @@ import { initialSelection, selectedReview } from "@/lib/review"; import { copyReview, loadReview, saveReview } from "@/lib/review-api"; import { useStore } from "@/lib/store"; -export function ResumeCardSession() { +export function ResumeCardSession({ className }: { className?: string }) { const [available, setAvailable] = useState(false); const [error, setError] = useState(""); useEffect(() => { void loadReview().then((r) => setAvailable(r !== null)).catch((e) => setError(String(e))); }, []); @@ -19,7 +19,7 @@ export function ResumeCardSession() { await useStore.getState().openFolder(review.sources[0], review); } catch (e) { setError(String(e)); } }; - return <>{available && }{error &&

{error}

}; + return <>{available && }{error &&

{error}

}; } export function ReviewBar() { From 82a75718729cb6e469b2fc80f817f916ad49fb1e Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:00:03 +0700 Subject: [PATCH 35/71] feat: enlarge recent folder cards with responsive photo previews --- src/components/home/HomeView.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/home/HomeView.tsx b/src/components/home/HomeView.tsx index ceee0e6..8881e35 100644 --- a/src/components/home/HomeView.tsx +++ b/src/components/home/HomeView.tsx @@ -184,7 +184,7 @@ export function HomeView({ dropping }: { dropping: boolean }) { )}
-
+
{recents.map((path) => ( -
{missing ? : }
-
-
+
{missing ? : }
+
+
{missing && } {baseName(path)}
-
{missing ? "Not where it was — click to find it" : path}
+
{missing ? "Not where it was — click to find it" : path}
- {showFilmstrip && viewMode === "loupe" && !empty && ( + {showFilmstrip && (viewMode === "loupe" || (viewMode === "scene" && stackAtBottom)) && !empty && (
- +
)}
diff --git a/src/components/cull/Dialogs.tsx b/src/components/cull/Dialogs.tsx index 81fd146..7a94659 100644 --- a/src/components/cull/Dialogs.tsx +++ b/src/components/cull/Dialogs.tsx @@ -12,6 +12,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { StackLayoutSettings } from "@/components/StackLayoutSettings"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -280,6 +281,7 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT they are stepped through; a tab with more than fits scrolls. */}
+ s.view); const photos = useStore((s) => s.photos); const current = useStore((s) => s.current); @@ -50,9 +50,9 @@ export function SceneView() {
-
+ {!externalFilmstrip &&
-
+
}
); diff --git a/src/lib/scene-frames.test.ts b/src/lib/scene-frames.test.ts new file mode 100644 index 0000000..66583df --- /dev/null +++ b/src/lib/scene-frames.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { sceneFrames } from "./scene-frames"; +describe("stack filmstrip membership", () => { + it("shows the current contiguous scene in visible order", () => { + expect(sceneFrames([4, 3, 2, 1, 0], 3, (i) => i > 1 ? 7 : 8)).toEqual([4, 3, 2]); + }); + it("does not join unrelated single frames or separated scenes", () => { + expect(sceneFrames([0, 1, 2], 1, () => null)).toEqual([1]); + expect(sceneFrames([0, 1, 2], 0, (i) => i === 1 ? 2 : 1)).toEqual([0]); + }); + it("does not include filtered-out frames", () => { + expect(sceneFrames([0, 2], 1, () => 7)).toEqual([]); + expect(sceneFrames([0, 2], 2, () => 7)).toEqual([0, 2]); + }); +}); diff --git a/src/lib/scene-frames.ts b/src/lib/scene-frames.ts new file mode 100644 index 0000000..03a1fe8 --- /dev/null +++ b/src/lib/scene-frames.ts @@ -0,0 +1,11 @@ +/** The contiguous scene containing the current frame, following the visible order. */ +export function sceneFrames(view: readonly number[], current: number, groupOf: (index: number) => number | null): number[] { + const at = view.indexOf(current); + if (at < 0) return []; + const group = groupOf(current); + if (group === null) return [current]; + let start = at, end = at + 1; + while (start > 0 && groupOf(view[start - 1]) === group) start--; + while (end < view.length && groupOf(view[end]) === group) end++; + return view.slice(start, end); +} diff --git a/src/lib/store.ts b/src/lib/store.ts index 0cafabf..1aa1143 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -61,6 +61,7 @@ interface State { sortAscending: boolean; viewMode: ViewMode; + stackFilmstripAtBottom: boolean; gridColumns: number; showExif: boolean; /** How wide the EXIF panel is, in pixels. */ @@ -335,6 +336,7 @@ export const speedCulling = (s: Pick) => /** Settings written back to storage whenever they change. */ const PERSISTED = [ + "stackFilmstripAtBottom", "sort", "sortAscending", "gridColumns", @@ -672,6 +674,7 @@ export const useStore = create((set, get) => { sortAscending: load("sortAscending", true), viewMode: "loupe", + stackFilmstripAtBottom: load("stackFilmstripAtBottom", false) === true, gridColumns: load("gridColumns", 4), showExif: load("showExif", false), exifPanelWidth: load("exifPanelWidth", 250), From b0982947c48ad639034ccb26b895b431011a4352 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:05:38 +0700 Subject: [PATCH 37/71] feat: retain card session history and resume latest session --- docs/features/RECENT_SESSIONS.md | 1 + src-tauri/src/lib.rs | 1 + src-tauri/src/review.rs | 90 ++++++++++++++++++++++++-- src/components/home/HomeView.tsx | 5 +- src/components/home/RecentSessions.tsx | 69 ++++++++++++++++++++ src/lib/review-api.ts | 30 +++++++-- src/lib/review.ts | 2 +- src/lib/session-history.test.ts | 14 ++++ src/lib/session-history.ts | 12 ++++ src/lib/store.ts | 1 + 10 files changed, 209 insertions(+), 16 deletions(-) create mode 100644 docs/features/RECENT_SESSIONS.md create mode 100644 src/components/home/RecentSessions.tsx create mode 100644 src/lib/session-history.test.ts create mode 100644 src/lib/session-history.ts diff --git a/docs/features/RECENT_SESSIONS.md b/docs/features/RECENT_SESSIONS.md new file mode 100644 index 0000000..a2343b9 --- /dev/null +++ b/docs/features/RECENT_SESSIONS.md @@ -0,0 +1 @@ +Depends on feature/cull-before-ingest. Card sessions are retained individually and shown under Recent Sessions. Legacy latest.json remains readable. Resume Latest Session compares card review save time with the last successfully opened normal folder. Missing cards produce a reconnect message. Source photos are never modified by session history. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f07f0b5..5e6a97f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -140,6 +140,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ review::review_save, + review::review_list, review::review_load, commands::review_copy, commands::app_info, diff --git a/src-tauri/src/review.rs b/src-tauri/src/review.rs index fba2632..75d7120 100644 --- a/src-tauri/src/review.rs +++ b/src-tauri/src/review.rs @@ -20,23 +20,99 @@ fn save(path: &Path, value: &Value) -> Result<(), String> { if result.is_err() { let _ = fs::remove_file(tmp); } result } +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} +fn read(path: &Path) -> Result, String> { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|e| format!("Saved session could not be read: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.to_string()), + } +} +fn save_history(latest: &Path, value: &Value) -> Result<(), String> { + let id = value.get("id").and_then(Value::as_str).filter(|id| valid_id(id) && *id != "latest").ok_or("Invalid session ID")?; + if value.get("version").and_then(Value::as_u64) != Some(1) || !value.get("items").is_some_and(Value::is_array) { return Err("Invalid session".into()); } + save(&latest.with_file_name(format!("{id}.json")), value)?; + save(latest, value) +} +fn history(latest: &Path) -> Result, String> { + let parent = latest.parent().ok_or("No session directory")?; + let entries = match fs::read_dir(parent) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.to_string()), + }; + let mut files: Vec<_> = entries.filter_map(Result::ok).filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { return None; } + let modified = entry.metadata().ok()?.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as u64; + Some((path, modified)) + }).collect(); + files.sort_by(|a, b| b.1.cmp(&a.1)); + let mut summaries = Vec::new(); + for (file, modified) in files { + let Ok(Some(value)) = read(&file) else { continue; }; + if value["version"] != 1 || !value["items"].is_array() || !value["sources"].is_array() { continue; } + let id = value["id"].as_str().filter(|id| valid_id(id)).unwrap_or("latest"); + if summaries.iter().any(|v: &Value| v["id"] == id) { continue; } + summaries.push(serde_json::json!({ + "id": id, "name": value["name"].as_str().unwrap_or("Card session"), + "sources": value["sources"], "count": value["items"].as_array().unwrap().len(), + "updatedAt": value["updatedAt"].as_u64().unwrap_or(modified), + "stage": value["stage"].as_str().unwrap_or("review"), "cover": value["items"][0], + })); + if summaries.len() == 24 { break; } + } + summaries.sort_by(|a,b| b["updatedAt"].as_u64().cmp(&a["updatedAt"].as_u64())); + Ok(summaries) +} #[tauri::command] pub fn review_save(app: tauri::AppHandle, value: Value) -> Result<(), String> { let _lock = WRITING.lock().map_err(|e| e.to_string())?; - if value.get("version").and_then(Value::as_u64) != Some(1) || !value.get("items").is_some_and(Value::is_array) { return Err("Invalid session".into()); } - save(&path(&app)?, &value) + save_history(&path(&app)?, &value) } #[tauri::command] -pub fn review_load(app: tauri::AppHandle) -> Result, String> { +pub fn review_load(app: tauri::AppHandle, id: Option) -> Result, String> { let _lock = WRITING.lock().map_err(|e| e.to_string())?; - match fs::read(path(&app)?) { - Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|e| format!("Saved session could not be read: {e}")), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e.to_string()), + let latest = path(&app)?; + if let Some(id) = id { + if !valid_id(&id) { return Err("Invalid session ID".into()); } + let file = latest.with_file_name(format!("{id}.json")); + if let Some(value) = read(&file)? { return Ok(Some(value)); } + let legacy = read(&latest)?; + return Ok(legacy.filter(|v| v["id"] == id)); } + read(&latest) +} +#[tauri::command] +pub async fn review_list(app: tauri::AppHandle) -> Result, String> { + let latest = path(&app)?; + tauri::async_runtime::spawn_blocking(move || { + let _lock = WRITING.lock().map_err(|e| e.to_string())?; + history(&latest) + }).await.map_err(|e| e.to_string())? } #[cfg(test)] mod tests { use super::*; + #[test] fn history_retains_independent_sessions_and_updates_them() { + let root = std::env::temp_dir().join(format!("opencull-history-{}", std::process::id())); + let latest = root.join("latest.json"); + for (id, updated) in [("a", 1), ("b", 2), ("a", 3)] { + save_history(&latest, &serde_json::json!({"version":1,"id":id,"name":id,"sources":["/card"],"items":[],"updatedAt":updated})).unwrap(); + } + let list = history(&latest).unwrap(); + assert_eq!(list.len(), 2); assert_eq!(list[0]["id"], "a"); assert_eq!(list[1]["id"], "b"); + assert_eq!(read(&root.join("b.json")).unwrap().unwrap()["id"], "b"); + assert!(save_history(&latest, &serde_json::json!({"version":1,"id":"../outside","items":[]})).is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn legacy_latest_session_is_listed_without_rewriting_it() { + let root = std::env::temp_dir().join(format!("opencull-history-legacy-{}", std::process::id())); let latest = root.join("latest.json"); + save(&latest, &serde_json::json!({"version":1,"id":"old","sources":["/card"],"items":[]})).unwrap(); + let before = fs::read(&latest).unwrap(); assert_eq!(history(&latest).unwrap()[0]["id"], "old"); + assert_eq!(fs::read(&latest).unwrap(), before); fs::remove_dir_all(root).unwrap(); + } #[test] fn session_is_stored_separately_and_replaced_whole() { let root = std::env::temp_dir().join(format!("opencull-review-{}", std::process::id())); let path = root.join("sessions/latest.json"); diff --git a/src/components/home/HomeView.tsx b/src/components/home/HomeView.tsx index b849cf2..650672b 100644 --- a/src/components/home/HomeView.tsx +++ b/src/components/home/HomeView.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Circle, CircleCheck, FolderPlus, FolderSearch, Images, Keyboard, Settings, Trash2, X } from "lucide-react"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu"; -import { ResumeCardSession } from "@/components/ingest/ReviewBar"; +import { RecentSessions, ResumeLatestSession } from "./RecentSessions"; import { CardIcon } from "@/components/ingest/IngestView"; import { api, type Volume } from "@/lib/api"; import { useStore } from "@/lib/store"; @@ -114,7 +114,7 @@ export function HomeView({ dropping }: { dropping: boolean }) { > Ingest from Card - + + {error &&

{error}

}; +} +export function RecentSessions() { + const { sessions,error } = useRecentSessions(); + const [opening,setOpening] = useState(null); + const [openError,setOpenError] = useState(""); + if (!sessions.length && !error) return null; + const open = async (id: string) => { setOpening(id);setOpenError("");try { await resume({kind:"card",id}); } catch(e) { setOpenError(String(e)); } finally { setOpening(null); } }; + return
+

Recent Sessions ({sessions.length})

+ {(error || openError) &&

{error || openError}

} +
+ {sessions.map((session) => )} +
+
; +} diff --git a/src/lib/review-api.ts b/src/lib/review-api.ts index 2456f62..a8b594f 100644 --- a/src/lib/review-api.ts +++ b/src/lib/review-api.ts @@ -2,15 +2,33 @@ import { Channel, invoke } from "@tauri-apps/api/core"; import { inTauri, type Shipped } from "./api"; import type { ReviewSession } from "./review"; import type { Photo } from "./types"; +import type { RecentSession } from "./session-history"; let saving = Promise.resolve(); -export function saveReview(review: ReviewSession): Promise { - const snapshot = JSON.parse(JSON.stringify(review)); - if (!inTauri) { localStorage.setItem("opencull.session.latest", JSON.stringify(snapshot)); return Promise.resolve(); } +export async function saveReview(review: ReviewSession): Promise { + const snapshot = { ...JSON.parse(JSON.stringify(review)), updatedAt: Date.now() }; + if (!inTauri) { + const history = browserHistory().filter((r) => r.id !== snapshot.id); + localStorage.setItem("opencull.session.history", JSON.stringify([snapshot, ...history])); + localStorage.setItem("opencull.session.latest", JSON.stringify(snapshot)); + window.dispatchEvent(new Event("opencull:sessions-changed")); return; + } saving = saving.catch(() => {}).then(() => invoke("review_save", { value: snapshot })); - return saving; + await saving; window.dispatchEvent(new Event("opencull:sessions-changed")); } -export async function loadReview(): Promise { - const value: ReviewSession | null = inTauri ? await invoke("review_load") : JSON.parse(localStorage.getItem("opencull.session.latest") ?? "null"); +function browserHistory(): ReviewSession[] { + const history = JSON.parse(localStorage.getItem("opencull.session.history") ?? "[]"); + const latest = JSON.parse(localStorage.getItem("opencull.session.latest") ?? "null"); + const values: ReviewSession[] = Array.isArray(history) ? history : []; + return latest && !values.some((v) => v.id === latest.id) ? [latest,...values] : values; +} +export async function listReviews(): Promise { + if (inTauri) return invoke("review_list"); + return browserHistory().filter((r) => r?.version === 1 && Array.isArray(r.items) && Array.isArray(r.sources)).map((r) => ({ + id:r.id ?? "latest",name:r.name ?? "Card session",sources:r.sources,count:r.items.length,updatedAt:r.updatedAt ?? (Number(r.id) || 0),stage:r.stage,cover:r.items[0] ?? null, + })).sort((a,b) => b.updatedAt-a.updatedAt).slice(0,24); +} +export async function loadReview(id?: string): Promise { + const value: ReviewSession | null = inTauri ? await invoke("review_load", { id }) : id ? browserHistory().find((r) => (r.id ?? "latest") === id) ?? null : JSON.parse(localStorage.getItem("opencull.session.latest") ?? "null"); if (value && (value.version !== 1 || !Array.isArray(value.items) || !Array.isArray(value.sources) || typeof value.decisions !== "object")) throw new Error("Saved card session is not supported"); return value; } diff --git a/src/lib/review.ts b/src/lib/review.ts index bc4410d..0a473ea 100644 --- a/src/lib/review.ts +++ b/src/lib/review.ts @@ -3,7 +3,7 @@ export interface ReviewSession { version: 1; id: string; name: string; sources: string[]; items: Scanned[]; decisions: Record; chosen: string[] | null; stage: "review" | "in-place"; - completed: Record; destination?: string; + completed: Record; destination?: string; updatedAt?: number; } export function initialSelection(photos: readonly T[]): string[] { return photos.filter((p) => p.flag !== "reject").map((p) => p.path); diff --git a/src/lib/session-history.test.ts b/src/lib/session-history.test.ts new file mode 100644 index 0000000..71a9998 --- /dev/null +++ b/src/lib/session-history.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { latestSession, type RecentSession } from "./session-history"; +const card = (id: string, updatedAt: number): RecentSession => ({id,updatedAt,name:id,sources:["/card"],count:1,stage:"review",cover:null}); +describe("resume latest session", () => { + it("chooses the most recently used session regardless of kind or list order", () => { + expect(latestSession([card("a",1),card("b",3)],{path:"/folder",updatedAt:2})).toEqual({kind:"card",id:"b"}); + expect(latestSession([card("a",1)],{path:"/folder",updatedAt:2})).toEqual({kind:"folder",path:"/folder"}); + }); + it("supports existing recent folders and ignores malformed preferences", () => { + expect(latestSession([],null,"/old-folder")).toEqual({kind:"folder",path:"/old-folder"}); + expect(latestSession([], {path:"/bad",updatedAt:"wrong"})).toBeNull(); + expect(latestSession([],null)).toBeNull(); + }); +}); diff --git a/src/lib/session-history.ts b/src/lib/session-history.ts new file mode 100644 index 0000000..602444e --- /dev/null +++ b/src/lib/session-history.ts @@ -0,0 +1,12 @@ +import type { Scanned } from "./types"; +export interface RecentSession { id: string; name: string; sources: string[]; count: number; updatedAt: number; stage: string; cover: Scanned | null } +export interface FolderSession { path: string; updatedAt: number } +export type ResumeTarget = { kind: "card"; id: string } | { kind: "folder"; path: string }; +export function latestSession(cards: readonly RecentSession[], folder: unknown, fallbackFolder?: string): ResumeTarget | null { + const f = folder as Partial | null; + const validFolder = typeof f?.path === "string" && !!f.path && typeof f.updatedAt === "number" && Number.isFinite(f.updatedAt); + const card = [...cards].sort((a,b) => b.updatedAt - a.updatedAt)[0]; + if (card && (!validFolder || card.updatedAt >= f.updatedAt!)) return { kind: "card", id: card.id }; + if (validFolder) return { kind: "folder", path: f.path! }; + return fallbackFolder ? { kind: "folder", path: fallbackFolder } : null; +} diff --git a/src/lib/store.ts b/src/lib/store.ts index c392ae3..7d4d2b1 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -830,6 +830,7 @@ export const useStore = create((set, get) => { const current = photos.length === 0 ? -1 : resume >= 0 ? resume : order[0]; const recents = review ? get().recents : [path, ...get().recents.filter((r) => r !== path)].slice(0, MAX_RECENTS); + if (!review) save("lastFolderSession", { path, updatedAt: Date.now() }); set({ session: { id: reply.session, folder: path, name: review?.name ?? baseName(path), includeSubfolders, subfolderCount: reply.subfolderCount, fileKinds, leftOut: reply.leftOut, timed: reply.timed }, loading: false, From eb58c19c26d2d0eb7f1d9b1e7b57035b248bedb2 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:05:38 +0700 Subject: [PATCH 38/71] feat: add experimental native-aspect filmstrip --- docs/features/EXPERIMENTAL_FILMSTRIP.md | 1 + src/components/cull/Dialogs.tsx | 2 ++ .../cull/ExperimentalFilmstripSettings.tsx | 14 ++++++++++++++ src/components/cull/Filmstrip.tsx | 15 +++++++++++---- src/lib/store.ts | 3 +++ 5 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 docs/features/EXPERIMENTAL_FILMSTRIP.md create mode 100644 src/components/cull/ExperimentalFilmstripSettings.tsx diff --git a/docs/features/EXPERIMENTAL_FILMSTRIP.md b/docs/features/EXPERIMENTAL_FILMSTRIP.md new file mode 100644 index 0000000..b146a97 --- /dev/null +++ b/docs/features/EXPERIMENTAL_FILMSTRIP.md @@ -0,0 +1 @@ +Settings → Culling → Experimental Features → Native-aspect filmstrip. Disabled by default. Uses displayed rotation/crop proportions at a common height, preserving the existing virtualized horizontal strip. Unknown dimensions temporarily use 3:2 until metadata arrives. No masonry CSS dependency or photo changes. diff --git a/src/components/cull/Dialogs.tsx b/src/components/cull/Dialogs.tsx index 81fd146..536d91f 100644 --- a/src/components/cull/Dialogs.tsx +++ b/src/components/cull/Dialogs.tsx @@ -1,3 +1,4 @@ +import { ExperimentalFilmstripSettings } from "./ExperimentalFilmstripSettings"; import { useEffect, useRef, useState } from "react"; import { HardDrive, Keyboard, ScanFace, Star, ZoomIn, type LucideIcon } from "lucide-react"; @@ -280,6 +281,7 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT they are stepped through; a tab with more than fits scrolls. */}
+ s.experimentalNativeFilmstrip); + return
+ Experimental Features + +
; +} diff --git a/src/components/cull/Filmstrip.tsx b/src/components/cull/Filmstrip.tsx index 369a185..e7ff767 100644 --- a/src/components/cull/Filmstrip.tsx +++ b/src/components/cull/Filmstrip.tsx @@ -2,6 +2,7 @@ import { memo, useMemo, useRef } from "react"; import { bucket, THUMB_SIZES } from "@/lib/api"; import { groupOf, hasActiveFilter, useStore } from "@/lib/store"; +import { shownSize } from "@/lib/framing"; import { cn } from "@/lib/utils"; import { PhotoMenu } from "./PhotoMenu"; import { useStrip } from "./strip"; @@ -67,7 +68,13 @@ export function Filmstrip({ only, height: fixedHeight, resizable = true }: { onl const storedHeight = useStore((s) => s.filmstripHeight); const height = fixedHeight ?? storedHeight; const thumbH = stripThumbHeight(height); - const thumbW = Math.round(thumbH * 1.5); + const nativeAspect = useStore((s) => s.experimentalNativeFilmstrip); + const widths = useMemo(() => view.map((index) => { + const photo = photos[index]; + const size = photo && shownSize(photo); + const ratio = nativeAspect && size && size.w > 0 && size.h > 0 ? size.w / size.h : 1.5; + return Math.max(1, Math.round(thumbH * ratio)); + }), [view, photos, thumbH, nativeAspect]); const size = useStripThumbSize(height); // Frames of one group are drawn joined, on a shared backing. A group is a @@ -92,11 +99,11 @@ export function Filmstrip({ only, height: fixedHeight, resizable = true }: { onl let x = PAD; rows.forEach((row, i) => { out[i] = x; - x += thumbW + (row.burst && row.first ? 8 : 3) + (row.burst && row.last ? 8 : 3) + (row.gap ? 6 : 0); + x += widths[i] + (row.burst && row.first ? 8 : 3) + (row.burst && row.last ? 8 : 3) + (row.gap ? 6 : 0); }); out[rows.length] = x; return out; - }, [rows, thumbW]); + }, [rows, widths]); // Keep the photo in the loupe in the middle of the strip. const position = useMemo(() => rows.findIndex((r) => r.index === current), [rows, current]); const { scroller, length, from, to, onScroll, onWheel } = useStrip({ starts, pad: PAD, position, anchor: current }); @@ -119,7 +126,7 @@ export function Filmstrip({ only, height: fixedHeight, resizable = true }: { onl )} style={{ paddingLeft: row.burst && row.first ? 8 : 3, paddingRight: row.burst && row.last ? 8 : 3 }} > - +
); diff --git a/src/lib/store.ts b/src/lib/store.ts index 0cafabf..892c538 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -69,6 +69,7 @@ interface State { showInfo: boolean; showRatingBar: boolean; showFilterBar: boolean; + experimentalNativeFilmstrip: boolean; filmstripHeight: number; /** The strip's edge is being dragged, and its height is changing every frame. */ stripResizing: boolean; @@ -344,6 +345,7 @@ const PERSISTED = [ "showInfo", "showRatingBar", "showFilterBar", + "experimentalNativeFilmstrip", "filmstripHeight", "includeSubfolders", "fileKinds", @@ -679,6 +681,7 @@ export const useStore = create((set, get) => { showInfo: load("showInfo", false), showRatingBar: load("showRatingBar", true), showFilterBar: load("showFilterBar", false), + experimentalNativeFilmstrip: load("experimentalNativeFilmstrip", false) === true, filmstripHeight: load("filmstripHeight", 120), stripResizing: false, panelResizing: false, From 91586ae472275df3f593381e579bfac0993ead06 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:05:41 +0700 Subject: [PATCH 39/71] feat: add experimental eased click zoom with reduced motion support --- docs/features/EXPERIMENTAL_ZOOM.md | 1 + src/components/cull/Dialogs.tsx | 2 ++ .../cull/ExperimentalZoomSettings.tsx | 14 ++++++++++++++ src/components/cull/Loupe.tsx | 18 ++++++++++++++++-- src/lib/store.ts | 3 +++ 5 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 docs/features/EXPERIMENTAL_ZOOM.md create mode 100644 src/components/cull/ExperimentalZoomSettings.tsx diff --git a/docs/features/EXPERIMENTAL_ZOOM.md b/docs/features/EXPERIMENTAL_ZOOM.md new file mode 100644 index 0000000..2eb1f92 --- /dev/null +++ b/docs/features/EXPERIMENTAL_ZOOM.md @@ -0,0 +1 @@ +Settings → Zoom → Experimental Features → Smooth click zoom. Disabled by default. Click/keyboard/face/marquee zoom uses a fast-start, slow-stop cubic curve with 280–340 ms durations. Reduced motion disables these transitions. Wheel zoom and dragging remain immediate. Existing sharp rerasterization after motion is retained. This is an experimental motion option, not a promise of native macOS performance. diff --git a/src/components/cull/Dialogs.tsx b/src/components/cull/Dialogs.tsx index 81fd146..468f8f1 100644 --- a/src/components/cull/Dialogs.tsx +++ b/src/components/cull/Dialogs.tsx @@ -1,3 +1,4 @@ +import { ExperimentalZoomSettings } from "./ExperimentalZoomSettings"; import { useEffect, useRef, useState } from "react"; import { HardDrive, Keyboard, ScanFace, Star, ZoomIn, type LucideIcon } from "lucide-react"; @@ -381,6 +382,7 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT /> + s.experimentalSmoothZoom); + return
+ Experimental Features + +
; +} diff --git a/src/components/cull/Loupe.tsx b/src/components/cull/Loupe.tsx index 9cb0bd6..5bbfe64 100644 --- a/src/components/cull/Loupe.tsx +++ b/src/components/cull/Loupe.tsx @@ -42,7 +42,7 @@ const MAX_ZOOM = 20; const SHARPEN_MS = 120; /** How long each zoom takes to glide into place, in milliseconds. */ -const GLIDE = { in: 220, out: 200, box: 250 }; + /** A press that travels no further than this is a click, not a drag. */ const CLICK_SLOP = 3; @@ -112,6 +112,20 @@ const WORTH_FULL = 1.15; export function Loupe() { const photo = useStore((s) => (s.current >= 0 ? s.photos[s.current] : undefined)); const loading = useStore((s) => s.loading); + const smoothZoom = useStore((s) => s.experimentalSmoothZoom); + const [reducedMotion, setReducedMotion] = useState(() => window.matchMedia("(prefers-reduced-motion: reduce)").matches); + useEffect(() => { + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReducedMotion(query.matches); + query.addEventListener("change", update); + update(); + return () => query.removeEventListener("change", update); + }, []); + const GLIDE = smoothZoom + ? reducedMotion ? { in: 0, out: 0, box: 0 } : { in: 320, out: 280, box: 340 } + : { in: 220, out: 200, box: 250 }; + const zoomCurve = smoothZoom ? "cubic-bezier(0.22, 1, 0.36, 1)" : "ease-in-out"; + const folder = useStore((s) => s.session?.name ?? "folder"); const showInfo = useStore((s) => s.showInfo); const showRatingBar = useStore((s) => s.showRatingBar); @@ -639,7 +653,7 @@ function Canvas({ photo }: { photo: Photo }) { marginLeft: -laid.w / 2, marginTop: -laid.h / 2, transform, - transition: glide > 0 ? `transform ${glide}ms ease-in-out` : undefined, + transition: glide > 0 ? `transform ${glide}ms ${zoomCurve}` : undefined, }} > {/* The pictures, in one box that is always here: for a photo with diff --git a/src/lib/store.ts b/src/lib/store.ts index 0cafabf..502b3ec 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -69,6 +69,7 @@ interface State { showInfo: boolean; showRatingBar: boolean; showFilterBar: boolean; + experimentalSmoothZoom: boolean; filmstripHeight: number; /** The strip's edge is being dragged, and its height is changing every frame. */ stripResizing: boolean; @@ -344,6 +345,7 @@ const PERSISTED = [ "showInfo", "showRatingBar", "showFilterBar", + "experimentalSmoothZoom", "filmstripHeight", "includeSubfolders", "fileKinds", @@ -679,6 +681,7 @@ export const useStore = create((set, get) => { showInfo: load("showInfo", false), showRatingBar: load("showRatingBar", true), showFilterBar: load("showFilterBar", false), + experimentalSmoothZoom: load("experimentalSmoothZoom", false) === true, filmstripHeight: load("filmstripHeight", 120), stripResizing: false, panelResizing: false, From bc4da6c40e56a92bd848765e6b9d011549ddd06e Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:06:01 +0700 Subject: [PATCH 40/71] fix: scope zoom motion preferences to photo canvas --- src/components/cull/Loupe.tsx | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/components/cull/Loupe.tsx b/src/components/cull/Loupe.tsx index 5bbfe64..29dd12f 100644 --- a/src/components/cull/Loupe.tsx +++ b/src/components/cull/Loupe.tsx @@ -112,20 +112,6 @@ const WORTH_FULL = 1.15; export function Loupe() { const photo = useStore((s) => (s.current >= 0 ? s.photos[s.current] : undefined)); const loading = useStore((s) => s.loading); - const smoothZoom = useStore((s) => s.experimentalSmoothZoom); - const [reducedMotion, setReducedMotion] = useState(() => window.matchMedia("(prefers-reduced-motion: reduce)").matches); - useEffect(() => { - const query = window.matchMedia("(prefers-reduced-motion: reduce)"); - const update = () => setReducedMotion(query.matches); - query.addEventListener("change", update); - update(); - return () => query.removeEventListener("change", update); - }, []); - const GLIDE = smoothZoom - ? reducedMotion ? { in: 0, out: 0, box: 0 } : { in: 320, out: 280, box: 340 } - : { in: 220, out: 200, box: 250 }; - const zoomCurve = smoothZoom ? "cubic-bezier(0.22, 1, 0.36, 1)" : "ease-in-out"; - const folder = useStore((s) => s.session?.name ?? "folder"); const showInfo = useStore((s) => s.showInfo); const showRatingBar = useStore((s) => s.showRatingBar); @@ -156,6 +142,21 @@ export function Loupe() { } function Canvas({ photo }: { photo: Photo }) { + const smoothZoom = useStore((s) => s.experimentalSmoothZoom); + const [reducedMotion, setReducedMotion] = useState(() => window.matchMedia("(prefers-reduced-motion: reduce)").matches); + useEffect(() => { + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReducedMotion(query.matches); + query.addEventListener("change", update); + update(); + return () => query.removeEventListener("change", update); + }, []); + const GLIDE = smoothZoom + ? reducedMotion ? { in: 0, out: 0, box: 0 } : { in: 320, out: 280, box: 340 } + : { in: 220, out: 200, box: 250 }; + const zoomCurve = smoothZoom ? "cubic-bezier(0.22, 1, 0.36, 1)" : "ease-in-out"; + + const frame = useRef(null); const sheet = useRef(null); const [box, setBox] = useState({ w: 0, h: 0 }); From 411034ce8fdca3cf0f5690b9cd8456ab308f4e84 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:09:32 +0700 Subject: [PATCH 41/71] docs: record session, stack, experimental and preview validation --- docs/features/VALIDATION.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/features/VALIDATION.md b/docs/features/VALIDATION.md index 8171cf7..b29726e 100644 --- a/docs/features/VALIDATION.md +++ b/docs/features/VALIDATION.md @@ -101,3 +101,14 @@ These fixtures validate session, pairing, copying, persistence, and media delive ``` Existing panel defaults remain unchanged. These are personal values; standalone feature branches preserve the original first-run defaults. + + +## October 16 follow-up + +- Combined frontend: 57 tests pass; TypeScript/Vite production build passes. +- Combined Rust library: 285 tests pass, including retained session history and legacy session compatibility. +- Browser runtime: mixed native-aspect widths, fixed full-width stack filmstrip geometry, and the experimental 320ms cubic click-zoom transition verified after a fresh dev-server launch. +- Linux native preview fix: WebKitGTK dev-origin CORS allowlist enables opencull://localhost previews. Reproduced failure with the DMABUF flag both set and unset; verified JPEG and real RW2 preview dimensions after the fix. +- Stack placement lives under Layout in the combined app. Standalone branch uses Culling. +- New standalone branches: feature/recent-sessions (depends on cull-before-ingest), feature/stack-filmstrip-layout, feature/experimental-filmstrip-aspect, feature/experimental-smooth-zoom. +- Native macOS runtime remains unverified locally. From 14bec6c8d4a51b310d9639cba82f738f373b0e16 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:14:20 +0700 Subject: [PATCH 42/71] feat: add saved native interface scaling and responsive controls --- docs/features/INTERFACE_SCALE.md | 7 ++++ src-tauri/capabilities/default.json | 1 + src/App.tsx | 2 ++ src/components/InterfaceScaleSettings.tsx | 40 +++++++++++++++++++++++ src/components/cull/Dialogs.tsx | 8 +++-- src/components/cull/Toolbar.tsx | 2 +- src/lib/interface-scale.test.ts | 6 ++++ src/lib/interface-scale.ts | 4 +++ 8 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 docs/features/INTERFACE_SCALE.md create mode 100644 src/components/InterfaceScaleSettings.tsx create mode 100644 src/lib/interface-scale.test.ts create mode 100644 src/lib/interface-scale.ts diff --git a/docs/features/INTERFACE_SCALE.md b/docs/features/INTERFACE_SCALE.md new file mode 100644 index 0000000..8a5a2e7 --- /dev/null +++ b/docs/features/INTERFACE_SCALE.md @@ -0,0 +1,7 @@ +# Interface scale + +Saved desktop preference under Settings → Culling in the standalone feature, and Layout in the combined app. Uses Tauri Webview.setZoom so pixels, rems, menus, controls, and pointer coordinate systems scale together. Supports 80–200%, default 100%, plus Reset. Uses shared preferences. Browser demos retain browser zoom controls rather than simulating page zoom with CSS transforms. + +macOS requires 11+ (Tauri API requirement). Linux and Windows use their native webview zoom implementation. A narrowly scoped core:webview:allow-set-webview-zoom permission is added. Errors remain visible and unsuccessful changes are not persisted. + +Settings stay within the viewport, tabs wrap, settings content scrolls on short windows, and the culling toolbar wraps rather than clipping controls as usable CSS space decreases. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0a553e9..11efb0f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", + "core:webview:allow-set-webview-zoom", "core:window:allow-start-dragging", "core:window:allow-toggle-maximize", "core:window:allow-internal-toggle-maximize", diff --git a/src/App.tsx b/src/App.tsx index b13dafe..d51c22f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import { useInterfaceScale } from "@/components/InterfaceScaleSettings"; import { useCallback, useEffect, useRef, useState } from "react"; import { CullingView } from "@/components/cull/CullingView"; @@ -12,6 +13,7 @@ import { installShortcuts, loadBindings, saveBindings, type Action, type Combo } import { useStore } from "@/lib/store"; export default function App() { + useInterfaceScale(); const open = useStore((s) => s.session !== null); const ingesting = useStore((s) => s.ingestOpen); const [bindings, setBindings] = useState(loadBindings); diff --git a/src/components/InterfaceScaleSettings.tsx b/src/components/InterfaceScaleSettings.tsx new file mode 100644 index 0000000..ceb134e --- /dev/null +++ b/src/components/InterfaceScaleSettings.tsx @@ -0,0 +1,40 @@ +import { useEffect } from "react"; +import { create } from "zustand"; +import { inTauri } from "@/lib/api"; +import { load, save } from "@/lib/prefs"; +import { INTERFACE_SCALES, validInterfaceScale } from "@/lib/interface-scale"; + +const useScale = create<{ value: number; error: string; set: (value: number) => void }>((set) => ({ + value: validInterfaceScale(load("interfaceScale", 1)), error: "", + set(value) { set({ value: validInterfaceScale(value), error: "" }); }, +})); +let applying = Promise.resolve(); +export function useInterfaceScale() { + const value = useScale((s) => s.value); + useEffect(() => { + if (!inTauri) return; + let stale = false; + applying = applying.catch(() => {}).then(async () => { + if (stale) return; + const { getCurrentWebview } = await import("@tauri-apps/api/webview"); + await getCurrentWebview().setZoom(value); + if (!stale) { save("interfaceScale", value); useScale.setState({ error: "" }); } + }).catch((e) => { if (!stale) useScale.setState({ error: `Could not change interface scale: ${String(e)}` }); }); + return () => { stale = true; }; + }, [value]); +} +export function InterfaceScaleSettings() { + const { value, set, error } = useScale(); + return
+ Interface scale + +

{inTauri ? "Resize the whole interface, including menus, panels and thumbnails. Saved for your next launch. Smaller windows have less room for photos at larger scales." : "Use your browser’s zoom controls here. This saved setting controls scaling in the desktop app."}

+ + {error &&

{error}

} +
; +} diff --git a/src/components/cull/Dialogs.tsx b/src/components/cull/Dialogs.tsx index 81fd146..065757d 100644 --- a/src/components/cull/Dialogs.tsx +++ b/src/components/cull/Dialogs.tsx @@ -1,3 +1,4 @@ +import { InterfaceScaleSettings } from "@/components/InterfaceScaleSettings"; import { useEffect, useRef, useState } from "react"; import { HardDrive, Keyboard, ScanFace, Star, ZoomIn, type LucideIcon } from "lucide-react"; @@ -169,7 +170,7 @@ export function SettingsDialog() { return ( useStore.getState().set("settingsOpen", o)}> { @@ -268,7 +269,7 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT return ( onTab(to as SettingsTab)}> - + {SETTINGS_TABS.map(([value, label, Icon]) => ( @@ -278,8 +279,9 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT {/* One height for every tab, so the window does not jump about as they are stepped through; a tab with more than fits scrolls. */} -
+
+ +
s.closeFolder()} className="w-9"> diff --git a/src/lib/interface-scale.test.ts b/src/lib/interface-scale.test.ts new file mode 100644 index 0000000..4cd6b99 --- /dev/null +++ b/src/lib/interface-scale.test.ts @@ -0,0 +1,6 @@ +import { expect, it } from "vitest"; +import { INTERFACE_SCALES, validInterfaceScale } from "./interface-scale"; +it("accepts supported scales including 200% and rejects corrupt or unsafe saved values", () => { + for (const value of INTERFACE_SCALES) expect(validInterfaceScale(value)).toBe(value); + for (const value of [null, "1.5", 0, -1, Infinity, NaN, 10, {}, 1.234]) expect(validInterfaceScale(value)).toBe(1); +}); diff --git a/src/lib/interface-scale.ts b/src/lib/interface-scale.ts new file mode 100644 index 0000000..1a544f5 --- /dev/null +++ b/src/lib/interface-scale.ts @@ -0,0 +1,4 @@ +export const INTERFACE_SCALES = [0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2] as const; +export function validInterfaceScale(value: unknown): number { + return typeof value === "number" && INTERFACE_SCALES.some((scale) => scale === value) ? value : 1; +} From 128950f2b4643b08b9bc6bb0398305d978f60d29 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:14:20 +0700 Subject: [PATCH 43/71] feat: scale home typography with its preview area --- src/components/home/HomeView.tsx | 9 +++++---- src/components/home/home-typography.css | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 src/components/home/home-typography.css diff --git a/src/components/home/HomeView.tsx b/src/components/home/HomeView.tsx index 8881e35..e0423c2 100644 --- a/src/components/home/HomeView.tsx +++ b/src/components/home/HomeView.tsx @@ -1,3 +1,4 @@ +import "./home-typography.css"; import { useEffect, useRef, useState } from "react"; import { Circle, CircleCheck, FolderPlus, FolderSearch, Images, Keyboard, Settings, Trash2, X } from "lucide-react"; @@ -96,9 +97,9 @@ export function HomeView({ dropping }: { dropping: boolean }) { }; return ( -
-
-
+
+
+

OpenCull

Culling Software made for Photographers

@@ -153,7 +154,7 @@ export function HomeView({ dropping }: { dropping: boolean }) { {openError &&
Could not open that folder: {openError}
} -
+
{recents.length === 0 ? (
diff --git a/src/components/home/home-typography.css b/src/components/home/home-typography.css new file mode 100644 index 0000000..c347b28 --- /dev/null +++ b/src/components/home/home-typography.css @@ -0,0 +1,15 @@ +/* Keep the opening page's hierarchy proportional to its preview area. */ +.home-content { container-type: inline-size; } +.home-heading h1 { font-size: 2.75rem; line-height: 1.12; letter-spacing: -0.025em; } +.home-heading > p { font-size: 1rem; line-height: 1.5; } +.home-recents h2 { font-size: 1.5rem; line-height: 1.25; } +.home-recents { padding-top: 1.5rem; } +@supports (font-size: 1cqi) { + .home-heading h1 { font-size: clamp(2.25rem, 1.75rem + 2cqi, 3.5rem); } + .home-heading > p { font-size: clamp(0.875rem, 0.75rem + 0.4cqi, 1.125rem); } + .home-recents h2 { font-size: clamp(1.375rem, 1rem + 1cqi, 2rem); } +} +@media (max-width: 40em) { + .home-header { padding: 1.5rem; } + .home-recents { padding: 1.5rem; } +} From a038681f059c2dab5196009451661e090b2fe8ca Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:14:50 +0700 Subject: [PATCH 44/71] feat: keep RapidRAW home actions in a compact horizontal row --- src/visual-theme.css | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/visual-theme.css b/src/visual-theme.css index 1e273eb..bc7cf9f 100644 --- a/src/visual-theme.css +++ b/src/visual-theme.css @@ -191,3 +191,38 @@ :root[data-visual-theme="rapidraw"] .rapidraw-artwork { display: none; } :root[data-visual-theme="rapidraw"] .home-content { border-radius: 6px; padding: 40px 24px 24px; } } + +/* Compact application header: actions share a row rather than filling columns. */ +:root[data-visual-theme="rapidraw"] .home-content { + justify-content: flex-start; + padding: 32px clamp(24px, 3vw, 48px) 24px; +} +:root[data-visual-theme="rapidraw"] .home-header { flex-wrap: wrap; gap: 16px; } +:root[data-visual-theme="rapidraw"] .home-heading { flex: 1 1 260px; min-width: 0; } +:root[data-visual-theme="rapidraw"] .home-heading h1 { + margin: 0 0 8px; + font-size: clamp(2.25rem, 1.75rem + 2cqi, 3.5rem); + line-height: 1.12; + letter-spacing: -0.025em; +} +:root[data-visual-theme="rapidraw"] .rapidraw-intro { + max-width: 34rem; + margin: 0; + font-size: 1rem; + line-height: 1.5; +} +:root[data-visual-theme="rapidraw"] .home-controls { + width: auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 16px; +} +:root[data-visual-theme="rapidraw"] .home-actions { display: flex; flex-wrap: wrap; gap: 12px; } +:root[data-visual-theme="rapidraw"] .home-header button { flex: 0 0 auto; order: initial; } +:root[data-visual-theme="rapidraw"] .home-header .home-open, +:root[data-visual-theme="rapidraw"] .home-header .home-ingest { flex: 0 0 auto; } +:root[data-visual-theme="rapidraw"] .home-header button[aria-label] { order: initial; padding: 0; width: 40px; height: 40px; } +:root[data-visual-theme="rapidraw"] .home-recents { flex: 1 1 auto; max-height: none; min-height: 0; } +:root[data-visual-theme="rapidraw"] .rapidraw-footer { margin-top: 16px; } From 3275afd70e15b398c7e7957117e56c3c897a86a6 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:15:26 +0700 Subject: [PATCH 45/71] style: tighten home toolbar gaps without shrinking controls --- src/visual-theme.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/visual-theme.css b/src/visual-theme.css index bc7cf9f..b6b5ae7 100644 --- a/src/visual-theme.css +++ b/src/visual-theme.css @@ -226,3 +226,6 @@ :root[data-visual-theme="rapidraw"] .home-header button[aria-label] { order: initial; padding: 0; width: 40px; height: 40px; } :root[data-visual-theme="rapidraw"] .home-recents { flex: 1 1 auto; max-height: none; min-height: 0; } :root[data-visual-theme="rapidraw"] .rapidraw-footer { margin-top: 16px; } +:root[data-visual-theme="rapidraw"] .home-controls { gap: 8px; } +:root[data-visual-theme="rapidraw"] .home-actions { gap: 8px; } +:root[data-visual-theme="rapidraw"] .home-actions button { padding-inline: 12px; } From c8006f16847066c2d2c7ef627e71d3fd87024a1e Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:15:38 +0700 Subject: [PATCH 46/71] style: keep identity above horizontal home actions --- src/visual-theme.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/visual-theme.css b/src/visual-theme.css index b6b5ae7..a7387e4 100644 --- a/src/visual-theme.css +++ b/src/visual-theme.css @@ -229,3 +229,6 @@ :root[data-visual-theme="rapidraw"] .home-controls { gap: 8px; } :root[data-visual-theme="rapidraw"] .home-actions { gap: 8px; } :root[data-visual-theme="rapidraw"] .home-actions button { padding-inline: 12px; } +/* Identity always occupies its own row above the horizontal actions. */ +:root[data-visual-theme="rapidraw"] .home-heading { flex: 1 0 100%; } +:root[data-visual-theme="rapidraw"] .home-controls { flex: 1 0 100%; } From 76a6b34089387b8a6734303ed379e86f659b4909 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:15:38 +0700 Subject: [PATCH 47/71] style: place home actions on their own row below identity --- src/components/home/home-typography.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/home/home-typography.css b/src/components/home/home-typography.css index c347b28..e6f48c0 100644 --- a/src/components/home/home-typography.css +++ b/src/components/home/home-typography.css @@ -13,3 +13,5 @@ .home-header { padding: 1.5rem; } .home-recents { padding: 1.5rem; } } +.home-heading { flex: 1 0 100%; min-width: 0; } +.home-controls { flex: 1 0 100%; justify-content: flex-start; } From a823f2fa4d66b418f7e9ab730911bd1beb9cd048 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:16:00 +0700 Subject: [PATCH 48/71] style: expose recent session layout hooks for visual themes --- src/components/home/RecentSessions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/home/RecentSessions.tsx b/src/components/home/RecentSessions.tsx index 9b34115..b838887 100644 --- a/src/components/home/RecentSessions.tsx +++ b/src/components/home/RecentSessions.tsx @@ -49,10 +49,10 @@ export function RecentSessions() { const [openError,setOpenError] = useState(""); if (!sessions.length && !error) return null; const open = async (id: string) => { setOpening(id);setOpenError("");try { await resume({kind:"card",id}); } catch(e) { setOpenError(String(e)); } finally { setOpening(null); } }; - return
+ return

Recent Sessions ({sessions.length})

{(error || openError) &&

{error || openError}

} -
+
{sessions.map((session) => + } {selected.size > 0 && ( + + + } +
+ {selected.size > 0 &&

Deletes saved session history. Source photos stay on disk.

} {(error || openError) &&

{error || openError}

}
- {sessions.map((session) => )} + {sessions.map((session) =>
+ + +
)}
; } diff --git a/src/lib/review-api.ts b/src/lib/review-api.ts index a8b594f..cb9dcf6 100644 --- a/src/lib/review-api.ts +++ b/src/lib/review-api.ts @@ -27,6 +27,18 @@ export async function listReviews(): Promise { id:r.id ?? "latest",name:r.name ?? "Card session",sources:r.sources,count:r.items.length,updatedAt:r.updatedAt ?? (Number(r.id) || 0),stage:r.stage,cover:r.items[0] ?? null, })).sort((a,b) => b.updatedAt-a.updatedAt).slice(0,24); } +export async function removeReviews(ids: string[]): Promise { + if (inTauri) { + saving = saving.catch(() => {}).then(() => invoke("review_remove", { ids })); + await saving; + } else { + const kept = browserHistory().filter((r) => !ids.includes(r.id ?? "latest")).sort((a,b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + localStorage.setItem("opencull.session.history", JSON.stringify(kept)); + if (kept.length) localStorage.setItem("opencull.session.latest", JSON.stringify(kept[0])); + else localStorage.removeItem("opencull.session.latest"); + } + window.dispatchEvent(new Event("opencull:sessions-changed")); +} export async function loadReview(id?: string): Promise { const value: ReviewSession | null = inTauri ? await invoke("review_load", { id }) : id ? browserHistory().find((r) => (r.id ?? "latest") === id) ?? null : JSON.parse(localStorage.getItem("opencull.session.latest") ?? "null"); if (value && (value.version !== 1 || !Array.isArray(value.items) || !Array.isArray(value.sources) || typeof value.decisions !== "object")) throw new Error("Saved card session is not supported"); From 032e7e77703caee84132e83c9ad72253100da7cb Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:23:02 +0700 Subject: [PATCH 57/71] style: retain compact theme cards with session selection controls --- src/visual-theme.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/visual-theme.css b/src/visual-theme.css index 7d65a83..f91c134 100644 --- a/src/visual-theme.css +++ b/src/visual-theme.css @@ -242,10 +242,10 @@ grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); gap: 16px; } -:root[data-visual-theme="rapidraw"] .recent-sessions-grid > button { max-width: 320px; } -:root[data-visual-theme="rapidraw"] .recent-sessions-grid > button > div:first-child { aspect-ratio: 16 / 9; } -:root[data-visual-theme="rapidraw"] .recent-sessions-grid > button > div:nth-child(2) { padding: 12px; } -:root[data-visual-theme="rapidraw"] .recent-sessions-grid > button > div:nth-child(2) > div { font-size: 0.875rem; } +:root[data-visual-theme="rapidraw"] .recent-sessions-grid > .recent-session-card { max-width: 320px; } +:root[data-visual-theme="rapidraw"] .recent-sessions-grid .recent-session-open > div:first-child { aspect-ratio: 16 / 9; } +:root[data-visual-theme="rapidraw"] .recent-sessions-grid .recent-session-open > div:nth-child(2) { padding: 12px; } +:root[data-visual-theme="rapidraw"] .recent-sessions-grid .recent-session-open > div:nth-child(2) > div { font-size: 0.875rem; } :root[data-visual-theme="rapidraw"] .recent-sessions-grid p { font-size: 0.75rem; line-height: 1.5; } /* Let the welcome block breathe before the first row of photographs. */ :root[data-visual-theme="rapidraw"] .home-content { padding-top: 40px; } From 63553daa26e1bda390ab2f3adc8b87c3c778cf01 Mon Sep 17 00:00:00 2001 From: Wisp <128091900+wispdevon@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:24:34 +0700 Subject: [PATCH 58/71] feat: add Wisp theme with clear setting surfaces and app design guidelines --- DESIGN.md | 29 +++++++ src/components/VisualThemeSettings.tsx | 4 +- src/components/cull/Dialogs.tsx | 6 +- src/lib/visual-theme.test.ts | 2 + src/lib/visual-theme.ts | 4 +- src/wisp-theme.css | 110 +++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 DESIGN.md create mode 100644 src/wisp-theme.css diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..2cf0522 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,29 @@ +# OpenCull interface design guidelines + +These rules apply across Home, ingest, culling, settings, menus and dialogs. Wisp is the reference theme for readable interface contrast; other presets retain their visual character. + +## Readability and surfaces + +Use semantic tokens for background, panel, row, input, selected state, text and focus. Avoid a single grey for every level. Wisp separates an ink workspace (#111827), navy panels (#162033), raised rows (#1d293d), and controls (#2b3b55). Borders (#71839f) identify controls and setting boundaries. Primary text is #f3f6ff; secondary copy is #bac8df. Violet (#b7a6ff) marks primary actions and active controls. Light mode uses paper surfaces with dark ink and a deeper violet. + +Normal text must reach 4.5:1 against its actual background; large text, icons and necessary control boundaries must reach 3:1. Test computed colours in every colour mode, including hover and selected states. Do not lower descriptive copy opacity until it becomes difficult to read. Colour never carries state alone: use position, border, checkmark, text or icon as well. + +## Hierarchy and density + +Keep identity and supporting text above a horizontal action row. Use an 8px spacing rhythm: 8 within groups, 16 between controls or rows, 24 between sections, 32 before image collections. Setting rows have their own surface, 1px visible border, 8px radius and 16px padding; title and description belong together, control aligned at the start. Do not put borders around every text fragment. + +Titles use a clear weight and size hierarchy. Home headings can scale from 36–56px with bounded container sizing. Section headings use 22–32px. Setting labels use 14px semibold; explanatory text uses 13px with 1.6 line height. Retain the user's font preference and native interface scale. Avoid fixed heights on text blocks; allow wrapping and vertical scrolling. + +Controls need a 40px hit area where practical, with a visible keyboard focus outline separated from their edge. Selected tabs need both a border and stronger text; checked switches need a contrasting thumb position and track. Disabled controls retain labels and never look active. Menus, dialogs and popovers use opaque readable surfaces. Dialog content stays within the viewport and scrolls on smaller windows. + +## Photograph-first workspace + +Photo pixels, marks and rating colours are never recoloured by the theme. Keep the loupe surround neutral; 18% grey is #767676. Native-aspect strips use displayed rotation and crop proportions. Keep selection rings, rank labels and resize edges visible over mixed photographs. Theme accents belong on interface controls, not as a tint over previews. + +Recent folder contact sheets stay visible. Compact presets may reduce card dimensions without turning images into text-only entries. Bulk actions appear after the first selection. Removing recent history must clearly distinguish itself from deleting source photos. Errors use readable text, not just red borders. Loading retains useful previews where possible. + +## Verification + +Check Home, ingest, culling and all Settings tabs in dark, light and 18% grey, at small and large windows and at 100%, 150% and 200% native interface scale. Verify keyboard focus, scrolling, switch/tab state contrast and forced colours. Use screenshots plus measured contrast; do not infer accessibility from palette names. + +Wisp is supplied by feature/wisp-theme and depends on feature/rapidraw-theme's preset infrastructure. It changes no personal default automatically and loads no remote fonts or assets. diff --git a/src/components/VisualThemeSettings.tsx b/src/components/VisualThemeSettings.tsx index 7fa1039..c0df53d 100644 --- a/src/components/VisualThemeSettings.tsx +++ b/src/components/VisualThemeSettings.tsx @@ -3,6 +3,7 @@ import { create } from "zustand"; import { load, save } from "@/lib/prefs"; import { validVisualTheme, visualThemeFont, type VisualTheme } from "@/lib/visual-theme"; import "@/visual-theme.css"; +import "@/wisp-theme.css"; const useVisualTheme = create<{ theme: VisualTheme; @@ -48,9 +49,10 @@ export function VisualThemeSettings() { className="rounded border border-input bg-background px-2 py-1 text-foreground"> + -

A photographic split welcome screen, quiet neutral surfaces, and simple session actions. Your colour mode stays separate.

+

OpenCull is the original layout. RapidRAW adds a photographic split page. Wisp adds distinct surfaces, stronger contrast and violet accents. Your colour mode stays separate.

{theme === "rapidraw" &&