Skip to content
Merged
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
32 changes: 32 additions & 0 deletions docs/02-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,38 @@ Each feature owns its own data, domain, and presentation layers. Shared code goe

---

## Sheets — one door, one dressing

Every bottom sheet opens through `showAppSheet` (`lib/core/widgets/app_sheet.dart`),
and a guard test fails the build on one opened anywhere else. The chrome is
identical across all nine sheet types the design specifies, which is why one
function serves them all — callers supply only what is inside.

- **The barrier** is `OverlayColors.dimModal`, the app's one blocking overlay,
and the corners are `AppRadii.chrome`, which names bottom sheets among the
surfaces it is for. The dim arrives as an `AppOverlay` — colour *and* the
design's 5px blur — through `OverlayBarrier`. That is why the route is pushed
by hand: the theme and `showModalBottomSheet` can each carry a barrier colour,
and neither can carry the blur that goes with it.
- **`title` is the sheet's only name.** It is the heading every sheet opens on
and the accessible name of the sheet as a region; one string feeds both so
they cannot drift, which a second label parameter had already allowed.
- **`eyebrow`** is the kicker the design sets over a sheet's title. The design
has two — the how-to-play drawer's *How to play* and the duel's *SHARE TO* —
of which the app has built the first.

Two rules the design states are deliberately **not** implemented, because
Flutter satisfies both for free and porting them would re-solve a DOM problem:

- *Sheets stack.* The design lifts its gate sheet onto a higher z-index pair
because the web has no navigator stack. Here the navigator stack **is** the
z-order, so a sheet opened from inside a sheet already renders above it.
- *Root-level sheets are dismissed on navigation.* Flutter does this in all
three navigation shapes this app performs — a route change, a shell branch
switch, and a push inside a branch.

---

## The type ladder — `AppText`

`lib/shared/theme/app_text.dart` is the only place a font size exists. Ten
Expand Down
9 changes: 9 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ You can always edit this file by hand instead — the helpers just save effort.

### Added

- **A card says what it is, and shows you how to play it.** The ten formats
with instructions — multiple choice, select all, match, calibrate, put in
order, true or false, tasting, taste fix, blind bag and complete the sentence
— now open on their name in the accent, with a `?` beside it. Tapping it
opens the design's how-to-play drawer: what the format is, in a sentence, and
the three steps to play it. The words are the design's own. The five kinds
that carry an authored eyebrow instead are untouched, and a round in a
mini-game reads exactly as it does in a lesson.

- **Checks run before code leaves the machine.** `tool/git-hooks/` fails a
commit on an unformatted Dart file or an over-long comment, and a push on
anything the metrics, a guard test, the comment check or the changelog
Expand Down
1 change: 1 addition & 0 deletions docs/design/03-design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ app this axis is `AppTracking` (`lib/shared/theme/app_text.dart`).
| 0.08 | `meta` | A meta line or figure that must read as one run — at 0.14em a count's numerals drift apart: `.lesson-row .meta`, `.challenge-pill`, `.bag-opt-s`. |
| 0.10 | `tag` | A word set as a pill naming a state: the tastefix card's symptom chips and its Balanced state, `.cheer-points`. |
| 0.12 | `hint` | The sequence card's out-of-place hint `.seq-hint`, and the practice shelf's meta line and count. |
| 0.14 | `smallcaps` | The smallcaps rule itself, named for a rung that does not default to it — the card kind cue, set at the support step. |
| 0.16 | `marker` | A mono micro line marking what a thing *is*, wider than the smallcaps rule so a two-word label reads as discrete: the dictionary's status chip, `.collect-card .cc-sub`. |
| 0.18 | `chrome` | The app's own frame, a step wider than the pages it frames: the tab bar's label, and the sticky header's compact-title eyebrow. |

Expand Down
5 changes: 3 additions & 2 deletions docs/design/06-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ became active ones. `intro` and `takeaway` are the leftovers of that change and
are candidates for deletion rather than authoring.

