diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e550a95
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,40 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ build-test:
+ name: Build & Test (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ # macos-15 ships Xcode 16 (Swift 6.1): exercises the older-SDK fallback
+ # compile path. macos-26 ships Xcode 26 (Swift 6.2): the real shipping
+ # toolchain, with the macOS 26 SDK and Liquid Glass. The app uses
+ # MeshGradient (macOS 15) and glassEffect (macOS 26), so it needs the
+ # macOS 15+ SDK to compile, macos-14 cannot build it.
+ os: [macos-15, macos-26]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Cache SwiftPM
+ uses: actions/cache@v4
+ with:
+ path: |
+ .build
+ ~/Library/Caches/org.swift.swiftpm
+ key: ${{ matrix.os }}-spm-${{ hashFiles('Package.resolved') }}
+ restore-keys: |
+ ${{ matrix.os }}-spm-
+
+ # Static-analysis gate: the Swift compiler (with StrictConcurrency enabled
+ # in Package.swift) type-checks and concurrency-checks all production code.
+ - name: Build
+ run: swift build -v
+
+ - name: Test
+ run: swift test
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..0c1c90a
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,61 @@
+name: Deploy Pages
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "guide/**"
+ - "mkdocs.yml"
+ - "docs/**"
+ - ".github/workflows/pages.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+
+ - name: Install MkDocs Material
+ run: pip install mkdocs-material
+
+ # Builds the docs-as-code site (tutorial/how-to/reference) from guide/,
+ # per mkdocs.yml's `site_dir: site`.
+ - name: Build docs site
+ run: mkdocs build --strict
+
+ # Combine the existing landing page (docs/index.html, screenshots, logo)
+ # with the freshly built docs site, so the same GitHub Pages URL keeps
+ # serving the landing page at / while the docs move to /guide/.
+ - name: Assemble Pages artifact
+ run: |
+ mkdir -p _site/guide
+ cp docs/index.html docs/privacy.html docs/logo.png docs/screenshot*.png _site/
+ cp -r site/. _site/guide/
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 4d161be..45f6a39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,9 @@ DerivedData/
# Test results
*.xcresult
+# MkDocs build output (built by CI, not committed)
+/site/
+
# Amore / Sparkle private signing keys — NEVER commit these
*.private
sparkle_private_key
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..e094785
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "16096b517bff461c6fe8f1407ae92f21b2980ceea27de1c7be2b00618e486ccd",
+ "pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
+ "version" : "2.9.3"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
index 810ca58..61f3c68 100644
--- a/Package.swift
+++ b/Package.swift
@@ -7,9 +7,15 @@ let package = Package(
products: [
.executable(name: "MacDirStat", targets: ["MacDirStat"])
],
+ dependencies: [
+ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1")
+ ],
targets: [
.executableTarget(
name: "MacDirStat",
+ dependencies: [
+ .product(name: "Sparkle", package: "Sparkle")
+ ],
path: "Sources",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
diff --git a/README.md b/README.md
index f0c389c..6b4aa8b 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@
See where your disk space went.
A fast, beautiful macOS disk usage visualizer — built entirely in Swift.
+[](https://github.com/Ti-03/MacDirStat/actions/workflows/ci.yml)
[](https://apps.apple.com/app/dirstat/id6766033292?mt=12)
[](https://www.apple.com/macos/)
[](./LICENSE)
@@ -72,6 +73,10 @@ Pure Swift + SwiftUI — no Electron, no web views, no dependencies.
| Haptics | `NSHapticFeedbackManager` — intensity scales with file size |
| Duplicates | SHA-256 content hashing on a background actor |
+## Documentation
+
+Tutorial, how-to guides, and API reference: **[docs site](https://ti-03.github.io/MacDirStat/guide/)**. Architecture decisions are recorded in [`docs/adr/`](docs/adr).
+
## Privacy
MacDirStat collects zero data. No network access. No analytics. No tracking. Everything runs on your device. [Full privacy policy](https://ti-03.github.io/MacDirStat/privacy.html).
diff --git a/Sources/App/GlassCompat.swift b/Sources/App/GlassCompat.swift
index c5104ec..a8aef08 100644
--- a/Sources/App/GlassCompat.swift
+++ b/Sources/App/GlassCompat.swift
@@ -3,77 +3,114 @@ import SwiftUI
// Compatibility shims for macOS 26 Liquid Glass and macOS 14/15 APIs.
// On older OS versions these fall back to NSVisualEffectView-based materials.
+// `glassEffect` / `.glass` button styles only exist in the macOS 26 SDK (Xcode 26,
+// Swift 6.2). `#available` is a *runtime* check, it does not stop the compiler from
+// needing the symbol, so on an older SDK these references fail to compile. Gate them
+// at *compile* time with `#if compiler(>=6.2)`; the runtime `#available` stays inside
+// so an Xcode-26 build still back-deploys correctly to macOS 14/15.
extension View {
@ViewBuilder
func glassCapsule() -> some View {
+ #if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(in: .capsule)
} else {
self.background(.ultraThinMaterial, in: Capsule())
}
+ #else
+ self.background(.ultraThinMaterial, in: Capsule())
+ #endif
}
@ViewBuilder
func glassCircle() -> some View {
+ #if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(.regular, in: .circle)
} else {
self.background(.ultraThinMaterial, in: Circle())
}
+ #else
+ self.background(.ultraThinMaterial, in: Circle())
+ #endif
}
@ViewBuilder
func glassCard(cornerRadius: CGFloat) -> some View {
+ #if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius))
} else {
self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
}
+ #else
+ self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
+ #endif
}
@ViewBuilder
func glassInteractiveCard(cornerRadius: CGFloat) -> some View {
+ #if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: cornerRadius))
} else {
self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
}
+ #else
+ self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
+ #endif
}
@ViewBuilder
func glassTintedCard(tint: Color, cornerRadius: CGFloat) -> some View {
+ #if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(.regular.tint(tint), in: .rect(cornerRadius: cornerRadius))
} else {
self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
}
+ #else
+ self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
+ #endif
}
@ViewBuilder
func glassTintedInteractiveCapsule(tint: Color) -> some View {
+ #if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(.regular.tint(tint).interactive(), in: .capsule)
} else {
self.background(.ultraThinMaterial, in: Capsule())
}
+ #else
+ self.background(.ultraThinMaterial, in: Capsule())
+ #endif
}
@ViewBuilder
func glassButton() -> some View {
+ #if compiler(>=6.2)
if #available(macOS 26, *) {
self.buttonStyle(.glass)
} else {
self.buttonStyle(.bordered)
}
+ #else
+ self.buttonStyle(.bordered)
+ #endif
}
@ViewBuilder
func glassProminentButton() -> some View {
+ #if compiler(>=6.2)
if #available(macOS 26, *) {
self.buttonStyle(.glassProminent)
} else {
self.buttonStyle(.borderedProminent)
}
+ #else
+ self.buttonStyle(.borderedProminent)
+ #endif
}
}
diff --git a/Tests/TreemapLayoutTests.swift b/Tests/TreemapLayoutTests.swift
index 872e749..51c77b2 100644
--- a/Tests/TreemapLayoutTests.swift
+++ b/Tests/TreemapLayoutTests.swift
@@ -4,6 +4,14 @@ import SwiftUI
final class TreemapLayoutTests: XCTestCase {
+ override func setUp() {
+ super.setUp()
+ // ByteFormatter reads "useBinarySize" from UserDefaults. Pin it to the
+ // default (decimal / SI) so the formatter tests are deterministic
+ // regardless of any value the app left on this machine.
+ UserDefaults.standard.set(false, forKey: "useBinarySize")
+ }
+
func test_extension_color_map_returns_consistent_color() {
let root = makeTree([("a.pdf", 100), ("b.pdf", 200), ("c.mp4", 300)])
let map = ExtensionColorMap(root: root)
@@ -19,15 +27,15 @@ final class TreemapLayoutTests: XCTestCase {
}
func test_byte_formatter_kb() {
- XCTAssertEqual(ByteFormatter.string(from: 1024), "1.0 KB")
+ XCTAssertEqual(ByteFormatter.string(from: 1_000), "1.0 KB")
}
func test_byte_formatter_mb() {
- XCTAssertEqual(ByteFormatter.string(from: 1024 * 1024), "1.0 MB")
+ XCTAssertEqual(ByteFormatter.string(from: 1_000_000), "1.0 MB")
}
func test_byte_formatter_gb() {
- XCTAssertEqual(ByteFormatter.string(from: 1024 * 1024 * 1024), "1.0 GB")
+ XCTAssertEqual(ByteFormatter.string(from: 1_000_000_000), "1.0 GB")
}
func test_byte_formatter_bytes() {
@@ -47,66 +55,76 @@ final class TreemapLayoutTests: XCTestCase {
return root
}
- func test_layout_single_item_fills_rect() {
+ // The layout is a radial sunburst: a node's children fill the angular range
+ // [-pi/2, 1.5*pi] (a full 2*pi circle), each child's arc proportional to its
+ // size. Cells sit in radial bands. These tests assert on angles/radii.
+ // Note: compute() needs min(width, height) > ~212 to produce any cells.
+
+ func test_layout_single_item_spans_full_circle() {
let root = makeTree([("a.pdf", 1000)])
let map = ExtensionColorMap(root: root)
- let rect = CGRect(x: 0, y: 0, width: 200, height: 100)
+ let rect = CGRect(x: 0, y: 0, width: 400, height: 400)
let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map)
XCTAssertEqual(cells.count, 1)
- XCTAssertTrue(cells[0].rect.intersects(rect))
+ XCTAssertEqual(cells[0].endAngle - cells[0].startAngle, 2 * .pi, accuracy: 0.001)
}
- func test_layout_two_equal_items_fill_rect() {
+ func test_layout_two_equal_items_split_circle_evenly() {
let root = makeTree([("a.pdf", 500), ("b.mp4", 500)])
let map = ExtensionColorMap(root: root)
- let rect = CGRect(x: 0, y: 0, width: 200, height: 100)
+ let rect = CGRect(x: 0, y: 0, width: 400, height: 400)
let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map)
XCTAssertEqual(cells.count, 2)
- let totalArea = cells.reduce(0.0) { $0 + $1.rect.width * $1.rect.height }
- XCTAssertEqual(totalArea, rect.width * rect.height, accuracy: 10)
+ for cell in cells {
+ XCTAssertEqual(cell.endAngle - cell.startAngle, .pi, accuracy: 0.001)
+ }
+ let totalArc = cells.reduce(0.0) { $0 + ($1.endAngle - $1.startAngle) }
+ XCTAssertEqual(totalArc, 2 * .pi, accuracy: 0.001)
}
- func test_layout_cells_dont_overlap() {
+ func test_layout_sibling_arcs_dont_overlap() {
let root = makeTree([("a.pdf", 300), ("b.mp4", 200), ("c.zip", 100), ("d.txt", 400)])
let map = ExtensionColorMap(root: root)
- let rect = CGRect(x: 0, y: 0, width: 400, height: 300)
+ let rect = CGRect(x: 0, y: 0, width: 500, height: 500)
let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map)
- for i in 0..=6.2)`, around the existing runtime
+`#available` check:
+
+```swift
+@ViewBuilder
+func glassCapsule() -> some View {
+ #if compiler(>=6.2)
+ if #available(macOS 26, *) {
+ self.glassEffect(in: .capsule)
+ } else {
+ self.background(.ultraThinMaterial, in: Capsule())
+ }
+ #else
+ self.background(.ultraThinMaterial, in: Capsule())
+ #endif
+}
+```
+
+`#if compiler(>=6.2)` is resolved by the compiler before type-checking, so
+on an older toolchain the entire `glassEffect` branch is never parsed, let
+alone type-checked. The runtime `#available` check stays inside the
+compile-time branch, so a build made with Xcode 26 still back-deploys
+correctly to macOS 14/15 at runtime.
+
+We also dropped `macos-14` from the CI matrix: its SDK predates both
+`glassEffect` and the `ContentUnavailableView` backport check, so it cannot
+build this project at all regardless of guards, and keeping it would only
+produce a permanently red, unfixable job.
+
+## Consequences
+
+- Every new Liquid Glass call site must use the same double guard
+ (`#if compiler(>=6.2)` wrapping `#available(macOS 26, *)`), not
+ `#available` alone. A future contributor who copies an existing
+ `#available`-only pattern from elsewhere in SwiftUI code will reintroduce
+ this bug; there is no compiler warning for it.
+- The CI matrix (`macos-15`, `macos-26`) is now load-bearing: it is the
+ only thing that would have caught this before it shipped, since a
+ single-SDK local build cannot reproduce the failure.
+- Slightly more boilerplate per call site (two nested conditionals instead
+ of one), in exchange for the app actually compiling on the SDK most
+ users' Macs currently have.
diff --git a/docs/ci-journal.md b/docs/ci-journal.md
new file mode 100644
index 0000000..135e986
--- /dev/null
+++ b/docs/ci-journal.md
@@ -0,0 +1,99 @@
+# CI workflow journal
+
+Week 5 (Testing, CI/CD & GitHub Actions). Notes from adding `.github/workflows/ci.yml`
+to MacDirStat.
+
+## The workflow
+
+A single matrixed job, `build-test`, runs on every push to `main` and every PR:
+
+- **runs-on:** `macos-15` and `macos-26` (the matrix, MacDirStat is a macOS-only
+ SwiftUI app, so it cannot build on Linux runners). `macos-15` ships Xcode 16
+ (Swift 6.1) and exercises the older-SDK fallback compile path; `macos-26` ships
+ Xcode 26 (Swift 6.2), the real shipping toolchain with Liquid Glass. See the
+ matrix note at the bottom for why `macos-14` was dropped.
+- **Caching:** SwiftPM `.build` + `~/Library/Caches/org.swift.swiftpm`, keyed on `Package.resolved`.
+- **Build (static-analysis gate):** `swift build`. The Swift compiler type-checks and, with
+ `StrictConcurrency` enabled in `Package.swift`, concurrency-checks all production code. This
+ is the Swift equivalent of the "static analysis" base of the Testing Trophy.
+- **Test:** `swift test` (20 tests).
+- **Badge:** added to `README.md`.
+
+## Why no `swift-format` lint gate
+
+`swift format` is available, but the existing code has ~3,700 violations against swift-format's
+defaults (3,404 are indentation alone). A formatting gate would have meant reformatting the entire
+codebase, which is out of scope for "add CI." The compile step (with StrictConcurrency) serves as
+the static-analysis gate instead. Adopting swift-format with an agreed `.swift-format` config is a
+sensible future follow-up.
+
+## Bugs found while wiring up CI
+
+Getting the suite to even compile surfaced three real problems:
+
+1. **`Sparkle` missing from `Package.swift`.** The source does `import Sparkle`, but the dependency
+ was only declared in the Xcode project, not the SPM manifest. So `swift build` / `swift test`
+ failed from the command line (and would fail in any SPM-based CI). **Fix:** added Sparkle 2.9.1
+ to `Package.swift` and linked it to the target.
+
+2. **Stale `TreemapLayoutTests`.** Six tests asserted on `TreemapCell.rect`, but the treemap was
+ redesigned from a rectangular layout into a radial sunburst (`startAngle` / `endAngle` /
+ `innerRadius` / `outerRadius`). **Fix:** rewrote the six tests against the radial model,
+ preserving each test's intent (e.g. "larger items get larger cells" → larger angular span).
+
+3. **Wrong `ByteFormatter` test expectation.** `test_byte_formatter_gb` expected `1.0 GB` for
+ `1024^3` bytes, but `ByteFormatter` defaults to decimal (SI) units, so it returns `1.1 GB`.
+ The KB/MB cases happened to round to `1.0` and hid the bug. **Fix:** switched the tests to
+ decimal inputs and pinned the `useBinarySize` default in `setUp` for determinism.
+
+None of these were caught before because the suite never compiled (the Sparkle gap).
+
+## `act` and macOS: the Task-3 limitation
+
+`act` runs GitHub Actions locally using **Linux Docker containers**. A macOS job
+(`runs-on: macos-14`) cannot be reproduced with `act`, there is no macOS container image (Apple
+licensing forbids macOS in Docker). Running `act -j build-test` only offers Linux base images,
+which can't build a macOS SwiftUI + Sparkle app.
+
+**Conclusion:** for a macOS app, the local-`act` feedback loop is not available. Verification is
+done by pushing and watching GitHub Actions, or by running `swift build` / `swift test` directly
+on a Mac (which is what was done here, all 20 tests pass locally).
+
+## The matrix earned its keep: a "works on my machine" SDK bug
+
+The very first CI run was **red on both `macos-14` and `macos-15`**, despite a clean local
+`swift build`/`swift test`. The cause is exactly the class of bug the OS matrix exists to catch:
+
+- `GlassCompat.swift` calls `glassEffect(...)` and `.buttonStyle(.glass)`, which are **macOS 26
+ (Liquid Glass) APIs**. They only exist in the macOS 26 SDK (Xcode 26 / Swift 6.2).
+- My local machine runs macOS 26 with the Xcode 26 SDK, so it compiled fine. GitHub's `macos-14`
+ / `macos-15` runners ship Xcode 16.x with the macOS 14/15 SDK, where those symbols **do not
+ exist**, so the build failed (~300 errors, plus cascading errors in `ContentView` which calls
+ the helpers).
+- The original code guarded the calls with `if #available(macOS 26, *)`. That is a **runtime**
+ check, it does not stop the compiler from needing the symbol at build time. So `#available`
+ alone cannot make code compile against an SDK that lacks the API.
+
+**Fix:** wrap each macOS-26-only call in a **compile-time** `#if compiler(>=6.2)` (Swift 6.2 ships
+with the Xcode 26 / macOS 26 SDK). Older toolchains compile only the `NSVisualEffectView`-based
+fallback; the runtime `#available` stays inside the new-SDK branch so an Xcode-26 build still
+back-deploys correctly to macOS 14/15. After this fix the matrix goes green on all runners.
+
+**Lesson:** a green local build proves nothing about other SDKs. The OS matrix turned a latent
+portability bug (that would have bitten anyone building the package without the macOS 26 SDK) into
+a one-PR fix.
+
+### ...and then it caught a second one: `macos-14` can't build the app at all
+
+With the glass code guarded, `macos-15` went green but `macos-14` still failed, this time on
+`ContentView.swift`: `cannot find 'MeshGradient' in scope`. `MeshGradient` is a **macOS 15** API,
+so the macOS 14 SDK doesn't have it either (and, like `glassEffect`, it's behind a runtime
+`if #available(macOS 15, *)` that can't help at compile time). The app genuinely uses macOS 15 and
+macOS 26 APIs, so it **requires the macOS 15+ SDK to compile**; `macos-14` was simply the wrong
+runner. I would rather guard `MeshGradient` than gut a real piece of UI, so the fix here was the
+matrix itself: dropped `macos-14` and settled on **`[macos-15, macos-26]`**, which compiles cleanly
+and still tests both the fallback path (15) and the real Liquid Glass path (26).
+
+**Takeaway:** the deployment target (macOS 14, via `#available`) and the *build* SDK are different
+things. You back-deploy at runtime, but you must compile against an SDK new enough to contain every
+symbol you reference.
diff --git a/guide/how-to/read-the-treemap.md b/guide/how-to/read-the-treemap.md
new file mode 100644
index 0000000..6ae0ff1
--- /dev/null
+++ b/guide/how-to/read-the-treemap.md
@@ -0,0 +1,61 @@
+# How to read the treemap
+
+This guide is for MacDirStat users who have already run a scan and want to
+understand what the chart is showing them.
+
+## Understand what an arc represents
+
+Each arc in the ring is one file or folder. Two things about an arc carry
+information:
+
+- **Angle.** A wider arc holds more disk space. Angle is proportional to
+ size within its parent, not to the whole disk.
+- **Radius (distance from center).** Deeper arcs sit farther from the
+ center. A file three folders deep sits in the third ring band out.
+
+## Understand the default colors ("By Type")
+
+By default, MacDirStat colors files by category, not by individual
+extension:
+
+| Color | Category | Example extensions |
+| ------- | -------------------- | -------------------------- |
+| Orange | Video | `mp4`, `mov`, `mkv` |
+| Emerald | Images | `jpg`, `png`, `heic` |
+| Cyan | Audio | `mp3`, `wav`, `flac` |
+| Blue | Code | `swift`, `py`, `js` |
+| Violet | Documents | `pdf`, `docx`, `md` |
+| Red | Archives | `zip`, `dmg`, `pkg` |
+| Yellow | Data / config | `json`, `yaml`, `sqlite` |
+| Teal | Web | `html`, `css` |
+| Pink | Fonts | `ttf`, `otf`, `woff` |
+| Lime | 3D / models | `obj`, `fbx`, `gltf` |
+
+An extension MacDirStat does not recognize gets a stable color derived from
+its name, so the same unknown extension is always the same color across a
+scan. Folders are colored separately from files: each folder gets a muted,
+low-saturation tint based on its name, so folders never compete visually
+with the files inside them. Files also darken slightly with depth, so you
+can tell a top-level file from one buried five folders down at a glance.
+
+## Change the color scheme or size units
+
+1. Click the gear icon (**Settings & About**) in the dashboard.
+2. Under **Appearance**:
+ - **Color scheme**: choose **By Type** (default), **Rainbow** (one hue
+ per extension, no category grouping), or **Mono** (grayscale, useful
+ for spotting size outliers without color as a distraction).
+ - **File size units**: choose **Decimal** (KB/MB/GB, 1000-based) or
+ **Binary** (KiB/MiB/GiB, 1024-based).
+
+## Zoom in and out
+
+- Click an arc that represents a folder to make it the new center ring.
+- Click the center circle to zoom back out one level.
+
+## See also
+
+- [Scan your first folder](../tutorial/first-scan.md) if you have not run a
+ scan yet.
+- [TreemapLayout reference](../reference/treemap-layout.md) for exactly how
+ angles and colors are computed.
diff --git a/guide/index.md b/guide/index.md
new file mode 100644
index 0000000..d384af5
--- /dev/null
+++ b/guide/index.md
@@ -0,0 +1,16 @@
+# MacDirStat
+
+MacDirStat is a native macOS app that scans a folder and shows what is taking up
+disk space, as an interactive radial treemap.
+
+This guide is for **macOS users who have MacDirStat installed** (or built it
+from source) and want to scan a folder, read the resulting chart, or look up
+what a specific API does. You do not need any Swift experience to follow the
+tutorial or how-to guide; the reference section assumes you are reading the
+Swift source.
+
+- New to MacDirStat? Start with the [tutorial](tutorial/first-scan.md).
+- Trying to make sense of the colors in the chart? Read
+ [How to read the treemap](how-to/read-the-treemap.md).
+- Working on the codebase? See the [reference](reference/byte-formatter.md)
+ section for the exact behavior of `ByteFormatter` and `TreemapLayout`.
diff --git a/guide/reference/byte-formatter.md b/guide/reference/byte-formatter.md
new file mode 100644
index 0000000..025a838
--- /dev/null
+++ b/guide/reference/byte-formatter.md
@@ -0,0 +1,39 @@
+# ByteFormatter
+
+`ByteFormatter` is a stateless enum in
+[`Sources/Views/Shared/ByteFormatter.swift`](https://github.com/Ti-03/MacDirStat/blob/main/Sources/Views/Shared/ByteFormatter.swift)
+that turns a raw byte count into a human-readable string.
+
+## `ByteFormatter.string(from:)`
+
+```swift
+public static func string(from bytes: Int64) -> String
+```
+
+Formats `bytes` using the unit style the user chose in **Settings →
+Appearance → File size units**, read from
+`UserDefaults.standard.bool(forKey: "useBinarySize")`.
+
+| Setting | Base | Units |
+| -------- | ---- | ------------------------------- |
+| Decimal (default) | 1000 | B, KB, MB, GB, TB |
+| Binary | 1024 | B, KiB, MiB, GiB, TiB |
+
+Returns a string with one decimal place for any unit above bytes, for
+example `"4.2 GB"` or `"512 B"`.
+
+### Examples
+
+```swift
+ByteFormatter.string(from: 512) // "512 B"
+ByteFormatter.string(from: 1_500_000) // "1.5 MB" (decimal)
+ByteFormatter.string(from: 1_572_864) // "1.5 MiB" (binary)
+```
+
+## Notes for contributors
+
+`ByteFormatter` reads `UserDefaults` directly rather than taking a
+parameter, so it always reflects the user's current setting without
+threading it through every call site. If you add a call to
+`ByteFormatter.string(from:)` in a `View`, no extra wiring is needed; it
+already reacts to the setting the same way the rest of the app does.
diff --git a/guide/reference/treemap-layout.md b/guide/reference/treemap-layout.md
new file mode 100644
index 0000000..e3c633f
--- /dev/null
+++ b/guide/reference/treemap-layout.md
@@ -0,0 +1,69 @@
+# TreemapLayout
+
+`TreemapLayout` is a stateless struct in
+[`Sources/Layout/TreemapLayout.swift`](https://github.com/Ti-03/MacDirStat/blob/main/Sources/Layout/TreemapLayout.swift)
+that turns a file-system tree into the arcs drawn on screen. It has one
+public entry point.
+
+## `TreemapLayout.compute(root:in:colorMap:)`
+
+```swift
+public static func compute(
+ root: FSNode,
+ in rect: CGRect,
+ colorMap: ExtensionColorMap
+) -> [TreemapCell]
+```
+
+Computes one [`TreemapCell`](#treemapcell) per visible file or folder under
+`root`, laid out to fit inside `rect`.
+
+- `root`: the folder currently zoomed into. Only its direct and nested
+ children are laid out, up to a fixed maximum depth of 5.
+- `rect`: the drawing area. The available radius is derived from
+ `min(rect.width, rect.height)`.
+- `colorMap`: the active [`ExtensionColorMap`](#color-assignment), which
+ encodes the user's chosen color scheme (By Type, Rainbow, or Mono).
+
+Returns an empty array if `rect` is too small to fit a ring, or if `root`
+has no children with `size > 0`.
+
+### Layout rule
+
+Each child's arc angle is proportional to its share of its parent's total
+size:
+
+```swift
+let fraction = Double(child.size) / Double(totalSize)
+let arcAngle = fraction * totalAngle
+```
+
+An arc smaller than `minArcAngle` (about 1 degree) is skipped entirely
+rather than drawn as an unreadable sliver. Folders recurse into their own
+children at the next radius band; files do not.
+
+## `TreemapCell`
+
+```swift
+public struct TreemapCell: Identifiable {
+ public let node: FSNode
+ public let startAngle: Double // radians; 0 = right, clockwise
+ public let endAngle: Double
+ public let innerRadius: CGFloat
+ public let outerRadius: CGFloat
+ public let color: Color
+ public let depth: Int
+}
+```
+
+One cell per arc. `startAngle`/`endAngle` are in radians; `innerRadius`/
+`outerRadius` define the ring band for the cell's depth.
+
+## Color assignment
+
+Colors are not assigned by `TreemapLayout` directly; it delegates to
+`ExtensionColorMap.color(for:)` for files and computes a muted per-folder
+tint by hashing the folder's name for directories. Files also darken by a
+fixed amount per depth (from 0% at depth 0 up to 38% at depth 5+), so a
+`.mp4` five folders deep is visibly darker than one at the top level, even
+though both are "orange".
diff --git a/guide/tutorial/first-scan.md b/guide/tutorial/first-scan.md
new file mode 100644
index 0000000..2b342f4
--- /dev/null
+++ b/guide/tutorial/first-scan.md
@@ -0,0 +1,39 @@
+# Scan your first folder
+
+This tutorial is for anyone opening MacDirStat for the first time. By the end,
+you will have scanned a real folder and seen its contents as a treemap.
+
+## Before you start
+
+You need MacDirStat installed and a folder you are curious about, for
+example your Downloads folder.
+
+## 1. Open MacDirStat
+
+Launch the app. You see an empty dashboard with a **Scan** button.
+
+## 2. Choose a folder
+
+Click **Scan**. A folder picker opens.
+
+1. Navigate to the folder you want to inspect.
+2. Select it and click **Scan** in the picker.
+
+MacDirStat starts walking the folder tree immediately. Large folders take
+longer; a progress indicator shows the scan is still running.
+
+## 3. Read the result
+
+When the scan finishes, you see a radial treemap: a ring of colored arcs
+around a center point. Each arc is a file or folder; the size of the arc is
+proportional to how much disk space it uses.
+
+- Hover over an arc to see its name and size.
+- Click an arc that represents a folder to zoom into it.
+- Click the center to zoom back out.
+
+## Next step
+
+The colors are not random. Continue to
+[How to read the treemap](../how-to/read-the-treemap.md) to learn what each
+color means and how to change the color scheme.
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..c5754d1
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,27 @@
+site_name: MacDirStat Docs
+site_url: https://ti-03.github.io/MacDirStat/guide/
+repo_url: https://github.com/Ti-03/MacDirStat
+docs_dir: guide
+site_dir: site
+
+theme:
+ name: material
+ palette:
+ scheme: slate
+ primary: deep orange
+ features:
+ - navigation.sections
+ - content.code.copy
+
+nav:
+ - Home: index.md
+ - Tutorial: tutorial/first-scan.md
+ - How-to guides:
+ - Read the treemap: how-to/read-the-treemap.md
+ - Reference:
+ - ByteFormatter: reference/byte-formatter.md
+ - TreemapLayout: reference/treemap-layout.md
+
+markdown_extensions:
+ - admonition
+ - pymdownx.superfences