Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@ Break-glass only — forces a full re-download (minutes).
./tool/reset_ios_spm.sh --build # clean, then flutter build ios
```

### `tool/clean_build_caches.sh` — reclaim disk

Run when the machine is low on space. Reports what a build has accumulated —
Xcode's DerivedData and device symbols, and the `build/` each worktree grows —
and deletes nothing without `--apply`. All of it is rebuilt on demand; source,
Archives and package caches are refused by an allow-list.

```bash
./tool/clean_build_caches.sh # report only
./tool/clean_build_caches.sh --apply # Xcode caches, dead simulators
./tool/clean_build_caches.sh --apply --worktrees # also every build/
```

`--worktrees` forces a full rebuild for anyone working in one; leave it off
while another session is mid-build.

### `tool/extract_content.js` — regenerate bundled content

Node script (no dependencies). Run after the design prototype's (`prototype/`)
Expand Down
51 changes: 51 additions & 0 deletions docs/adr/0028-every-module-a-learner-can-reach-opens-and-shuts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ADR-0028: Every module a learner can reach opens and shuts

- **Status:** accepted
- **Date:** 2026-09-12

## Context

`prototype/disclosure.jsx` makes one flag decide two things: a header that
cannot be tapped has its panel forced open (`open = !collapsible ? true : …`).
Path sets `canCollapse = allDone && !mod.locked`, so the design pins the module
a learner is working through open and gives it no caret — leaving them unable
to fold away the section taking the most room on the screen.

Ruled by the owner on [#596](https://github.com/maximsan/brewpath/issues/596),
which has the argument.

## Decision

**Every module a learner can reach folds away on a tap of its caret** — the one
they are working through included. Whether it *starts* open is a separate
question, and only that module does.

| Density | Caret | Folds away on a tap | On arrival |
|---|---|---|---|
| `active` — reachable, unfinished | yes | yes | open |
| `complete` — every lesson done | yes | yes | shut |
| `locked` — not yet reached | no | no, nothing to fold | shut |

A locked module keeps neither caret nor lesson list: there is nothing behind it
to open, so a caret would be a control over nothing.

`Disclosure` therefore **states** its open flag rather than inferring it from
`collapsible`. The two are independent: a header can be fixed and its panel
open, fixed and shut, or a toggle.

## Consequences

Path holds the open state per module — what the learner last said, falling back
to `PathModuleDensity.opensUnasked`. It is a reading position, so it is not
persisted; a relaunch returns to the default.

The prototype draws the other rule, and
[`docs/design/07-components.md`](../design/07-components.md) records its formula
as the design's — both true of the prototype, neither true of the app, which is
why that section points here. **A drop reconciliation will find this divergence
again.** Under [`docs/README.md`](../README.md)'s precedence a ruling beats the
prototype: leave the app alone.

**Revisit if Path stops being one screen.** The densities exist because five
modules and thirty-two lessons share it; paginate the course and the question
of what folds away changes shape.
6 changes: 6 additions & 0 deletions docs/design/07-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,12 @@ Only 6 props, but one undocumented behaviour that changes how the screen reads.
> So a module folds away the moment its last lesson is done, keeping the current
> module in view. In-progress and locked modules cannot be collapsed.
>
> ⚠️ **The app does not follow the last sentence, by ruling.** Every module a
> learner can reach carries a caret and opens and shuts; only the active one is
> open on arrival, and a locked one draws no lessons at all —
> [ADR-0028](../adr/0028-every-module-a-learner-can-reach-opens-and-shuts.md).
> Do not port the prototype's rule back.
>
> ⚠️ **The expanded/collapsed state lives in `window.__pathExpandedMods`,** a
> global mirrored out of React state — not in `localStorage`, not in a provider.
> It survives tab switches within a session and is lost on reload. A real
Expand Down
3 changes: 2 additions & 1 deletion lib/features/path/domain/path_density.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import 'package:brew_path/features/learn/domain/learn_providers.dart';
///
/// The course is one screen, so five modules and thirty-two lessons share it.
/// Every module the learner can reach carries a caret and answers a tap; only
/// the one they are in lists its lessons before being asked.
/// the one they are in lists its lessons before being asked. ADR-0028, which
/// departs from the prototype here on purpose.
enum PathModuleDensity {
/// Reachable and unfinished — the module the learner is in. Opens itself,
/// because the work in front of someone is what the screen exists to show,
Expand Down
13 changes: 11 additions & 2 deletions test/widget/features/dictionary/term_of_day_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,14 @@ void main() {
// frozen date is gone, so it has to run against whatever today is.
final today = await _todaysTerm(tester);

// Scoped to the banner: a category chip on the index can carry the
// same name as a term — `Espresso` is both — so an unscoped finder
// matches twice on whichever day picks one of those words.
expect(
find.text(today.term),
find.descendant(
of: find.byType(TermOfDayBanner),
matching: find.text(today.term),
),
findsOneWidget,
reason: 'the banner names the term the pick chose for today',
);
Expand All @@ -246,7 +252,10 @@ void main() {
expect(find.byType(TermOfDayScreen), findsOneWidget);
expect(find.text(TermOfDayCopy.readFullEntry), findsOneWidget);
expect(
find.text(today.term),
find.descendant(
of: find.byType(TermOfDayScreen),
matching: find.text(today.term),
),
findsOneWidget,
reason: 'and the screen it opens names the same one',
);
Expand Down
120 changes: 120 additions & 0 deletions tool/clean_build_caches.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
#
# clean_build_caches.sh — reclaim the disk this project's builds eat.
#
# WHEN TO RUN THIS
# When the machine is low on space, or a command fails with "no space left on
# device". An iOS Flutter project generates gigabytes per build and never
# reclaims them: Xcode keeps a DerivedData tree per build configuration, and
# every git worktree grows its own `build/`. Six worktrees is ~15 GB.
#
# Everything this deletes is regenerated on the next build. Nothing here is
# source, git data, an Xcode Archive, or a package cache — the first three
# cannot be rebuilt, and wiping the last costs a full re-download for no
# space worth having.
#
# It reports and exits by default. Deleting takes --apply, so the first run
# can never surprise you.
#
# It does not repair a build. The SwiftPM state behind the iOS "Target
# Integrity" error is tool/reset_ios_spm.sh's job, not this script's.
#
# USAGE
# ./tool/clean_build_caches.sh # report only
# ./tool/clean_build_caches.sh --apply # Xcode caches, dead simulators
# ./tool/clean_build_caches.sh --apply --worktrees # also every build/
#
# ⚠️ --worktrees forces a full rebuild for anyone working in one. Leave it off
# while another session is mid-build.
#
set -euo pipefail

apply=0
worktrees=0
for arg in "$@"; do
case "$arg" in
--apply) apply=1 ;;
--worktrees) worktrees=1 ;;
*)
echo "usage: $(basename "$0") [--apply] [--worktrees]" >&2
exit 2
;;
esac
done

PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# The checkout that owns the worktrees, which is not $PROJECT_ROOT when this
# runs from inside one: `--git-common-dir` points at the main clone's .git
# either way, and the worktrees hang off its parent.
MAIN_CHECKOUT="$(cd "$PROJECT_ROOT" && cd "$(git rev-parse --git-common-dir)/.." && pwd)"

DERIVED_DATA="$HOME/Library/Developer/Xcode/DerivedData"
DEVICE_SUPPORT="$HOME/Library/Developer/Xcode/iOS DeviceSupport"

free_space() { df -h /System/Volumes/Data | awk 'NR==2 {print $4}'; }

report() {
[ -d "$1" ] || return 0
printf ' %-16s %7s %s\n' "$2" "$(du -sh "$1" 2>/dev/null | cut -f1)" "$1"
}

# An allow-list of the four things this script may delete, so a bad path stops
# the run rather than taking something that cannot be rebuilt — an Archive, say.
safe_rm() {
local target="$1"
[ -n "$target" ] && [ -d "$target" ] || return 0
case "$target" in
"$DERIVED_DATA" | "$DEVICE_SUPPORT" | "$MAIN_CHECKOUT"/build | "$MAIN_CHECKOUT"/*/build) ;;
*)
echo " REFUSED — outside the allowed paths: $target" >&2
return 1
;;
esac
rm -rf -- "$target"
echo " removed $target"
}

build_dirs() {
local dir
for dir in "$MAIN_CHECKOUT"/build "$MAIN_CHECKOUT"/.claude/worktrees/*/build; do
[ -d "$dir" ] && echo "$dir"
done
return 0
}

echo "==> Checkout: $MAIN_CHECKOUT"
echo "==> Free now: $(free_space)"
echo
echo "Xcode caches — rebuilt on demand:"
report "$DERIVED_DATA" "DerivedData"
report "$DEVICE_SUPPORT" "DeviceSupport"
echo
echo "Flutter build output — needs --worktrees:"
while IFS= read -r dir; do report "$dir" "build"; done < <(build_dirs)

if [ "$apply" -eq 0 ]; then
echo
echo "==> Report only. Re-run with --apply to delete."
exit 0
fi

echo
echo "==> [1/3] Xcode DerivedData"
safe_rm "$DERIVED_DATA"

echo "==> [2/3] iOS DeviceSupport — symbols, re-fetched when a device connects"
safe_rm "$DEVICE_SUPPORT"

# Only devices whose runtime is already gone. Never `simctl erase`, which would
# throw away installed apps and their data on simulators still in use.
echo "==> [3/3] Simulator devices with no runtime"
xcrun simctl delete unavailable || true

if [ "$worktrees" -eq 1 ]; then
echo "==> Build output in every worktree"
while IFS= read -r dir; do safe_rm "$dir"; done < <(build_dirs)
fi

echo
echo "==> Free after: $(free_space)"
3 changes: 3 additions & 0 deletions tool/reset_ios_spm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
# re-download + recompile of the entire dependency tree (minutes), so only
# pay that cost when you actually hit the error.
#
# It frees almost no disk — one app's DerivedData folders and this checkout's
# build/. For space, run tool/clean_build_caches.sh instead.
#
# USAGE
# ./tool/reset_ios_spm.sh # clean only; you run `flutter build ios` after
# ./tool/reset_ios_spm.sh --build # clean, then build iOS (no codesign) in one go
Expand Down
Loading