**Help drawer.** `CARD_KIND_HELP` (`lesson.jsx:9`) holds title + blurb + 3
numbered steps, surfaced from a "?" button in the lesson top bar. It has **10
entries and does not cover every kind**: `mcq` · `multi` · `match` · `slider` ·
numbered steps, surfaced from a "?" button **beside the card's kind cue** —
not in the lesson top bar, which earlier versions of this doc claimed. It has
**10 entries and does not cover every kind**: `mcq` · `multi` · `match` · `slider` ·
`sequence` · `tastefix` · `bagpick` · `fill` (the concept fill-in-the-blank) ·
plus `quiz` and `flavor` for the mini-games. The "?" button simply does not
render for a kind with no entry — so `decision`, `recall`, `predict`, `visual`
Expand Down
2 changes: 2 additions & 0 deletions docs/design/11-open-items.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ disagreement rule by rule, and built at

| Item | Detail |
|---|---|
| **The card cue sits taller than the design's** | The design pulls its `?` back onto the cue line with `margin: '-13px -8px'`, so a 44px touch area does not set the height of a one-line eyebrow. CSS hit-tests the padding box whatever the layout size; Flutter rejects a tap outside the parent's own box, so porting the bleed with an `OverflowBox` silently shrinks the target to 28×18 — measured, not assumed. The 44 target wins and the line is taller by that much. **No action:** this is the trade, not a gap |
| **The help drawer ships without its icon well** | Seven of the ten kinds with help carry a mark; `mcq`, `multi` and `fill` do not, and a well filled on seven rows beside three empty ones is the fault [#436](https://github.com/maximsan/brewpath/issues/436) already ruled out. Owned by [#567](https://github.com/maximsan/brewpath/issues/567), which draws the three marks and adds the well |
| **`tools/extract-facts.js` reads a dead field** | Line 86 pulls `L[i].xp`, which is `null` for every lesson — per-lesson points live on the module entries. Harmless, but this reference's own tooling still carries the legacy name |
| ~~**`CLAUDE.md` points at the wrong app path**~~ **RESOLVED** | It documented the Flutter app as living in `coffee_quest/` while `lib/` and `test/` are at the repo root. Both halves are now fixed: the app was renamed to package `brew_path` ([#41](https://github.com/maximsan/brewpath/issues/41)) and the project instructions were corrected to the real root-relative layout ([#35](https://github.com/maximsan/brewpath/issues/35)). The note that project instructions sit outside this reference's remit still holds — recorded here only because this table raised it |

Expand Down
56 changes: 20 additions & 36 deletions lib/core/widgets/app_sheet.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:async';

import 'package:brew_path/core/widgets/overlay_barrier.dart';
import 'package:brew_path/core/widgets/smallcaps_label.dart';
import 'package:brew_path/shared/theme/app_overlay.dart';
import 'package:brew_path/shared/theme/app_radii.dart';
import 'package:brew_path/shared/theme/app_spacing.dart';
Expand All @@ -17,39 +18,15 @@ const double _maxHeightFraction = 0.78;

/// Presents [builder] as a bottom sheet, wearing the app's one sheet dressing.
///
/// **Every sheet opens through here.** The chrome below is identical across all
/// nine sheet types the design specifies, which is why one function can serve
/// them all — callers supply only what is inside. A guard test fails the build
/// on a sheet opened anywhere else, because the first sheet carried a comment
/// inviting the second to generalise it and the second was written raw
/// anyway.
///
/// The barrier is [OverlayColors.dimModal], whose own doc names this as the
/// app's one blocking overlay, and the corners are [AppRadii.chrome], which
/// names bottom sheets among the surfaces it is for. The dim arrives as an
/// [AppOverlay] — colour *and* the design's 5px blur — through
/// [OverlayBarrier], which is why the route below is pushed by hand: the theme
/// and `showModalBottomSheet` can both carry a barrier colour, and neither can
/// carry the blur that goes with it.
///
/// [title] is required and is the sheet's *only* name: it is rendered as the
/// heading every sheet opens on, and it is the accessible name of the sheet as
/// a region. One string feeds both so they cannot drift — which is what a
/// second, separate label parameter had already allowed.
///
/// Two rules the design states are **not** implemented here, because Flutter
/// satisfies both for free and porting them would be re-solving a DOM problem:
///
/// - *Sheets stack.* The design lifts its gate sheet onto a higher z-index pair
/// because the web has no navigator stack. Here the navigator stack **is**
/// the z-order, so a sheet from inside a sheet already renders above it.
/// - *Root-level sheets are dismissed on navigation.* Flutter does this in all
/// three navigation shapes this app performs — a route change, a shell branch
/// switch, and a push inside a branch.
/// **Every sheet opens through here**, and a guard test fails the build on one
/// opened anywhere else. [title] is the sheet's *only* name — the heading and
/// the accessible name both — and [eyebrow] is the kicker the design sets over
/// it. The chrome and what it deliberately omits: `docs/02-architecture.md`.
Future<T?> showAppSheet<T>({
required BuildContext context,
required String title,
required WidgetBuilder builder,
String? eyebrow,
}) {
final mood = context.mood;
final settleAtOnce = _restingControllerForReducedMotion(context);
Expand Down Expand Up @@ -85,6 +62,7 @@ Future<T?> showAppSheet<T>({
label: title,
child: _SheetFrame(
title: title,
eyebrow: eyebrow,
child: Builder(builder: builder),
),
),
Expand All @@ -103,12 +81,9 @@ Future<T?> showAppSheet<T>({
/// An already-elapsed controller when the platform asks for reduced motion,
/// or null to let the default transition run.
///
/// `ModalBottomSheetRoute` does not consult
/// [MediaQueryData.disableAnimations] — measured, not assumed: the slide is
/// identical either way. Handing it a
/// zero-duration controller is the only supported hook, and it lands the sheet
/// at rest on the first frame. It needs a [TickerProvider], which a top-level
/// function does not have and the navigator does.
/// `ModalBottomSheetRoute` does not consult [MediaQueryData.disableAnimations]
/// — measured, not assumed. A zero-duration controller is the only supported
/// hook, and it needs the [TickerProvider] the navigator has.
AnimationController? _restingControllerForReducedMotion(BuildContext context) {
if (!MediaQuery.disableAnimationsOf(context)) return null;

Expand All @@ -120,9 +95,14 @@ AnimationController? _restingControllerForReducedMotion(BuildContext context) {

/// The chrome every sheet wears: handle, title, insets and a scrolling cap.
class _SheetFrame extends StatelessWidget {
const _SheetFrame({required this.title, required this.child});
const _SheetFrame({
required this.title,
required this.child,
this.eyebrow,
});

final String title;
final String? eyebrow;
final Widget child;

@override
Expand Down Expand Up @@ -155,6 +135,10 @@ class _SheetFrame extends StatelessWidget {
),
),
const SizedBox(height: AppSpacing.md),
if (eyebrow != null) ...[
SmallcapsLabel(eyebrow!, color: mood.accent),
const SizedBox(height: AppSpacing.xxs),
],
// "Every sheet opens on its title, in the same display face at
// the same size" — the design's own rule, enforceable only from
// in here.
Expand Down
20 changes: 6 additions & 14 deletions lib/features/lessons/presentation/cards/bagpick_card_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:brew_path/core/widgets/answer_feedback.dart';
import 'package:brew_path/core/widgets/dashed_rounded_border.dart';
import 'package:brew_path/features/lessons/presentation/cards/bagpick_bean_view.dart';
import 'package:brew_path/features/lessons/presentation/cards/card_boundary.dart';
import 'package:brew_path/features/lessons/presentation/cards/card_cue.dart';
import 'package:brew_path/features/lessons/presentation/cards/card_shell.dart';
import 'package:brew_path/features/lessons/presentation/cards/card_tints.dart';
import 'package:brew_path/features/lessons/presentation/cards/choice_list.dart';
Expand All @@ -24,20 +25,10 @@ const Map<String, String> _processLabels = {

/// An unlabelled bag, a sample of three seeds, and three things to inspect.
///
/// The only kind here that is not a picker with decoration. Its mechanic is
/// **investigate, then call it**, and three details carry that:
///
/// * the bag's process is *withheld* until the learner commits, because a card
/// showing the answer while asking the question is not asking anything;
/// * each cue is hidden until tapped, and tapping is optional — someone who is
/// sure may call it from the beans alone, and confidence is worth rewarding;
/// * the feedback names **which cue was the real tell**, which is the round's
/// whole teaching payload. A version that graded the pick and skipped the
/// tell would satisfy every other rule here and teach nothing: the lesson is
/// "this is what you should have looked at", not "you were wrong".
///
/// Option identity is the **process key**, never the position — the seeded
/// order moves what is on screen and nothing keys off an index.
/// **Investigate, then call it**: the process is withheld until the learner
/// commits, each cue is hidden until tapped and tapping is optional, and the
/// feedback names which cue was the real tell — the round's whole teaching
/// payload. Option identity is the process key, never the position.
class BagpickCardView extends StatefulWidget {
/// Creates a [BagpickCardView].
const BagpickCardView({
Expand Down Expand Up @@ -110,6 +101,7 @@ class _BagpickCardViewState extends State<BagpickCardView> {
final card = widget.card;

return CardShell(
cue: CardCue.bagpick,
latched: _latched,
onContinue: widget.onContinue,
children: [
Expand Down
62 changes: 62 additions & 0 deletions lib/features/lessons/presentation/cards/card_cue.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import 'package:brew_path/shared/models/content/card_kind_help.dart';
import 'package:brew_path/shared/repositories/card_kind_help_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'card_cue.g.dart';

/// The line a card opens on, naming the format it is asking the learner to
/// play — and the key to the drawer explaining how.
///
/// The ten kinds with help are exactly the ten values here, so a card that
/// carries a cue always has something behind its `?`. The other five kinds
/// open on their own authored eyebrow and take no cue at all.
enum CardCue {
/// Pick one of four.
mcq('Multiple choice · pick one'),

/// Pick every answer that belongs, then check them together.
multi('Select all that apply'),

/// Drag each trait onto what it belongs to.
match('Match · drag to pair'),

/// Dial a slider to where the answer lands.
slider('Calibrate · dial to the target'),

/// Tap the items into their right order.
sequence('Put in order · tap in sequence'),

/// Decide whether a statement holds.
quiz('True or false'),

/// Name the note behind a tasting clue.
flavor('Tasting · name the note'),

/// Choose the fix for a cup that came out wrong.
tastefix('Taste Fix'),

/// Call the process from an unlabelled bag's beans.
bagpick('Blind bag · read the beans'),

/// Fill the blanks in a sentence.
fill('Complete the sentence');

const CardCue(this.phrase);

/// What the cue reads, as the design writes it. Set upper case by the type
/// rule rather than here, so assistive technology is given it as written.
final String phrase;

/// The `kind` this cue's entry carries in the bundled help bank, which is
/// the enum's own name for all ten.
String get helpKey => name;
}

/// How the kind behind [cue] is played.
///
/// Keyed on the cue rather than a raw kind string: every [CardCue] has an
/// entry, so a caller cannot ask this about a format the design never wrote
/// help for. Null only if the bundled bank has lost the entry.
@riverpod
Future<CardKindHelp?> cardKindHelp(Ref ref, CardCue cue) =>
ref.watch(cardKindHelpRepositoryProvider).getForKind(cue.helpKey);
Loading
Loading