From ded07db5d6d8ebdd8fe8b79a630313dea5a5014b Mon Sep 17 00:00:00 2001 From: RasputinKaiser <178525839+RasputinKaiser@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:07:03 -0400 Subject: [PATCH 1/4] perf: cache derived UI state and drop per-row GeometryReader bars - Cache filteredTypeBreakdown/filteredCategoryBreakdown per scan + query - Cache tree container IDs so canExpandAllTree stops re-walking the tree - Cache sidebar volume capacity strings to avoid main-actor disk I/O - Shared Canvas-based SizeBar replaces GeometryReader track/overlay in Tree Explorer, Type Breakdown, and Overview storage map rows - Drop Array(enumerated()) allocations in divider ForEach loops Co-Authored-By: Claude Fable 5 --- Sources/StorageScope/Stores/ScanStore.swift | 82 +++++++++++++++++-- Sources/StorageScope/Support/SizeBar.swift | 32 ++++++++ .../Views/DuplicateCandidatesView.swift | 5 +- .../Views/KeeperComparisonSheet.swift | 5 +- Sources/StorageScope/Views/OverviewView.swift | 18 ++-- .../Views/TrashConfirmationSheet.swift | 5 +- .../StorageScope/Views/TreeExplorerView.swift | 14 ++-- .../Views/TypeBreakdownView.swift | 26 ++---- 8 files changed, 135 insertions(+), 52 deletions(-) create mode 100644 Sources/StorageScope/Support/SizeBar.swift diff --git a/Sources/StorageScope/Stores/ScanStore.swift b/Sources/StorageScope/Stores/ScanStore.swift index 96eb06e..6f486f5 100644 --- a/Sources/StorageScope/Stores/ScanStore.swift +++ b/Sources/StorageScope/Stores/ScanStore.swift @@ -213,6 +213,19 @@ func setSelectedView(_ view: SmartView) { private var cachedReclaimPlan = ReclaimPlan(sections: [], primaryAction: nil) private var cachedItemsKey: ItemsCacheKey? private var cachedItems: [StorageItem] = [] + + /// Key for the type/category breakdown caches. Only the scan identity and the search + /// query feed those computations, so the key deliberately omits the other filter dims — + /// a size-filter or sort change must not evict a still-valid breakdown. + private struct TypeBreakdownCacheKey: Equatable { + let scanFinishedAt: Date + let query: String + } + + private var cachedFilteredTypeBreakdownKey: TypeBreakdownCacheKey? + private var cachedFilteredTypeBreakdown: [FileTypeStat] = [] + private var cachedFilteredCategoryBreakdownKey: TypeBreakdownCacheKey? + private var cachedFilteredCategoryBreakdown: [FileCategoryStat] = [] private var cachedOldLargeFilesKey: DerivedCacheKey? private var cachedOldLargeFiles: [StorageItem] = [] private var cachedDuplicateGroupsKey: DerivedCacheKey? @@ -328,9 +341,8 @@ func setSelectedView(_ view: SmartView) { } var canExpandAllTree: Bool { - guard let rootItem = scan?.rootItem else { return false } - let allContainers = allTreeContainerIDs(in: rootItem) - return !allContainers.allSatisfy { treeExpandedIDs.contains($0) } + let containers = treeContainerIDs + return !containers.isEmpty && !containers.isSubset(of: treeExpandedIDs) } var canCollapseTree: Bool { @@ -341,8 +353,26 @@ func setSelectedView(_ view: SmartView) { /// same retention criteria as the scan itself, so big scans only expand what's actually /// visible — the renderer never sees the dropped children. func expandEntireTree() { - guard let rootItem = scan?.rootItem else { return } - treeExpandedIDs = allTreeContainerIDs(in: rootItem) + guard scan != nil else { return } + treeExpandedIDs = treeContainerIDs + } + + private var cachedTreeContainerIDsScanFinishedAt: Date? + private var cachedTreeContainerIDs: Set = [] + + /// Container IDs of the retained tree, cached per scan identity. `canExpandAllTree` is + /// read on every TreeExplorerView body pass (toolbar enablement), and each selection or + /// expansion change re-evaluates that body — without this cache every click re-walked + /// the entire retained tree just to produce one Bool. + private var treeContainerIDs: Set { + guard let scan else { return [] } + if cachedTreeContainerIDsScanFinishedAt == scan.finishedAt { + return cachedTreeContainerIDs + } + let ids = allTreeContainerIDs(in: scan.rootItem) + cachedTreeContainerIDsScanFinishedAt = scan.finishedAt + cachedTreeContainerIDs = ids + return ids } /// Collapses every container — reverts the tree to a single root row. @@ -632,8 +662,16 @@ func setSelectedView(_ view: SmartView) { func refreshMountedVolumes() { cachedMountedVolumes = resolveMountedVolumes() + cachedVolumeCapacityDescriptions.removeAll() } + /// Cached per volume URL for the same reason as `cachedMountedVolumes`: SidebarView + /// renders one capacity string per volume on every body pass, and the underlying + /// `resourceValues` fetch is synchronous disk I/O on the main actor. Outer Optional + /// distinguishes "not fetched yet" from a cached nil (values unavailable). Cleared + /// alongside the volume list in `refreshMountedVolumes()`. + private var cachedVolumeCapacityDescriptions: [URL: String?] = [:] + private func resolveMountedVolumes() -> [URL] { let keys: [URLResourceKey] = [ .volumeNameKey, @@ -659,6 +697,15 @@ func setSelectedView(_ view: SmartView) { /// Used by the sidebar Volumes section to surface free vs total capacity before the user /// commits to scanning a volume. func volumeCapacityDescription(for url: URL) -> String? { + if let cached = cachedVolumeCapacityDescriptions[url] { + return cached + } + let description = resolveVolumeCapacityDescription(for: url) + cachedVolumeCapacityDescriptions[url] = description + return description + } + + private func resolveVolumeCapacityDescription(for url: URL) -> String? { let keys: Set = [.volumeAvailableCapacityKey, .volumeTotalCapacityKey] guard let values = try? url.resourceValues(forKeys: keys) else { return nil } let free = values.volumeAvailableCapacity ?? 0 @@ -1538,19 +1585,31 @@ func setSelectedView(_ view: SmartView) { guard !trimmedQuery.isEmpty else { return scan.typeBreakdown } - return scan.typeBreakdown.filter { + let key = TypeBreakdownCacheKey(scanFinishedAt: scan.finishedAt, query: trimmedQuery) + if cachedFilteredTypeBreakdownKey == key { + return cachedFilteredTypeBreakdown + } + let value = scan.typeBreakdown.filter { $0.label.localizedCaseInsensitiveContains(trimmedQuery) || $0.category.rawValue.localizedCaseInsensitiveContains(trimmedQuery) } + cachedFilteredTypeBreakdownKey = key + cachedFilteredTypeBreakdown = value + return value } var filteredCategoryBreakdown: [FileCategoryStat] { guard let scan else { return [] } - guard !filters.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + let trimmedQuery = filters.query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return scan.categoryBreakdown } + let key = TypeBreakdownCacheKey(scanFinishedAt: scan.finishedAt, query: trimmedQuery) + if cachedFilteredCategoryBreakdownKey == key { + return cachedFilteredCategoryBreakdown + } let typeStats = filteredTypeBreakdown var statsByCategory: [FileTypeStat.Category: (fileCount: Int, extensionCount: Int, totalBytes: Int64)] = [:] @@ -1563,7 +1622,7 @@ func setSelectedView(_ view: SmartView) { statsByCategory[stat.category] = categoryStat } - return statsByCategory.map { category, stats in + let value = statsByCategory.map { category, stats in FileCategoryStat( category: category, fileCount: stats.fileCount, @@ -1577,6 +1636,9 @@ func setSelectedView(_ view: SmartView) { } return lhs.totalBytes > rhs.totalBytes } + cachedFilteredCategoryBreakdownKey = key + cachedFilteredCategoryBreakdown = value + return value } func focusFileType(_ stat: FileTypeStat) { @@ -1735,6 +1797,10 @@ func setSelectedView(_ view: SmartView) { cachedDuplicateGroups = [] cachedVerifiedDuplicateGroupsKey = nil cachedVerifiedDuplicateGroups = [] + cachedFilteredTypeBreakdownKey = nil + cachedFilteredTypeBreakdown = [] + cachedFilteredCategoryBreakdownKey = nil + cachedFilteredCategoryBreakdown = [] // cachedItemsKey/cachedItems intentionally NOT dropped here. `items(for:)`'s key // comparison is the source of truth — its key normalizes irrelevant dims per view, // so a filter change that doesn't affect the active view's output is a no-op diff --git a/Sources/StorageScope/Support/SizeBar.swift b/Sources/StorageScope/Support/SizeBar.swift new file mode 100644 index 0000000..1b896e2 --- /dev/null +++ b/Sources/StorageScope/Support/SizeBar.swift @@ -0,0 +1,32 @@ +import SwiftUI + +/// Shared fractional size bar used by row views (Tree Explorer, Type Breakdown, +/// Overview storage map). Drawn with `Canvas` rather than the previous per-row +/// `GeometryReader` track/overlay pair: `GeometryReader` adds a deferred layout +/// pass for every visible row, which compounds on scrolls through hundreds of +/// expanded tree rows. `Canvas` receives its resolved size directly and draws +/// the same two rounded rects in one pass with no extra layout feedback. +struct SizeBar: View { + let fraction: Double + private let fill: AnyShapeStyle + private let minimumWidth: CGFloat + + init(fraction: Double, fill: some ShapeStyle, minimumWidth: CGFloat = 8) { + self.fraction = fraction + self.fill = AnyShapeStyle(fill) + self.minimumWidth = minimumWidth + } + + var body: some View { + Canvas { context, size in + let track = Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 4) + context.fill(track, with: .style(.quaternary)) + let width = min(max(minimumWidth, size.width * CGFloat(fraction)), size.width) + let bar = Path( + roundedRect: CGRect(x: 0, y: 0, width: width, height: size.height), + cornerRadius: 4 + ) + context.fill(bar, with: .style(fill)) + } + } +} diff --git a/Sources/StorageScope/Views/DuplicateCandidatesView.swift b/Sources/StorageScope/Views/DuplicateCandidatesView.swift index 1128194..ebc977e 100644 --- a/Sources/StorageScope/Views/DuplicateCandidatesView.swift +++ b/Sources/StorageScope/Views/DuplicateCandidatesView.swift @@ -270,7 +270,8 @@ private struct DuplicateItemList: View { var body: some View { VStack(spacing: 0) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + let lastItemID = items.last?.id + ForEach(items) { item in let isKeeper = keeperItemID == item.id DuplicateFileRow( item: item, @@ -292,7 +293,7 @@ private struct DuplicateItemList: View { } .equatable() - if index < items.index(before: items.endIndex) { + if item.id != lastItemID { Divider() } } diff --git a/Sources/StorageScope/Views/KeeperComparisonSheet.swift b/Sources/StorageScope/Views/KeeperComparisonSheet.swift index 1a371f6..8f7a85f 100644 --- a/Sources/StorageScope/Views/KeeperComparisonSheet.swift +++ b/Sources/StorageScope/Views/KeeperComparisonSheet.swift @@ -91,11 +91,12 @@ struct KeeperComparisonSheet: View { } VStack(spacing: 0) { - ForEach(Array(copies.enumerated()), id: \.element.id) { index, item in + let lastItemID = copies.last?.id + ForEach(copies) { item in KeeperComparisonRow(item: item, filters: store.filters, isKeeper: false) { onSetKeeper(item) } - if index < copies.index(before: copies.endIndex) { + if item.id != lastItemID { Divider() } } diff --git a/Sources/StorageScope/Views/OverviewView.swift b/Sources/StorageScope/Views/OverviewView.swift index d361c30..c630d34 100644 --- a/Sources/StorageScope/Views/OverviewView.swift +++ b/Sources/StorageScope/Views/OverviewView.swift @@ -278,7 +278,8 @@ private struct SizeDistributionView: View { .cardBackground() } else { VStack(spacing: 0) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + let lastItemID = items.last?.id + ForEach(items) { item in StorageMapRow( item: item, maxSize: maxSize, @@ -288,7 +289,7 @@ private struct SizeDistributionView: View { store.selectedItemID = item.id } .equatable() - if index < items.count - 1 { + if item.id != lastItemID { Divider() } } @@ -340,15 +341,10 @@ private struct StorageMapRow: View, Equatable { .monospacedDigit() } - GeometryReader { geometry in - RoundedRectangle(cornerRadius: 4) - .fill(.quaternary) - .overlay(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(.tint.opacity(0.85)) - .frame(width: max(8, geometry.size.width * CGFloat(Double(item.displaySize) / Double(maxSize)))) - } - } + SizeBar( + fraction: Double(item.displaySize) / Double(maxSize), + fill: .tint.opacity(0.85) + ) .frame(height: 8) .accessibilityHidden(true) } diff --git a/Sources/StorageScope/Views/TrashConfirmationSheet.swift b/Sources/StorageScope/Views/TrashConfirmationSheet.swift index 817b118..d40abde 100644 --- a/Sources/StorageScope/Views/TrashConfirmationSheet.swift +++ b/Sources/StorageScope/Views/TrashConfirmationSheet.swift @@ -178,10 +178,11 @@ private struct TrashReviewSection: View { .foregroundStyle(.secondary) VStack(spacing: 0) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + let lastItemID = items.last?.id + ForEach(items) { item in TrashReviewRow(item: item, filters: filters, reveal: reveal, open: open, remove: remove, isMoving: isMoving) - if index < items.index(before: items.endIndex) { + if item.id != lastItemID { Divider() } } diff --git a/Sources/StorageScope/Views/TreeExplorerView.swift b/Sources/StorageScope/Views/TreeExplorerView.swift index a7138e2..c7fb44f 100644 --- a/Sources/StorageScope/Views/TreeExplorerView.swift +++ b/Sources/StorageScope/Views/TreeExplorerView.swift @@ -180,15 +180,11 @@ private struct TreeNodeRow: View { .foregroundStyle(.secondary) } - GeometryReader { geometry in - RoundedRectangle(cornerRadius: 4) - .fill(.quaternary) - .overlay(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(.tint.opacity(depth == 0 ? 0.85 : 0.62)) - .frame(width: max(6, geometry.size.width * CGFloat(Double(item.displaySize) / Double(rootSize)))) - } - } + SizeBar( + fraction: Double(item.displaySize) / Double(rootSize), + fill: .tint.opacity(depth == 0 ? 0.85 : 0.62), + minimumWidth: 6 + ) .frame(height: 7) // Purely decorative — the row's own accessibilityLabel/Value already // states the size; without this, VoiceOver can land on an unlabeled bar. diff --git a/Sources/StorageScope/Views/TypeBreakdownView.swift b/Sources/StorageScope/Views/TypeBreakdownView.swift index fef394d..c7b0499 100644 --- a/Sources/StorageScope/Views/TypeBreakdownView.swift +++ b/Sources/StorageScope/Views/TypeBreakdownView.swift @@ -45,15 +45,10 @@ struct TypeBreakdownView: View { .font(.system(.body, design: .rounded).weight(.semibold)) .frame(width: 110, alignment: .leading) - GeometryReader { geometry in - RoundedRectangle(cornerRadius: 4) - .fill(.quaternary) - .overlay(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(stat.category.barTint.opacity(0.72)) - .frame(width: max(8, geometry.size.width * CGFloat(Double(stat.totalBytes) / Double(maxCategoryBytes)))) - } - } + SizeBar( + fraction: Double(stat.totalBytes) / Double(maxCategoryBytes), + fill: stat.category.barTint.opacity(0.72) + ) .frame(height: 10) .accessibilityHidden(true) @@ -128,15 +123,10 @@ private struct FileTypeRowLabel: View, Equatable { .foregroundStyle(.secondary) .frame(width: 86, alignment: .leading) - GeometryReader { geometry in - RoundedRectangle(cornerRadius: 4) - .fill(.quaternary) - .overlay(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(stat.category.barTint.opacity(0.72)) - .frame(width: max(8, geometry.size.width * CGFloat(Double(stat.totalBytes) / Double(maxBytes)))) - } - } + SizeBar( + fraction: Double(stat.totalBytes) / Double(maxBytes), + fill: stat.category.barTint.opacity(0.72) + ) .frame(height: 10) .accessibilityHidden(true) From ce23a2d35fb75b55f35271b90d19835c5b735e47 Mon Sep 17 00:00:00 2001 From: RasputinKaiser <178525839+RasputinKaiser@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:07:03 -0400 Subject: [PATCH 2/4] docs: redesign landing page for v0.7.0, extend changelog Dark data-forward redesign with an animated scan-window hero, category color system, and mono data type. Adds the v0.7.0 interface-performance changelog section and bumps sitemap lastmod dates. Co-Authored-By: Claude Fable 5 --- docs/changelog.html | 11 +- docs/index.html | 1015 ++++++++++++++++++++++++++----------------- docs/sitemap.xml | 4 +- 3 files changed, 618 insertions(+), 412 deletions(-) diff --git a/docs/changelog.html b/docs/changelog.html index 9afc95e..57f5ac7 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -73,7 +73,7 @@

Changelog

v0.7.0 — Scan Control and Privacy

Released 2026-07-01 · release notes · download .dmg
-

Scan control and privacy release. Adds a folder exclusion list so scans can skip node_modules, .git, and cache directories entirely; results now stream into list views live during a scan instead of only appearing at the end; cancelling a scan keeps its partial results browsable instead of discarding them; scans can be paused and resumed in place, with a live items/sec and elapsed-time readout; and a new redaction toggle masks file/folder names in the UI for screen-sharing.

+

Scan control and privacy release. Adds a folder exclusion list so scans can skip node_modules, .git, and cache directories entirely; results now stream into list views live during a scan instead of only appearing at the end; cancelling a scan keeps its partial results browsable instead of discarding them; scans can be paused and resumed in place, with a live items/sec and elapsed-time readout; and a new redaction toggle masks file/folder names in the UI for screen-sharing. Interface rendering also got faster: cached type breakdowns, cheaper size bars, and less main-thread disk I/O.

Scan control

    @@ -88,6 +88,15 @@

    Privacy

    • Redaction toggle — a new Settings section masks file and folder names/paths across the UI with stable, per-session generic placeholders (e.g. "File 3.mp4", "Folder 1") while keeping sizes, dates, and counts real. Trash/move/reveal actions are unaffected since only display text is masked.
    + +

    Interface performance

    +
      +
    • Canvas size bars — the proportional size bars in Tree Explorer, Type Breakdown, and the Overview storage map are now drawn with a shared Canvas view instead of a per-row GeometryReader, removing an extra layout pass for every visible row on large expanded trees.
    • +
    • Cached type breakdowns — the File Type Breakdown and Category Mix lists cache per scan + search query instead of re-filtering every extension on every render while typing.
    • +
    • O(1) Expand All enablement — the Tree Explorer toolbar no longer re-walks the entire retained tree on every selection or expansion change to decide whether "Expand All" applies; container IDs are cached per scan.
    • +
    • Cached volume capacity — the sidebar Volumes section no longer performs synchronous disk I/O on every render to show "free of total" capacity; values cache per volume and refresh with the volume list.
    • +
    • Lighter list dividers — row lists no longer allocate an enumerated tuple array per render just to skip the trailing divider.
    • +
diff --git a/docs/index.html b/docs/index.html index a78735f..5bba578 100644 --- a/docs/index.html +++ b/docs/index.html @@ -15,6 +15,9 @@ + + + @@ -629,7 +763,7 @@
-
- -

Three steps to a cleaner Mac

-
-
- -

Grant folders

-

Pick folders through macOS folder selection or persist access with security-scoped bookmarks. Nothing is uploaded.

-
-
- -

Scan and review

-

See ranked storage pressure, file types, and verified duplicates with a keeper per group, then mark cleanup candidates.

-
-
- -

Send to Trash

-

Confirm a batched, transactional move to Trash that rolls back on partial failure. You stay in control of every change.

-
+ +

Everything you need to reclaim disk space

+

A macOS disk space analyzer, storage cleaner, and duplicate file finder in one sandboxed app.

+
+
+ +

macOS disk space analyzer

+

Rank largest folders, old large files, file types, installers, archives, disk images, caches, and build artifacts.

+
+
+ +

Mac storage cleaner planning

+

Separate verified duplicate reclaim, review-suggested cleanup, and access gaps before anything moves to Trash.

+
+
+ +

Duplicate file review

+

Start from same-size candidates, verify matches with SHA-256, and keep hashing work bounded to what you need.

+
+
+ +

Parallel, performant scans

+

Recurses sibling directories concurrently and scales hash verification with available cores, with deterministic child order.

+
+
+ +

Keeper comparison

+

Side-by-side sheet lets you reassign the keeper of a verified duplicate group with size, date, and path at a glance.

+
+
+ +

Transactional Trash

+

Batch moves go to Trash via macOS APIs and roll back earlier moves if a later one fails — never silent permanent deletion.

-
-
-
- -
-

Local-first by design

-

StorageScope never sends scan data off your Mac. Sandboxed and MIT licensed — read every line.

-
-
-
    -
  • No uploads of scan results, file names, paths, or hashes
  • -
  • No analytics, identifiers, or telemetry in the binary
  • -
  • Sandboxed with security-scoped bookmarks for folder access
  • -
  • Cleanup uses macOS Trash, not silent permanent deletion
  • -
+ +

Three steps to a cleaner Mac

+
+
+ +

Grant folders

+

Pick folders through macOS folder selection or persist access with security-scoped bookmarks. Nothing is uploaded.

+
+
+ +

Scan and review

+

See ranked storage pressure, file types, and verified duplicates with a keeper per group, then mark cleanup candidates.

+
+ +

Send to Trash

+

Confirm a batched, transactional move to Trash that rolls back on partial failure. You stay in control of every change.

+
+
+ + +

The whole picture, one window

+

Ranked storage views, cleanup review, folder tree, and inspector — the same scan, four ways to read it.

+ StorageScope macOS disk space analyzer showing storage views, cleanup review, folder tree, and inspector panes + +
+

Nothing leaves this Mac

+

StorageScope never sends scan data off your machine. It is sandboxed, MIT licensed, and small enough to read every line.

+
    +
  • No uploads of scan results, file names, paths, or hashes
  • +
  • No analytics, identifiers, or telemetry in the binary
  • +
  • Sandboxed with security-scoped bookmarks for folder access
  • +
  • Cleanup uses macOS Trash, not silent permanent deletion
  • +
- +
+ +

ScanStore Architecture

How ScanStore coordinates SwiftUI state, why it owns sub-stores via closure-injected hooks, and what's planned for v0.5.0+.

@@ -334,8 +304,24 @@

6. Store hierarchy

See also: source on GitHub · release history · FAQ · Keyboard Shortcuts

diff --git a/docs/assets/site.css b/docs/assets/site.css new file mode 100644 index 0000000..38805a1 --- /dev/null +++ b/docs/assets/site.css @@ -0,0 +1,403 @@ +/* StorageScope shared site styles — v0.7.0 design system. + The homepage (index.html) carries its own page-specific styles built on the + same tokens; every other page links this sheet so the whole site reads as + one product. */ + +:root { + color-scheme: dark; + --ink: #0a0d14; + --surface-solid: #131a28; + --surface-2: #1a2133; + --line: #222c44; + --line-soft: #1a2338; + --text: #e9edf6; + --muted: #9aa3ba; + --faint: #6b7490; + --blue: #56a0ff; + --blue-deep: #2f7de8; + --amber: #ffb454; + --green: #6fd08c; + --mono: "JetBrains Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; + --display: "Archivo", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --body: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--body); + background: var(--ink); + color: var(--text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.5; +} + +a { color: var(--blue); } + +a:focus-visible, +button:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 2px; + border-radius: 6px; +} + +/* ── Nav ── */ +.site-nav { + position: sticky; + top: 0; + z-index: 100; + background: rgba(10, 13, 20, 0.78); + backdrop-filter: saturate(160%) blur(18px); + -webkit-backdrop-filter: saturate(160%) blur(18px); + border-bottom: 1px solid var(--line-soft); +} + +.nav-inner { + max-width: 1140px; + margin: 0 auto; + padding: 0 24px; + height: 56px; + display: flex; + align-items: center; + gap: 24px; +} + +.brand { + display: inline-flex; + align-items: baseline; + gap: 8px; + color: var(--text); + text-decoration: none; + flex-shrink: 0; + font-family: var(--display); + font-weight: 700; + font-size: 1.02rem; + letter-spacing: -0.01em; +} + +.brand .ver { + font-family: var(--mono); + font-weight: 500; + font-size: 0.72rem; + color: var(--blue); + background: rgba(86, 160, 255, 0.10); + border: 1px solid rgba(86, 160, 255, 0.28); + padding: 1px 7px; + border-radius: 999px; +} + +.site-nav nav { + display: flex; + flex-wrap: wrap; + gap: 2px; + margin-left: auto; +} + +.site-nav nav a { + color: var(--muted); + text-decoration: none; + font-size: 0.86rem; + padding: 6px 10px; + border-radius: 7px; + transition: color 150ms ease, background 150ms ease; +} + +.site-nav nav a:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.06); +} + +.site-nav nav a[aria-current="page"] { color: var(--text); font-weight: 600; } + +/* ── Article layout ── */ +main { + max-width: 880px; + margin: 0 auto; + padding: 52px 24px 80px; +} + +h1 { + margin: 0 0 14px; + font-family: var(--display); + font-weight: 800; + font-size: clamp(2rem, 4.6vw, 3rem); + line-height: 1.05; + letter-spacing: -0.03em; + text-wrap: balance; +} + +h2 { + margin: 44px 0 12px; + font-family: var(--display); + font-weight: 700; + font-size: 1.3rem; + letter-spacing: -0.02em; + text-wrap: balance; +} + +h3 { + margin: 26px 0 10px; + font-size: 1rem; + font-weight: 650; + letter-spacing: -0.01em; +} + +p, li { + color: var(--muted); + line-height: 1.65; + text-wrap: pretty; +} + +strong { color: var(--text); font-weight: 600; } + +ul, ol { margin: 8px 0 0; padding-left: 22px; } +li { margin-bottom: 6px; } + +.lede, .lead { + color: var(--muted); + font-size: 1.06rem; + line-height: 1.65; + margin: 0 0 28px; + max-width: 62ch; +} + +/* ── Code ── */ +code { + font-family: var(--mono); + font-size: 0.88em; +} + +p > code, li > code, td > code { + background: var(--surface-2); + border: 1px solid var(--line); + padding: 1px 6px; + border-radius: 5px; + color: var(--text); +} + +pre { + background: #0d1220; + border: 1px solid var(--line); + border-radius: 12px; + padding: 16px 18px; + overflow-x: auto; + line-height: 1.55; + margin: 14px 0 18px; +} + +pre code { + color: var(--text); + background: transparent; + border: 0; + padding: 0; + font-size: 0.83rem; + white-space: pre; +} + +/* ── Panels ── */ +.card, .qa, .section, .release, .callout { + background: var(--surface-solid); + border: 1px solid var(--line); + border-radius: 13px; + padding: 24px; + margin-top: 20px; +} + +.card h2, .qa h2, .section h2, .release h2, .section h3:first-child { + margin-top: 0; +} + +.qa h2 { font-size: 1.05rem; } +.qa p { margin: 8px 0 0; } + +.release h2 { font-size: 1.2rem; } + +.release .meta { + font-family: var(--mono); + font-size: 0.76rem; + color: var(--faint); + margin: 4px 0 12px; +} + +.callout { color: var(--muted); line-height: 1.65; } + +.note { + font-size: 0.9rem; + color: var(--muted); + background: var(--surface-2); + border-left: 3px solid var(--blue); + padding: 10px 14px; + border-radius: 0 8px 8px 0; + margin: 14px 0 0; +} + +.coming { + background: var(--surface-solid); + border: 1px solid rgba(86, 160, 255, 0.4); + border-radius: 13px; + padding: 20px 24px; + margin-top: 18px; +} + +.coming h3 { + margin: 0 0 10px; + color: var(--blue); + font-family: var(--mono); + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +/* ── Tables ── */ +table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + background: var(--surface-solid); + border: 1px solid var(--line); + border-radius: 13px; + overflow: hidden; + margin-top: 14px; +} + +th, td { + text-align: left; + padding: 12px 16px; + border-bottom: 1px solid var(--line-soft); + vertical-align: middle; +} + +th { + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--faint); + background: rgba(255, 255, 255, 0.02); +} + +tr:last-child td { border-bottom: none; } + +td.shortcut { white-space: nowrap; width: 28%; } +td.action { font-weight: 550; width: 28%; color: var(--text); } +td.notes { color: var(--muted); font-size: 0.92rem; } + +kbd { + display: inline-block; + font-family: var(--mono); + font-size: 0.74rem; + font-weight: 500; + padding: 3px 8px; + border-radius: 6px; + background: var(--surface-2); + border: 1px solid var(--line); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.5); + color: var(--text); + margin-right: 2px; + min-width: 1.6em; + text-align: center; +} + +kbd + kbd { margin-left: 4px; } + +/* ── Buttons ── */ +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 26px; +} + +a.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + padding: 0 20px; + border-radius: 11px; + text-decoration: none; + font-weight: 600; + font-size: 0.92rem; + border: 1px solid var(--line); + color: var(--text); + background: var(--surface-solid); + transition: transform 130ms cubic-bezier(0.2, 0, 0, 1), border-color 130ms ease, background 130ms ease; +} + +a.button:hover { + border-color: #33415f; + background: var(--surface-2); + transform: translateY(-1px); +} + +a.button:active { transform: scale(0.97); } + +a.primary { + background: linear-gradient(180deg, var(--blue) 0%, var(--blue-deep) 100%); + border-color: transparent; + color: #fff; + box-shadow: 0 2px 14px rgba(47, 125, 232, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +a.primary:hover { + background: linear-gradient(180deg, #6cadff 0%, #3a87ef 100%); +} + +.actions-note { + margin: 14px 0 0; + color: var(--faint); + font-size: 0.85rem; +} + +.actions-note a { color: var(--muted); } +.actions-note a:hover { color: var(--blue); } + +/* ── Footer ── */ +footer.site-footer { + margin-top: 64px; + padding-top: 24px; + border-top: 1px solid var(--line-soft); + color: var(--faint); + font-size: 0.85rem; + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + justify-content: space-between; +} + +footer.site-footer a { + color: inherit; + text-decoration: none; + transition: color 150ms ease; +} + +footer.site-footer a:hover { color: var(--blue); } + +.site-footer-meta { + font-family: var(--mono); + font-size: 0.76rem; + color: var(--faint); +} + +/* ── Responsive / motion ── */ +@media (max-width: 720px) { + main { padding: 36px 18px 64px; } + .card, .qa, .section, .release { padding: 18px; } + td, th { padding: 10px 12px; } + pre { padding: 12px; } + pre code { font-size: 0.76rem; } + .site-nav nav a { font-size: 0.8rem; padding: 5px 8px; } +} + +@media (max-width: 560px) { + .site-nav nav { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + a.button, .site-nav nav a { transition: none; } +} diff --git a/docs/changelog.html b/docs/changelog.html index 57f5ac7..611f7f5 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -29,44 +29,28 @@ } } - + + + + -
- +
+
+ +

Changelog

StorageScope release history. Download the latest from GitHub Releases.

@@ -386,8 +370,24 @@

v0.1.1 — Trash confirmation sheet and scan slices

Requires macOS 14 or later · verify with SHA-256 · Privacy

diff --git a/docs/duplicate-file-finder-macos.html b/docs/duplicate-file-finder-macos.html index eaffc47..dd76f07 100644 --- a/docs/duplicate-file-finder-macos.html +++ b/docs/duplicate-file-finder-macos.html @@ -21,43 +21,28 @@ - + + + + -
- +
+ + +

Duplicate file finder for macOS

StorageScope includes duplicate file review for macOS. It starts with same-size duplicate candidates, then verifies matches with SHA-256 inside a bounded work budget so cleanup planning can separate verified duplicates from files that still need human review.

@@ -97,8 +82,24 @@

Related StorageScope pages

Requires macOS 14 or later · verify with SHA-256 · all releases

diff --git a/docs/faq.html b/docs/faq.html index 4e438b5..c12897a 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -109,41 +109,28 @@ ] } - + + + + -
- + + + +

