From 5b34f3efd693c4b6fc9df94f679c4d170d6871fa Mon Sep 17 00:00:00 2001 From: Eliseo Robles Date: Thu, 30 Jul 2026 15:24:02 -0500 Subject: [PATCH 1/3] Open the project to contributors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository had all the writing done and none of the surfaces open: no CI, no issue templates, no code of conduct, and a README that said contributions were welcome without saying where to start. A vague invitation leaves the best-evidenced newcomer barrier — finding a way in — completely intact. CI runs on a macos-26 runner pinned to Xcode 26.3, the same build the suite is verified against. The second job is worth as much as the first: it fails if the private centering tool, any card image, or the internal working documents ever become tracked. Those guards exist today only in .gitignore and a pre-commit hook that a fresh clone does not have until someone remembers to point core.hooksPath at it. CI is the half that cannot be forgotten. The README now names the three things standing between this and the best open-source scanning app on the Mac — one supported scanner, no downloadable binary, and two review actions that need a mouse — and says where each one lives, because none of them fail for architectural reasons. Also states plainly that a scanner is not required. That is the most common reason someone assumes they cannot help, and it is wrong: 251 tests run without one, and --synthetic exercises the real crop, verdict, deskew and publication path. SPEC.md pointed at a research file that is no longer published. Fixed. --- .github/ISSUE_TEMPLATE/bug.yml | 81 ++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/detection-report.yml | 100 ++++++++++++ .github/ISSUE_TEMPLATE/hardware-datapoint.yml | 95 ++++++++++++ .github/ISSUE_TEMPLATE/spec-hole.yml | 40 +++++ .github/workflows/ci.yml | 144 ++++++++++++++++++ CODE_OF_CONDUCT.md | 64 ++++++++ CONTRIBUTING.md | 60 ++++++++ README.md | 55 +++++++ SPEC.md | 5 +- 10 files changed, 650 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/detection-report.yml create mode 100644 .github/ISSUE_TEMPLATE/hardware-datapoint.yml create mode 100644 .github/ISSUE_TEMPLATE/spec-hole.yml create mode 100644 .github/workflows/ci.yml create mode 100644 CODE_OF_CONDUCT.md diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..151ace4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,81 @@ +name: Bug report +description: Something in the app behaved wrongly. For a bad crop use the detection template instead. +title: "[bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + ⚠️ **Never attach a card scan.** Paste text, or a screenshot with any + card artwork cropped out. + + - type: textarea + id: what_happened + attributes: + label: What happened + description: What you did, what you expected, and what you got instead. + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: > + If you can reproduce it with `bash Scripts/bundle-app.sh --synthetic`, + say so — that means anyone can fix it without a scanner, which makes it + far more likely to get fixed quickly. + value: | + 1. + 2. + 3. + validations: + required: true + + - type: dropdown + id: how_run + attributes: + label: How were you running ScanDeck? + options: + - "bash Scripts/bundle-app.sh --run (real scanner)" + - "bash Scripts/bundle-app.sh --synthetic (no scanner)" + - "swift run ScanDeck" + - "swift run scandeck-m0 (command line)" + validations: + required: true + + - type: markdown + attributes: + value: | + If you chose `swift run ScanDeck` and the symptom is that **typing does + nothing**: that is a known trap rather than a bug. A bare SwiftPM + executable registers as background-only and receives no keyboard events. + Use `bash Scripts/bundle-app.sh --run` instead and check with + `lsappinfo list | grep -A4 ScanDeck` that you get `type="Foreground"`. + + - type: input + id: versions + attributes: + label: macOS version, and scanner if one was attached + placeholder: "macOS 26.5.2, fi-8170" + validations: + required: true + + - type: textarea + id: diagnostics + attributes: + label: Diagnostics report + description: > + Settings → Copy Diagnostics Report. Content-free by construction — no + path, file name or batch name can reach it. + render: shell + validations: + required: false + + - type: checkboxes + id: safety + attributes: + label: Before you submit + options: + - label: I have not attached any card scan or personal data + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d384785 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: How changes land, and what to expect back + url: https://github.com/eliseorobles/ScanDeck/blob/main/CONTRIBUTING.md + about: Branch, PR, review. Also which changes want an issue opened first, and whether you need a scanner (usually not). + - name: Will my scanner work? + url: https://github.com/eliseorobles/ScanDeck/blob/main/README.md#about-scanner-support + about: Only the fi-8170 today. If you have something else, open a hardware datapoint — that is how the list grows. diff --git a/.github/ISSUE_TEMPLATE/detection-report.yml b/.github/ISSUE_TEMPLATE/detection-report.yml new file mode 100644 index 0000000..3f7aec3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/detection-report.yml @@ -0,0 +1,100 @@ +name: ScanDeck measured a card wrong +description: A crop clipped the card, kept too much, or the two sides disagreed when they should not have. +title: "[detection] " +labels: ["detection", "image-pipeline"] +body: + - type: markdown + attributes: + value: | + This is the bug class the project cares most about, because it is the one + that can publish a wrong image that still looks plausible. + + ⚠️ **Do not attach the scan.** Card images are your property. Everything + below is numbers and text, which is all we need to reproduce the + reasoning — and if it turns out we need pixels, we will talk about how to + share them privately rather than in a public issue. + + - type: dropdown + id: symptom + attributes: + label: What went wrong? + options: + - The crop cut into the card + - The crop kept far too much background + - The two sides of one card measured very differently + - Complete Batch was blocked and I think it should not have been + - Complete Batch was allowed and I think it should not have been + - The card published visibly crooked + - Something else + validations: + required: true + + - type: textarea + id: what_the_app_said + attributes: + label: What did ScanDeck say? + description: > + The margin badge on the row, the text of any banner, and the completion + blocker if there was one. Copy the wording exactly — the copy is part of + the product and getting it wrong is itself a bug. + validations: + required: true + + - type: input + id: footprints + attributes: + label: The two measured footprints, if the card row showed them + placeholder: "front 2104 x 2942 px, back 2098 x 2951 px" + validations: + required: false + + - type: dropdown + id: media + attributes: + label: What was in the feeder? + options: + - Raw (bare) card + - Card in a toploader + - Card in a semi-rigid (note - not a supported format) + - Card in a penny sleeve only (note - not a supported format) + - Something else + validations: + required: true + + - type: input + id: scanner + attributes: + label: Scanner and macOS version + placeholder: "fi-8170, macOS 26.5.2" + validations: + required: true + + - type: textarea + id: diagnostics + attributes: + label: Diagnostics report + description: > + Settings → Copy Diagnostics Report. It is content-free by construction — + it has no field a path, file name or batch name could go in, and there is + a test that proves it. + render: shell + validations: + required: false + + - type: markdown + attributes: + value: | + **If you keep the masters**, you can turn this into a permanent + regression yourself without sending anyone an image: + + ```sh + SCANDECK_CORPUS_DIR= SCANDECK_CORPUS_REPORT=/tmp/r.csv \ + swift test -c release --filter CardImageCropperCorpusDiagnostics + ``` + + That writes one row per side with both threshold reads, the dominant + component, the floor-supported bounds and the raw occupancy outside them + — which is the difference between "one side over-detected" and "the other + under-detected", opposite fixes on identical-looking evidence. Pasting + the CSV rows for the bad pair is the single most useful thing you can + attach. diff --git a/.github/ISSUE_TEMPLATE/hardware-datapoint.yml b/.github/ISSUE_TEMPLATE/hardware-datapoint.yml new file mode 100644 index 0000000..0dcecae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/hardware-datapoint.yml @@ -0,0 +1,95 @@ +name: Hardware datapoint +description: Report what ScanDeck saw on a scanner other than the fi-8170. This is the most valuable report the project can get. +title: "[hardware] " +labels: ["hardware-datapoint", "scanner-support"] +body: + - type: markdown + attributes: + value: | + Thank you — this is genuinely the highest-value contribution available. + + ScanDeck supports exactly one scanner today, and widening that is capped + by nobody having capability data for anything else. Your report is the + seed of the supported-scanner matrix. + + **You do not need the scan to have worked.** A device that fails is just + as useful as one that succeeds, as long as we can see *which* capability + was missing. + + ⚠️ **Never attach a card scan.** Card images are your property and this + repository has never contained one. Paste text output only. + + - type: input + id: model + attributes: + label: Scanner make and model + placeholder: "Ricoh fi-8040 / Brother ADS-4300N / Epson DS-C490" + validations: + required: true + + - type: input + id: macos + attributes: + label: macOS version + placeholder: "26.5.2 (25F84)" + validations: + required: true + + - type: dropdown + id: connection + attributes: + label: How is it connected? + options: + - USB + - Network (Wi-Fi or Ethernet) + - Both available, reporting USB + - Both available, reporting network + validations: + required: true + + - type: textarea + id: list + attributes: + label: Output of `swift run scandeck-m0 --list` + description: This works on any scanner and is not gated. + render: shell + validations: + required: true + + - type: textarea + id: inspect + attributes: + label: Output of `swift run scandeck-m0 --inspect` + description: > + Paste the whole thing, including any error. If it refuses your device, + that refusal IS the datapoint — paste it verbatim. Note that inspection + is currently gated to the fi-8170; if it refuses on those grounds, say + so and the `--list` output above is still useful on its own. + render: shell + validations: + required: false + + - type: checkboxes + id: capabilities + attributes: + label: Which of these does the scanner have? + description: > + These four are what the pipeline actually depends on. Tick what you know; + leave the rest blank rather than guessing. The vendor's spec sheet is a + fine source. + options: + - label: Duplex ADF (images both sides in one pass) + - label: 600 dpi + - label: 24-bit colour + - label: An overscan or margin mode that captures background *around* the detected media + - label: Automatic media-size detection + + - type: textarea + id: notes + attributes: + label: Anything else + description: > + Vendor driver name and version, whether Apple Image Capture sees it, or + anything that surprised you. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/spec-hole.yml b/.github/ISSUE_TEMPLATE/spec-hole.yml new file mode 100644 index 0000000..19efa95 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/spec-hole.yml @@ -0,0 +1,40 @@ +name: Spec hole or design disagreement +description: SPEC.md says something wrong, missing, over-engineered, or that you think is a mistake. +title: "[spec] " +labels: ["spec"] +body: + - type: markdown + attributes: + value: | + This is a real contribution and it is explicitly invited. SPEC.md is the + contract, and it has been changed before because someone argued it was + wrong — three of the seven invariants carry amendments for exactly that + reason. + + You do not need code, a scanner, or a patch. An argument is enough. + + - type: textarea + id: what + attributes: + label: What does the spec say, and where? + description: Quote it, with the section number. + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What is wrong with it? + description: > + Wrong, missing, over-engineered, internally inconsistent, or contradicted + by the code — say which, and why it matters to someone using the app. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What should it say instead? + description: Optional. "I think this is wrong but I don't know the fix" is a legitimate issue. + validations: + required: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ca16008 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,144 @@ +name: CI + +# ScanDeck compiles only against the macOS 26 SDK — `Package.swift` pins +# `.macOS(.v26)` and swift-tools-version 6.2 — so every job that touches Swift +# has to run on a `macos-26` runner. That image went generally available on +# 2026-02-26, and because this repository is public it costs nothing: standard +# GitHub-hosted runners are free and unmetered for public repositories. +# +# Nothing here needs an fi-8170. The suites that do need real hardware, a real +# corpus, or the private centering tool are all gated — on a `SCANDECK_*` +# environment variable, or on a file `.gitignore` deliberately keeps out of +# this repository — so they skip and the rest runs. + +on: + push: + branches: [main] + pull_request: + +# A second push to the same branch makes the first run's answer worthless, and +# a stale green is worse than no answer. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-and-test: + name: Build and test (macOS 26) + runs-on: macos-26 + timeout-minutes: 30 + + env: + # Pinned rather than left to the image default. The `macos-26` image + # currently defaults to Xcode 26.5, which ships Swift 6.3; this pin is + # Xcode 26.3 (build 17C529, Swift 6.2.4), the exact toolchain the 251-test + # suite has been verified green against. Strict concurrency is on across + # the whole package and a compiler bump is precisely where new + # diagnostics land, so moving this number is a deliberate change with its + # own review — not something a runner-image refresh gets to do overnight. + XCODE_APP: /Applications/Xcode_26.3.app + + steps: + - name: Check out + uses: actions/checkout@v7 + + # Fail loudly and legibly if the image has retired this Xcode, rather + # than silently compiling against whatever the default happens to be. + - name: Select Xcode + run: | + if [ ! -d "$XCODE_APP" ]; then + echo "::error::$XCODE_APP is not installed on this runner image." + echo "Xcodes present on the runner:" + ls -d /Applications/Xcode*.app + exit 1 + fi + sudo xcode-select -s "$XCODE_APP" + + - name: Record the toolchain + run: | + sw_vers + xcodebuild -version + swift --version + + # ScanDeck has zero external package dependencies, so this cache buys + # incremental compilation and nothing else — there are no checkouts to + # restore. Keep it keyed tightly enough that a restored `.build` is only + # ever a speed-up: the Xcode version is in the key because a toolchain + # change invalidates every module inside, and the source hash means the + # exact key only hits on a re-run of the same commit. Ordinary runs land + # on the prefix via restore-keys and rebuild what changed. It is roughly + # 335 MB, well inside the 10 GB repository cache budget. + - name: Cache SwiftPM build products + uses: actions/cache@v6 + with: + path: .build + key: >- + ${{ runner.os }}-${{ runner.arch }}-spm-xcode26.3-${{ + hashFiles('Package.swift', 'Sources/**/*.swift', 'Tests/**/*.swift') + }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-spm-xcode26.3- + + - name: Build + run: swift build + + # Release, not debug, and it is not a preference: `CardImageCropper` + # analyses full-resolution 600 dpi masters, which costs ~47 s per master + # in debug against ~0.25 s in release. + - name: Test + run: swift test -c release + + repository-hygiene: + # Free, ~15 s, and needs no macOS. This is the remote half of guards that + # currently exist only on the operator's machine: `.gitignore` plus the + # `.githooks/pre-commit` hook, which a fresh clone does not have until + # someone remembers `git config core.hooksPath .githooks`. + name: Repository hygiene + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check out + uses: actions/checkout@v7 + + - name: Nothing private reached the public remote + run: | + status=0 + fail() { echo "::error::$1"; status=1; } + + # The centering tool is private. This repository has a live public + # remote, so a stray `git add -f` is one push from publishing it. + tracked_centering="$(git ls-files centering)" + if [ -n "$tracked_centering" ]; then + fail "The private centering tool is tracked: $(echo "$tracked_centering" | tr '\n' ' ')" + fi + + # Card scans are the operator's images. The corpus lives in + # Application Support and must never be committed. + tracked_images="$(git ls-files -- '*.jpg' '*.jpeg' '*.png' '*.tiff' '*.tif' '*.heic')" + if [ -n "$tracked_images" ]; then + fail "Card images are tracked: $(echo "$tracked_images" | tr '\n' ' ')" + fi + + # Internal working documents: the ticket queue carries real consignor + # and batch names, and the PaperStream research is a derivative of one + # copyrighted source. + tracked_private="$(git ls-files -- CLAUDE.md PENDINGTICKETS.md 'PaperStream_for_Mac_UI_Spec*' docs/research/paperstream-capture-inventory.md)" + if [ -n "$tracked_private" ]; then + fail "Private working documents are tracked: $(echo "$tracked_private" | tr '\n' ' ')" + fi + + # "PaperStream" is PFU Limited's trademark. Nominative use in prose is + # fine and appears throughout the docs; it must never become part of a + # shipped product name, target name, or bundle identifier. + if grep -i -q 'paperstream' Package.swift Resources/ScanDeck-Info.plist; then + fail "PaperStream appears in Package.swift or the app's Info.plist." + fi + + if [ "$status" -eq 0 ]; then + echo "Repository hygiene checks passed." + fi + exit "$status" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a035e69 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,64 @@ +# Code of Conduct + +## The short version + +Be kind, be concrete. Assume the person on the other end is trying to help. + +Disagreement about the code is welcome and expected — this project changes its +mind when the evidence says to, and "I think this constant is wrong, here is a +corpus run" is the most valuable thing you can bring. Disagreement about a +person is not. + +## The longer version + +We pledge to make participation a harassment-free experience for everyone, +regardless of age, body size, visible or invisible disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, education, +socio-economic status, nationality, personal appearance, race, religion, or +sexual identity and orientation. + +Examples of behaviour that contributes to a positive environment: + +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility, apologising to those affected by our mistakes, and + learning from the experience +- Focusing on what is best for the project and the people using it + +Examples of unacceptable behaviour: + +- Sexualised language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Scope + +This applies in all project spaces — issues, pull requests, discussions, commit +messages — and when someone is representing the project in public. + +## Reporting + +Report unacceptable behaviour to **eliseo@eliseorobles.me**. Reports are +reviewed and investigated promptly and fairly, and the reporter's privacy and +security are respected. + +If the report concerns the maintainer, say so directly; you are also free to +report to GitHub through their abuse-reporting flow, which is outside this +project's control. + +## Enforcement + +The maintainer is responsible for clarifying and enforcing these standards and +will take appropriate and fair corrective action in response to any behaviour +judged inappropriate, threatening, offensive, or harmful — including editing or +removing comments, commits, code, issues and other contributions, and where +necessary blocking a person from the project. + +## Attribution + +Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b496f5c..f9758ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,66 @@ Say in the PR whether you ran against real hardware or not. "Tests pass, no scanner" is a completely fine and useful thing to write; claiming a hardware behaviour nobody observed is not. +## What you can expect back + +**A human reply within three days**, even when the reply is "not yet" or "not +this". If you have waited longer than that, the notification was missed rather +than ignored — say so on the thread and it will be picked up. + +This is a small project maintained by one person, so the honest version is: +review may be slower than you would like, but silence is never the answer and a +stale PR is a failure on this end, not yours. + +## Open an issue first for anything load-bearing + +Not bureaucracy — this codebase has unusually strong invariants, and the +expensive place to discover a scope mismatch is after you have written the code. + +**Ask first** if the change touches: + +- a `BatchSession` gate, or any safety rule +- `CardImageCropper` detection constants — these were measured against real + scanner artefacts and a plausible-sounding rewrite is how a defect gets back in +- an invariant in [SPEC.md](SPEC.md), or the v1 scope +- the `ImageCaptureCore` boundary + +**Just send it** for docs, operator copy, tests, accessibility, hardware +datapoints, or anything already described in an open issue. + +One concrete example of why: `AcceptedPairSizeMismatch` stores the two measured +footprints rather than a `Bool`, and that looks like something to simplify. It +is not — a `Bool` would carry a person's consent onto a re-measured pair they +never saw. Several things here are shaped that way, and the reasoning usually +lives in a doc comment right above the code. + +## Do you need an fi-8170? + +Almost certainly not. Every issue is labelled `needs-fi-8170` or +`no-hardware-needed`, and the second group is much larger: the whole reducer, +the SwiftUI shell, image encoding and DPI metadata, the crash-safety primitives, +error copy, accessibility, docs and CI. + +`swift test` runs 251 tests with no scanner attached, and +`bash Scripts/bundle-app.sh --synthetic` runs the real crop, margin verdicts, +deskew and atomic publication against a simulated page source — so you can +reproduce and fix most UI and pipeline behaviour without hardware. + +## Using AI assistance + +Allowed, and it does not need to be hidden — but note it in the PR so a reviewer +knows what kind of scrutiny to apply, and hold it to the same bar as anything +you wrote by hand. You are the author of what you submit. + +Two rules that bind harder here than in most projects, because this codebase's +safety story rests on things a model cannot check: + +- **An LLM cannot observe hardware.** Never let generated text become a claim + about what a scanner did. If a pass was not physically run, say so. +- **Detector constants need evidence, not rationale.** A change to + `CardImageCropper`'s thresholds must cite a corpus run. A confident-sounding + explanation for a new number is exactly the failure mode those constants were + measured to prevent. + ## The most useful contributions right now - **Exercise the suite.** Run `swift test` on Apple Silicon with Xcode 26 — diff --git a/README.md b/README.md index e0a3bd7..59845a1 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,61 @@ icon, accessibility passes, CI, and a supported-scanner matrix. There is no CI yet, so there is no build badge — `swift test` on your own machine is the honest signal. +## Help make this the best scanning app on the Mac + +That is the actual goal, and it is reachable — but not by one person with one +scanner. + +ScanDeck already does one thing nothing else on the Mac does: it **measures the +border it is about to publish and refuses to publish a batch it cannot vouch +for.** Every published pixel is captured background. When a card's two sides +disagree by more than 140 px the batch stays blocked until a person overrules +that specific card, and the overrule is stored against the two measured +footprints so a re-scan invalidates it. That is not a slogan — it is a reducer, +and you can go read it. + +"Best open-source Mac scanning app" would mean that same guarantee holds **on a +scanner you already own, in an app you can download, driven from a keyboard.** +All three of those currently fail, and none of them fail for architectural +reasons: + +| The gap | Why it is not hard | Where | +|---|---|---| +| **One supported scanner** | The hardware seam is a single protocol. The allowlist is a two-entry `Set` | `ScannerInventory.swift:23` | +| **No downloadable app** | The bundle script already builds and signs it; it needs notarization and a release | `Scripts/bundle-app.sh` | +| **Mouse required** | Two review actions are hover-only, with no keyboard route | `BatchWorkspaceView.swift:741` | + +**Widening scanner support is the highest-value thing anyone can do here**, and +it caps everything else — more scanners means more users, and contributors come +from users. The correctness guarantee is exactly what makes the project worth +widening, so the work is to grow the device list *without* loosening it. The +test harness for that already exists: 251 tests run with no scanner, and +`--synthetic` exercises the real crop, verdict, deskew and publication path. + +### You do not need a scanner + +This is the most common reason people assume they can't help, and it is wrong. +Every issue is labelled `needs-fi-8170` or `no-hardware-needed`, and almost +everything is the second one. The reducer, the SwiftUI shell, the JPEG and DPI +metadata, the crash-safety primitives, the error copy, accessibility, docs and +CI are all reachable with nothing but a Mac. + +### Good places to start + +- **[Open issues](https://github.com/eliseorobles/ScanDeck/issues)** — every one + names the files to touch and the command that proves it worked. +- **Have a different duplex ADF scanner?** Run `swift run scandeck-m0 --list` + and `--inspect` and open a hardware datapoint. That is the seed of the + supported-scanner matrix and it costs you five minutes. +- **Found a card ScanDeck measured wrong?** That is the most valuable bug report + this project can get. Tell us what the two sides measured — but never attach + the scan. +- **Read [SPEC.md](SPEC.md) and poke holes in it.** It is the contract, and + disagreeing with it in an issue is a real contribution. + +You will get a reply. See [CONTRIBUTING.md](CONTRIBUTING.md) for what to expect +and how changes land. + ## Contributing Contributions go through a branch and a pull request: diff --git a/SPEC.md b/SPEC.md index 68f138e..7fadab2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -138,11 +138,12 @@ Every milestone is validated on Apple Silicon against the physical fi-8170 with ## 8. Open questions & research -Research is in `docs/research/` (all three tracks complete): +Research is in `docs/research/`: - `macos-scanning-apis.md` — the ImageCaptureCore facts §6 is built on (verified against the macOS 26 SDK and the installed PFU driver binary) -- `paperstream-capture-inventory.md` — full PaperStream Capture 6.0 feature inventory (from the official 644-page manual); the post-v1 backlog source: batch state machine + Batch Manager, three counter types with persistence modes, naming-token builder with live preview, marks/filters, separators, ad-hoc override, hotkeys/hardware-button job mapping - `competitor-gaps.md` — positioning: the real competitor is ExactScan Pro (~100€, own drivers), and the two structural gaps nothing on Mac has are the review-then-completion gate and first-class pair naming with transactional counters. VueScan's watermark-trial/renewal model is the goodwill killer to avoid. +A third track — a feature inventory of PaperStream Capture 6.0, distilled from the vendor's 644-page manual — is the source of the post-v1 backlog below (batch state machine, three counter types with persistence modes, naming-token builder with live preview, marks/filters, separators, ad-hoc override, hardware-button job mapping). It is **not published**: it is a dense page-cited abstract of one copyrighted document, and nothing in ScanDeck needs it published to be useful. The backlog items it produced are listed here in their own right. + A separate, broader UX study of a general-purpose PaperStream Capture remake (presets, OCR, searchable PDF, page-list review) exists as local research and is deliberately not published. **It is not this product's roadmap.** Settled 2026-07-28: ScanDeck stays a trading-card app and that document serves as a UX quality bar and post-v1 backlog only. Where the two disagree, this file wins. Consequently the domain models F/B *pairs*, not a general page list; adding a document profile later would be a reducer rewrite, accepted deliberately. Still open: From e16c4b452f4e36885222b5203cedb572bfb5573a Mon Sep 17 00:00:00 2001 From: Eliseo Robles Date: Thu, 30 Jul 2026 15:31:07 -0500 Subject: [PATCH 2/3] Compare the unresampled crop within what the codec accounts for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI found this on its first run, which is the argument for having it: the deskew floor test asserted the published pixels equal the source exactly, and that held on one machine and failed on the runner reading 17 where 18 went in. Nothing was resampled. The fixture is a lossless PNG, `crop` publishes JPEG, and JPEG at quality 1.0 is still a DCT round-trip through YCbCr — it lands a flat region within a level or two rather than on it. The difference came from the encoder, and which side of a rounding boundary a value falls on is not a property this project gets to depend on. The tolerance is 2 and the comment says why it is small: interpolating a 0.1° rotation moves an edge pixel by whole levels of contrast, so it still catches a resample. Verified by dropping the floor to 0.01 and re-running — the test goes red. It goes red at `tiltDegrees == nil` and the output-dimension checks rather than in the pixel loop, which is worth knowing: those assertions are exact, platform-independent, and are what actually proves the crop was a copy. The pixel comparison is a secondary guard and the comment now says so instead of implying it carries the proof. --- .../CardImageCropperDeskewTests.swift | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift b/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift index af50695..cb676b3 100644 --- a/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift +++ b/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift @@ -130,8 +130,23 @@ struct CardImageCropperDeskewTests { == result.analysis.requestedCropBounds.height ) - // And the pixels came through by copy, not by interpolation: the - // output must equal the source region exactly. + // And the pixels came through by copy, not by interpolation. + // + // The comparison has a tolerance, and the reason is the file format + // rather than the pipeline: the fixture is a lossless PNG but `crop` + // publishes JPEG, and JPEG at quality 1.0 is still a DCT round-trip + // through YCbCr. It lands a flat region within a level or two, not on + // it. Asserting exact equality passed on one machine and failed in CI + // reading 17 where 18 went in — a difference produced by the encoder, + // not by anything ScanDeck did. + // + // The tolerance is small on purpose. Interpolating a 0.1° rotation + // moves an edge pixel by whole levels of contrast, so ±2 still catches + // a resample while admitting the codec. The load-bearing proof that + // nothing was resampled is above this loop and is exact: `tiltDegrees` + // is nil, so `square` is never called, and the output dimensions equal + // the requested crop bounds rather than a rotated bounding box. + let jpegRoundTripTolerance = 2 let sourceImage = try cropper.decode(source) let outputImage = try cropper.decode(output) let bounds = result.analysis.requestedCropBounds @@ -141,9 +156,13 @@ struct CardImageCropperDeskewTests { let out = (y * outputImage.width + x) * 4 let into = ((y + bounds.y) * sourceImage.width + (x + bounds.x)) * 4 + let difference = abs( + Int(outputImage.pixels[out]) + - Int(sourceImage.pixels[into]) + ) #expect( - outputImage.pixels[out] == sourceImage.pixels[into], - "pixel (\(x), \(y)) was resampled" + difference <= jpegRoundTripTolerance, + "pixel (\(x), \(y)) differs by \(difference), more than the codec accounts for, so it was resampled" ) sampled += 1 } From 629b90594001bb71bc13e244c7b0a33ad9656c80 Mon Sep 17 00:00:00 2001 From: Eliseo Robles Date: Thu, 30 Jul 2026 15:34:17 -0500 Subject: [PATCH 3/3] Add the CI badge, now that a run has actually gone green The badge and the required checks both waited on a real green run rather than being added on the assumption of one. A badge that has never passed is worse than no badge. --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 59845a1..4934640 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # ScanDeck — free, open-source Mac scanning software for trading cards +[![CI](https://github.com/eliseorobles/ScanDeck/actions/workflows/ci.yml/badge.svg)](https://github.com/eliseorobles/ScanDeck/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +![Platform: macOS 26](https://img.shields.io/badge/platform-macOS%2026-lightgrey) +![Swift 6.2](https://img.shields.io/badge/Swift-6.2-orange) + **Batch-scan trading cards front and back on macOS, at 600 dpi, with a real scanner-background border — and get numbered `_F` / `_B` pairs a listing tool can read.** ScanDeck drives a duplex ADF document scanner, crops each side to @@ -222,9 +227,12 @@ The app scans real batches end to end on a physical fi-8170 — including a 186-card production run. Milestones M0–M2 are met. Still open: a job/profile editor (there is one built-in profile today), an app -icon, accessibility passes, CI, and a supported-scanner matrix. There is no CI -yet, so there is no build badge — `swift test` on your own machine is the honest -signal. +icon, accessibility passes, a downloadable release, and a supported-scanner +matrix. Every one of those has an [issue](https://github.com/eliseorobles/ScanDeck/issues). + +CI runs `swift build` and `swift test -c release` on every pull request, plus a +hygiene job that fails if a card scan or any private working file ever becomes +tracked. ## Help make this the best scanning app on the Mac