Frequently asked questions

Common questions about StorageScope: pricing, privacy, duplicate verification, Trash safety, and macOS support.

@@ -161,8 +148,24 @@

Frequently asked questions

Privacy · Changelog · Download v0.7.0 (.dmg)

diff --git a/docs/keyboard-shortcuts.html b/docs/keyboard-shortcuts.html index 6589f4f..79e9871 100644 --- a/docs/keyboard-shortcuts.html +++ b/docs/keyboard-shortcuts.html @@ -11,227 +11,28 @@ - + + + + -
- + + + +

Keyboard Shortcuts

@@ -469,7 +270,23 @@

Notes

diff --git a/docs/mac-storage-cleaner.html b/docs/mac-storage-cleaner.html index b869eae..3b05052 100644 --- a/docs/mac-storage-cleaner.html +++ b/docs/mac-storage-cleaner.html @@ -21,43 +21,28 @@ - + + + + -
- + + + +

Open-source Mac storage cleaner

StorageScope is a Mac storage cleaner for people who want review-first cleanup instead of black-box deletion. It helps find large folders, stale files, duplicate candidates, caches, installers, archives, disk images, and build artifacts.

@@ -97,8 +82,24 @@

Related StorageScope pages

Requires macOS 14 or later · verify with SHA-256 · all releases

diff --git a/docs/macos-disk-space-analyzer.html b/docs/macos-disk-space-analyzer.html index 7314f28..b6d8f0d 100644 --- a/docs/macos-disk-space-analyzer.html +++ b/docs/macos-disk-space-analyzer.html @@ -21,43 +21,28 @@ - + + + + -
- + + + +

Free macOS disk space analyzer

StorageScope helps Mac users find what is taking up disk space without uploading scan results, file names, paths, or hashes. It is an open-source SwiftUI/AppKit app for disk usage analysis, large folder scanning, stale file review, and cleanup planning.

@@ -98,8 +83,24 @@

Related StorageScope pages

Requires macOS 14 or later · verify with SHA-256 · all releases

diff --git a/docs/open-source-cleanmymac-alternative.html b/docs/open-source-cleanmymac-alternative.html index c684de9..59fd3fe 100644 --- a/docs/open-source-cleanmymac-alternative.html +++ b/docs/open-source-cleanmymac-alternative.html @@ -21,43 +21,28 @@ - + + + + -
- + + + +

Open-source CleanMyMac alternative

StorageScope is for Mac users who want an open-source alternative to black-box cleaner apps. It focuses on local disk space analysis, duplicate file review, large folder scanning, and cleanup planning that the user can inspect before taking action.

@@ -97,8 +82,24 @@

Related StorageScope pages

Requires macOS 14 or later · verify with SHA-256 · all releases

From 583e2a2b6ecbd83089a0d21451e513352774818c Mon Sep 17 00:00:00 2001 From: RasputinKaiser <178525839+RasputinKaiser@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:22:05 -0400 Subject: [PATCH 4/4] docs: reword CSS comment flagged by public upload audit Co-Authored-By: Claude Fable 5 --- docs/assets/site.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/assets/site.css b/docs/assets/site.css index 38805a1..b4f2553 100644 --- a/docs/assets/site.css +++ b/docs/assets/site.css @@ -1,7 +1,7 @@ /* StorageScope shared site styles — v0.7.0 design system. The homepage (index.html) carries its own page-specific styles built on the - same tokens; every other page links this sheet so the whole site reads as - one product. */ + same variables; every other page links this sheet so the whole site reads + as one product. */ :root { color-scheme: dark;