# dani.ko — Design System

> 🚨 **SUPERSEDED, 2026-08-16 — THERE IS NO OFFLINE MODE AND NO LOCAL DATABASE.**
> Ted: *"WHO CARES ABOUT OFFLINE???? IN THIS MODERN AGE - NOBODY IS OFFLINE.
> ESPECIALLY IN KOREA, NETWORK REACHES EVERYWHERE, I MEAN EVERYWHERE"* and
> *"USE ONLY SUPABASE DB!!!!! DITCH DRIFT 100%!!!!!!"*.
>
> Every offline-first claim below is **dead**. Drift, the sync engine, the
> mutation queue and the watermarks are being deleted; Supabase is the only
> store and signing in is required. Do not re-derive offline behaviour from
> anything in this document — three sessions did exactly that and each one
> read *"save EVERYTHING on Supabase"* as *"add sync on top of Drift"*.
> `CLAUDE.md` § Key Architecture Decisions is the authority.


**“Taegeuk” · 태극기 blue and red · Manrope · image-led**

| | |
|---|---|
| Chosen | 2026-08-05 — `docs/design/proposal-d-taegeuk.html` |
| **The design** | **`docs/design/taegeuk/index.html` — 57 screens, both modes, and the flow** |
| Origin mockup | `docs/design/final-design.html` (4 screens × dark + light) |
| Language | `docs/design/taegeuk/taegeuk.css` — one stylesheet, nothing declared twice |
| Flow | `docs/design/taegeuk/FLOW.md` |
| Supersedes | “Ledger” + Dot Navy, chosen 2026-07-28. See §0. |
| Base system | Its own. **No longer DDS-conformant** — see §0.2 |
| Code | `lib/core/theme/dds_tokens.dart`, `dani_ko_theme.dart` |

---

## 🚨 0.0 This document is the rules. The mocks are the design.

**`docs/design/taegeuk/` holds every screen in the product, drawn.** This document
tells you what is *allowed* — the palette, the ramp, the radii, the laws about
red and Korean and provenance. It cannot tell you what a screen *is*: prose does
not carry composition, density or rhythm, which are exactly the things that decide
whether a screen reads as considered or reads as empty.

**So: build from the mock, and check against this document. Never the reverse.**
Where the two disagree, **the mock wins** and this document is corrected to match.

Render a mock with headless Chrome and look at it — it takes one command and no
setup:

```bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless \
  --disable-gpu --hide-scrollbars --screenshot=/tmp/mock.png \
  --window-size=1500,2100 "file://$PWD/docs/design/taegeuk/screens-2-trips.html"
```

---

## 0. 🚨 This document reverses the previous design system. Read this section first.

### 0.1 What Ted actually said, 2026-08-05

> *“UI overhaul. The UI is too monotone, boring, unimpressive. Users are planning
> for an exciting visit to Korea. The UI should show a lot of images on every
> page we can.”*
> *“The logo color is too close to black. We should pick a different color.”*
> *“Korean flag has blue and red — I think we should use those exact colors.”*

Three of the five things he raised were **information architecture**, not
styling. They are specified in §7 and they matter more than the palette:
sections must lead with a summary and a checklist, lists must be grouped and
ranked, and the trip must be the one container that holds everything.

### 0.2 🚨 dani.ko has left DDS. This is the largest consequence and it is deliberate.

The previous system was DDS (`dot_flutter`) with a dani.ko accent. Its rules —
**4px radii, no pills, no shadows, no circles, no motion, mono labels in caps** —
are the opposite of what was just chosen. dani.ko now specifies **20px cards,
pill buttons and tags, soft elevation, full-bleed photography, and Manrope**.

**`dot_flutter` and `shared/design/dot-tokens.yaml` are NOT edited for this.**
`CLAUDE.md` and D6 forbid it: those files are shared with dani.go and every
other dani product, and dani.go's behaviour must not change. dani.ko owns its
own theme and its own widgets, and stops consuming DDS's visual layer.

**What this costs, stated plainly so nobody is surprised mid-refactor:**

| Thing | Consequence |
|---|---|
| `DotCard` / `DotButton` / `DotChip` / `DotListItem` / `DotSectionHeader` | Replaced by `DaniKo*` equivalents. They are used across all 42 screens. |
| Every "implemented deviation" justified by *"DDS prohibits pills / motion / circles"* | Moot. §11.2 and §11.3 below are struck. |
| `dot_flutter` path dep | **Still needed** for `dotTypeScaleFor()` and the viewport ramp. Not removed, just not used for surface styling. |
| `shared/design/dot-tokens.yaml § brand_colors.ko` | **Fixed.** It holds Flag Blue — `#0047A0` light, `#3D7BD9` dark — as of 2026-08-05. This row said `#000080` until 2026-08-30 and was itself the stale thing. |
| `danizone/data/apps.json` | Restates every product's brand colour **by hand**. dani.ko's entry is now wrong. Already a known defect in `TODO.md`. |

### 0.3 What survives untouched

These are product rules, not style, and none of them changed:

- **§5 English-first naming**, and copy buttons always copying Korean.
- **§8b safe areas** — non-negotiable, shipped broken twice already.
- **Provenance on every externally-sourced surface** (§6.2).
- **All text selectable**, and the cross-fade transition that exists to protect it.
- **`unknown` is neutral, never red.** An honest gap is not an error.

### 0.4 🚨 One hard constraint was overturned by product decision — and then settled by evidence

`CLAUDE.md` states place cards are **photo-optional by default**, with a category
glyph as the normal state, because no API provides photos of ordinary Korean
venues. That is still true of the *world*. It is no longer true of *dani.ko*:

> **Ted, 2026-08-05:** *“If you are talking about place photos, we have to assume
> that all the places we recommend will have photos.”*
> *“We have a rich collection of Google Places. Assume we have detailed
> information about any places (via dani.go).”*

This converted a data problem into a **curation gate**. On 2026-08-06 it stopped
being a question at all: **1,102 photographs from 한국관광공사 포토코리아 are in
hand** under 공공누리 제1유형. So photography leads where coverage exists, the drawn
scenes carry the rest, and §6.3 has the whole rule.

It does not apply to places the *user* adds — those still have no guaranteed photo
and are a different card, not a broken one.

---

## 1. Philosophy

dani.ko is the one place a visitor's whole Korea trip lives — the checklist, the
places, the documents, the numbers and IDs — and it has to make that trip feel
worth taking.

Photography carries the pages that have it. Type is warm and open, never
compressed. Colour comes from the flag and does exactly two jobs: **blue is
structure; red is either a date or a danger.** Nothing else is coloured, so the
one red thing on a screen is either the thing you must do this week or the thing
that can put you in hospital — and **§2.4 is what keeps those two from blurring
into each other**, because a red that means everything means nothing.

Underneath, the old promise still binds: *we know which of these facts is
verified and which is not*. A richer surface must not manufacture confidence.
Provenance still shows, `unknown` still reads as neutral, and a photograph never
implies we checked the opening hours.

---

## 2. Colour

### 2.1 The accent is the flag — 태극기

Two accents, taken exactly from the national flag, each with one job.

| Token | Light | Dark | Job |
|---|---|---|---|
| **blue** | `#0047A0` | `#0047A0` | Navigation, primary buttons, progress, structure |
| **on_blue** | `#FFFFFF` | `#FFFFFF` | Text on a blue fill — 8.6:1 |
| **red** | `#CD2E3A` | `#CD2E3A` | **A date, or a danger — §2.4.** Overdue, due-soon, not-yet-filed · and the drawn subject that can hurt you |
| **on_red** | `#FFFFFF` | `#FFFFFF` | Text on a red fill — 5.2:1 |
| **blue_text** | `#0047A0` | **`#3D7BD9`** | The one lift — see below |
| black / white | `#000000` / `#FFFFFF` | | The trigrams and field of the flag; ink and surface |

**The rule that keeps the screen from going monotone again:** if something is
red it has a date attached, **or** it is a drawn subject that can injure a
person — and nothing else, ever. Never use red for emphasis, for branding, for
expense, for a rule you have broken, or for a destructive action that is
neither time-bound nor dangerous. Everything else is blue or neutral. **§2.4
is the boundary and it is not optional reading**; loosening red without it is
how the accent stops carrying anything.

#### 🚨 `blue_text` is the only deviation from the exact flag values, and it is measured

| Pair | Contrast | Verdict |
|---|---|---|
| `#FFFFFF` on `#0047A0` | **8.6:1** | Fills are the exact flag blue in both modes |
| `#FFFFFF` on `#CD2E3A` | **5.2:1** | Fills are the exact flag red in both modes |
| `#CD2E3A` as text on `#0D1117` | **3.66:1** | **Flag red needs no lift anywhere.** Stays exact |
| `#0047A0` as text on `#0D1117` | **2.41:1** | Unreadable. `#3D7BD9` (**4.55:1**) is used instead |

So: **every filled shape is the exact flag colour, in both modes.** The lift
applies only where blue is thin text or an icon sitting directly on the dark
background — the readiness number, section numerals, the active tab label.

This is the same failure Dot Navy had, caught before shipping rather than after.
`#000080` measured **1.31:1 on black** and that liability sat open in `TODO.md`
for two days. `#0047A0` is better but still under the 3:1 floor, so it gets the
same treatment for the same reason. **Do not "fix" `blue_text` back to `#0047A0`.**

### 2.2 Neutral base — cool, not pure black

The old base was pure `#000000` / `#F7F7F7`. Both are replaced: pure black under
full-bleed photography produces a hard seam at every image edge, and a cool
neutral sits with flag blue where a warm one fights it.

| Token | Light | Dark |
|---|---|---|
| bg | `#F7F8FA` | `#0D1117` |
| surface | `#FFFFFF` | `#161B22` |
| surface_2 | `#EEF1F5` | `#1D242E` |
| border | `#DDE2E9` | `#28313D` |
| text | `#0E1420` | `#F0F3F7` |
| text_2 | `#54606F` | `#A2AEBD` |
| text_3 | `#8593A3` | `#6E7B8B` |
| blue_soft | `#E3ECFA` | `#0E1D38` |
| red_soft | `#FDE7E9` | `#2E1216` |

**Light mode is not an inversion.** Borders carry depth on light; soft elevation
carries it on dark. `blue_soft` and `red_soft` are the container fills behind
summary blocks and urgent rows, and they are tinted, not tonal-mapped.

### 2.3 Confidence — still not a palette

`verified` / `stale` / `unknown` / `blocking` remain **icon and word**, not hue,
exactly as decided 2026-08-04. One colour is added back and only one:

| State | Light | Dark |
|---|---|---|
| `verified` / done | `#12855A` | `#4ADE80` |
| everything else | neutral | neutral |

`blocking` uses the flag red, because a blocking conflict is by definition
time-bound. `unknown` stays neutral grey with a dashed border. `stale` carries a
date and no colour.

#### 🚨 `unknown` is the most common state in this product, so it gets a real token

Coverage of Korean venue data is partial and always will be — no API supplies
hours, closures or 정기휴무일 for ordinary venues. So "we don't know" is not an
edge case to bolt on at the end; it is what most externally-sourced fields say,
and it needs to look deliberate rather than broken.

| Token | Treatment |
|---|---|
| `unknown` | **Dashed** 1px edge, `text3`, transparent fill. A dash reads as "left open" where a solid edge reads as "filled in" |
| `stale` | Solid hairline, `text3`, **and a date in DM Mono**. The date is the content — "checked 12 days ago" is a different claim from "unknown" |
| `verified` | `verifiedSoft` fill, `verified` label, and a tick |

🚨 **Never red, in any of the three, and §2.4 does not loosen this.** A missing
fact is neither a date nor a danger: a red `unknown` reads as *you did something
wrong* when the thing missing is ours, and "we don't have hours for this venue"
must stay visually distinct from "closed". Danger red is a drawn subject and
never a confidence state; a state that went red would be claiming the gap itself
could hurt you.

---

## 🚨 2.4 Red has TWO senses now, and they never share a shape

**Changed 2026-08-10. Ted was asked whether to keep red for deadlines only —
five families of drawings had declined it — and chose *"allow red for danger
too"*, told that red would stop meaning "deadline" and that a pass would go back
over the earlier drawings. This section is the boundary that stops the first
half of that from happening, and the pass is recorded in §6.3j.**

A rule loosened without a new boundary is not a rule. So red did not become "for
important things". It gained exactly one more sense, with a definition, a place
it may live, and a shape it must take.

| | **Deadline red** | **Danger red** |
|---|---|---|
| What earns it | a DATE or a TIME on it | **a threat to a person's body** — injury, illness, or a hazard to life |
| Where it may appear | the **chrome** — a row border, a tag, a container fill, a line of text | inside a **drawn diagram**, and nowhere else |
| Its shape | a border, a fill behind words, a 2px edge | the **subject** of the drawing — the thing that can hurt you |
| Its opposite in a drawing | a **WALL**, uncoloured, with hatching beyond it | — |

### The three consequences, and each of them is testable

1. 🚨 **A wall is never red, and red is never a wall.** Every diagram in the
   corpus already draws a deadline, a cut-off and a stop as a solid wall with
   hatching beyond it — `climate-card-clock`, `the-night-clock`, `the-bag-cut-off`,
   `two-filings-one-trip`, `where-a-ticket-will-sell-to-you`, the matrix's dead
   cell. **Not one of them changes.** That is what lets red enter drawings at
   all: inside a picture red can only ever mean danger, because the deadline
   already has a shape of its own and it is not a hue. This is the answer to
   *"how does a reader tell the two senses apart when both appear"* — they
   cannot appear as the same thing.
2. 🚨 **Danger red never leaves a drawing.** No red tag, border, container or
   line of text is licensed by danger. A card about an earthquake still has a
   plain chrome. So red in the chrome means, and only means, a date — unchanged
   since 2026-08-05, and every §2.1 call site is untouched.
3. 🚨 **Danger is not cost, and not inconvenience.** The line is *harm to a
   person*, not "bad outcome". Worked against the four cases that pull hardest:

   | Drawing | Costs you | Red? |
   |---|---|---|
   | `the-currency-prompt` | 7.1% on $1,000 | **No.** Money is not a body |
   | `the-night-clock` | the last train, and a taxi fare | **No.** A missed deadline is a wall |
   | `who-moves-first` (overstay) | a fine and an entry ban | **No.** Your record and your plans, not your person |
   | `open-at-two-in-the-morning` | a shut pharmacy at 02:00 | **No.** Inconvenience, and the answer is a clock |
   | `the-alert-you-cannot-silence` (earthquake, flood) | possibly your life | **Yes** |

   🚨 If a future drawing's argument for red is *"but this is really
   expensive"* or *"but people get this wrong a lot"*, the answer is no. The
   question is only ever **can this injure the reader.**

### 🚨 Colour is still never the message — §9, read the strict way round

Red is added **on top of a silhouette that already carried the fact**, and never
instead of one. Every reddened subject in §6.3j is drawn exactly as it was drawn
flat: the casualty is still a body on a trolley, the flood is still water over a
gauge, the emergency bay is still the one rung with its doors standing open. A
colour-blind reader loses nothing, because the drawing said it in shape first.
That is also why the pass reddened **five elements out of thirty-one drawings**
rather than every drawing that felt urgent — a set where everything is red is
the flat set with worse contrast. The connectivity family added the only two
since (§6.3j), and both are the same object: a lithium cell in an aircraft
cabin. **Seven elements, in four drawings, out of thirty-five** — eight in five of
forty-six once the everyday-conventions family landed.

### 🚨 `#CD2E3A` remains the ONLY red, and it is measured on the ground it lands on

No second warning colour, no lift, no orange. Danger red is painted on a
diagram strip, whose ground is `surface2`, so §9's **3:1 floor for a painted UI
shape** binds it in both modes:

| Pair | Ratio | Verdict |
|---|---|---|
| `#CD2E3A` on `#EEF1F5` — light `surface2` | **4.58:1** | Clears the 3:1 UI floor comfortably |
| `#CD2E3A` on `#1D242E` — **dark `surface2`** | **3.01:1** | 🚨 **The tightest pair in the app.** Clears, by 0.01 |

**Dark `surface2` is the worst ground red touches anywhere**, and it is the one
that must be re-measured before any token moves. `test/theme/contrast_test.dart`
asserts it in both modes, so darkening `surface2` by two steps fails the build
rather than quietly pushing a casualty under the floor. Blue's own subject
colour on that ground is 3.75:1, so danger red is the thinner of the two — which
is the second reason it is reinforcement on a shape that already worked and
never the only carrier.

### 🚨 `redText` — red as INK, added 2026-08-14, and it is not a second red

The Trip rebuild's phase 0 added **`redText`**: `#CD2E3A` in light, **`#E8737F`
in dark**. It looks like the lift this section forbids and is not, for the same
reason `blueText` is not:

- **`red` is a FILL and stays the unlifted flag value in both modes.** White on
  it is safe on either ground. Nothing about that changed.
- **`redText` is the case where red is the INK, on `bg`** — a thin label, a
  `.cant` in a warning register, a danger button's text. `#CD2E3A` on `#0D1117`
  measures **3.65:1**, under the 4.5:1 body floor. That is a different pair from
  the `surface2` one measured above, and it does not clear.

| Pair | Ratio |
|---|---|
| `redText` on light `bg` — `#CD2E3A` on `#F7F8FA` | **4.88:1** |
| `redText` on dark `bg` — `#E8737F` on `#0D1117` | **6.48:1** |
| 🚨 `red` as ink on dark `bg` — the failure the token exists for | **3.65:1** |

`contrast_test.dart` asserts all three, including the failure, so setting
`redText` back to the flag value in dark mode fails the build. **Light is NOT
forked** — the flag value already clears there, and forking it would fork the
palette for nothing.

### 🚨 `live` — the sharing green, and why it may not borrow `verified`

Three tokens, added in the same change: `live` `#37C26B` (both modes),
`liveSoft`, `liveText`.

**The temptation to reuse `verified` is the entire reason they exist.**
§2.3 makes `verified` the one colour in the confidence set — a claim about
**data we checked**, an address we resolved, a rule we read off a source.
`live` is a claim about **a person's consent**: they turned location sharing on,
and they can turn it off in the next second.

Borrowing one for the other makes *"sharing"* render identically to
*"confirmed"*, which is precisely the sentence this app must not say about
somebody else's whereabouts. `contrast_test.dart` asserts the SEPARATION as well
as the ratio — `live != verified`, `liveSoft != verifiedSoft` — in both modes.

`liveText` on `liveSoft` measures **4.69:1** light and **6.77:1** dark. 🚨 The
dot itself is the **same value in both modes**, because it sits on a photograph
and on a gradient as often as it sits on `bg`.

### 🚨 The drawn SCENES are a third thing and this rule does not reach them

`d-palace`'s lower roof is `#CD2E3A` and always has been. That is not a
violation, and the distinction is §6.3's own: a **diagram** carries a FACT, so
it takes theme tokens and §9 binds it in both modes; a **scene** carries MOOD
where no photograph exists, so it has its own light and paints fixed literals
identical in both modes. A 단청 roof is the colour that roof is. A scene is
`excludeSemantics` and asserts nothing to anybody, so it cannot assert danger.
`test/widget/drawn_scenes_test.dart` holds the roof to being a literal — the
same in both modes — so a future reader of §2.4 does not "fix" it.

### 🚨 A note on section numbers

Comments across `lib/**` and `test/**` cite this rule as **"§4"**. §4 is
Spacing; the numbering shifted long ago and the references did not. Every
"§4 keeps 태극기 red for a date" means **§2.1 and this section**. New comments
say §2.4.

---

## 3. Typography

**Manrope** for everything the user reads. **DM Mono** for everything the user
copies. Chosen 2026-08-05 from four candidates shown side by side.

Space Grotesk and Space Mono are retired. Ted on the previous display face:
*"just the main font ... needs to be changed"* — the serif alternative was
rejected in the same pass.

| Token | Size | Weight | Family | Usage |
|---|---|---|---|---|
| display | 34 | **700** | Manrope | Screen headline — "Korea, planned before you fly." |
| display_sm | 27 | 700 | Manrope | Trip name over the hero photo |
| title | 25 | 700 | Manrope | Place names on detail |
| heading | 18 | 700 | Manrope | Card titles |
| body | 14 | 400/600 | Manrope | List rows, task titles |
| body_sm | 12.5 | 400 | Manrope | Secondary lines, descriptions |
| label | 10.5 | 700 | Manrope | Eyebrows and section labels, uppercase, `.15em` tracking |
| **data** | 12 | 500 | **DM Mono** | 🚨 Every ID, PNR, price, date and confirmation number |
| amount | 34 | 700 | Manrope | The readiness numeral |

### 🚨 The type rule that caused the rejection, written down so it does not recur

**Display weight is capped at 700. Tracking is `-0.3px`. Leading is `1.16`.**

The rejected proposal used **900 at `-1.6px` tracking and `0.94` leading** and
Ted's note was *"font is too bold they look bunched up"*. That is not a taste
disagreement — those three values compound, and 0.94 leading on a 900 weight
makes descenders touch the next line's caps. Do not raise any of the three.

**Numbers are always DM Mono.** A passport number, a PNR, a confirmation code
and a price are things a person reads character by character or copies. They get
tabular figures. This is not up for restyling.

Viewport scale: phone 1.0 · tablet 1.2 · desktop 1.3 — still `dotTypeScaleFor()`.

🚨 **And therefore: NO PIXEL CONSTANT MAY HOLD SCALED TEXT.** A box whose height
is a number tuned on a phone is a 30% overflow waiting for a wider window, and an
OS text-size setting lifts it again on top of the viewport scale. The card
gallery's caption was `62`, and every card in every dossier's picture row struck
`BOTTOM OVERFLOWED BY 5.0 PIXELS` on desktop — `4 + 2×(12×1.3×1.55) + 9.5×1.55 =
67.08` in a 62px box (2026-08-16). Measure it instead: read the resolved
`TextStyle`s and `MediaQuery.textScalerOf`, as `_CardPhoto.captionHeight` in
`research_blocks.dart` now does, and put the growable line under a `Flexible` so
a fraction of a pixel clips rather than stripes. A fixed height is only ever safe
above text that is fixed too.

### 🚨 The Trip section has its OWN type set, and that is a recorded deviation

`lib/features/trip/presentation/widgets/kit/trip_type.dart`, added 2026-08-14.
**`DaniKoTypeRamp` was deliberately NOT widened**, and this is written down so
nobody "fixes" it later by folding one into the other.

`docs/design/trip-v2/frames.css` uses sizes the ramp does not carry — 14.5/800
for a band header, 10.5 for a `.cant`, 9/700 at `.09em` for a tag, 8/700 for a
tile badge. §0.0 and `CLAUDE.md` both settle which wins: *the mocks are the
shapes; where they disagree the mock wins.*

Two ways to honour that were open, and the choice was between them:

- **Widen `DaniKoTypeRamp`** — would have put eight Trip-only roles into the map
  `type_ramp_test.dart` walks and every other feature reads. A ramp with a role
  called `bandHeader` is not a ramp, it is a stylesheet.
- **A Trip-owned set** — the sizes live in one file, and the rule that actually
  matters still holds: 🚨 **no kit widget builds a `TextStyle` inline.** It names
  one from `TripType`. That is what keeps the whole section re-typable from one
  screen, which is the property the shared ramp was providing.

What did NOT change: `DdsType`'s three families are still the only families —
nothing in the kit names a font string of its own — and the caps above still
bind. 🚨 **No style in `TripType` carries a colour.** Every one comes out
uncoloured and the widget applies `context.dotColors`, so a style cannot smuggle
a palette decision into a mode it was not measured in.

### 🚨 `TripType` IS FOR BANDS. A FORM TAKES `DaniKoTypeRamp`.

**Added 2026-08-21, and it is the boundary the set above was missing.**

Ted, on the invitation card: *"ALL FONTS in the 'An Invitation' card are way too
small!!!"* and *"Nobody wants to read small font with long messages!!!!!!"*

He was right about the cause as well as the symptom. That card was assembled out
of `TripType` — `rowDetail` 11.5, `cant` 10.5 — and every individual size was
"consistent" with a kit that had no business being there.

🚨 **`TripType`'s scale is correct for what it was drawn from: `frames.css`'s
BANDS.** Four-line summaries inside a card on the trip root, read at a glance,
where 11.5px is dense on purpose and the reader is scanning rather than reading.

🚨 **A FORM is not a band.** When the surface is the primary thing on the page —
somebody deciding whether to join a trip and typing a code into it — it takes
`DaniKoTypeRamp`: `heading` 18 for the thing being decided about, `body` 14 for
anything that has to be READ, `bodySm` 12.5 as the floor. **Nothing on such a
surface goes below 12.5.**

The test is not which feature folder the file sits in. It is: *is the reader
scanning this, or reading it?* Prose that must be understood before a tap is
reading, and reading is 14.

### 🚨 NEVER TWO HEIGHTS IN ONE ROW

Ted, 2026-08-21, in capitals: *"NEVER USE DIFFERENT HEIGHT TEXT CONTROLS IN THE
SAME ROW!!!!!!!!"*

A `TextField` sizes itself from `contentPadding`; a `DaniKoButton` sizes itself
from its own padding against `DdsTapTarget.minimum`. **Two components sizing
themselves from unrelated rules will never agree**, and *"they look about the
same"* is how a six-pixel step ships — which is exactly what happened on the
invitation card (46 against 52).

🚨 **The ROW decides, not the components.** Wrap each in a `SizedBox` of one
named height and let both fill it; give the field
`contentPadding: EdgeInsets.symmetric(horizontal: …)` with **zero vertical**, and
`textAlignVertical: TextAlignVertical.center`. Vertical padding on the field
fights the box and reintroduces the step.

### 🚨 A `SizedBox` DOES NOT MAKE A `TextField` FILL IT

**This is the second half, and leaving it out cost a whole round.** Ted, after a
fix that wrapped both controls in the same `SizedBox`: *"why, why, why, why is
the number form shorter than the 'Join' button???? I explicitly told you to make
the height the same!"*

A `SizedBox` gives a child **space**; it does not make it **fill** that space,
and the two components behave oppositely:

- **`DaniKoButton` fills.** Its `ConstrainedBox(minHeight: DdsTapTarget.minimum)`
  *enforces* into the tight height it is handed, so 44 becomes 52 and the child
  stretches.
- **`TextField` does not.** It measures its intrinsic height from the text and
  `contentPadding`, centres that inside the box, and — the visible part — draws
  its **border around the intrinsic height**. A 52px box holds a 34px pill.

🚨 **`expands: true`, with `maxLines: null` and `minLines: null`** (Flutter
asserts on both). Without it, wrapping a field in a `SizedBox` is a no-op that
*looks* like the fix, which is worse than not having tried.

`kInviteControlHeight` is the worked example — one constant shared by the two
invitation surfaces, because each picking its own "about 50" is the same defect
twice, found separately.

### 🚨 AND NOT TWO HEIGHTS IN THE APP — `DdsControl.height`, 30, IS THE ONLY ONE

**2026-08-23, P0.** The rule above says one height per ROW. That was too small a
rule, and the gap is where this defect kept living: the Places search box and
`Sort by` matched each other perfectly at 36 and stood six points taller than the
category pills on the line above them. Ted: *"The search box, and the 'Sort by'
button MUST HAVE EXACTLY THE SAME HEIGHT AS THE categories above. The categories
is the standard height, font size for all Text boxes and button … INSPECT THE
CODEBASE - FIND ALL OCCURRENCES OF ALL THOSE CONTROLS AND MAKE THEM EXACTLY THE
SAME HEIGHT!!!!!"* And the rule that outlives the fix: *"If we decide to add more
such controls, they should all have same height."*

- **The height is `DdsControl.height` — 30.** A button, a pill, a chip, a menu,
  a text box. There is no second number and no per-screen constant. The one that
  existed, `DdsControl.fieldHeight = 36`, is deleted; `kPlaceFilterControlHeight`
  went with it.
- **The type is `DdsControl.textStyle`** — `DaniKoTypeRamp.bodySm`, the category
  pill's own. Ted named the pills as the standard for *"height, font size"* both.
- **`DaniKoTextBox` and `DaniKoControlRow` take no `height` argument.** Not a
  default — no parameter. A caller cannot pass the wrong number because there is
  nowhere to put one. That is the guard; everything else is documentation.
- **`test/lint/control_height_rule_test.dart`** reads `lib/**` and fails on a
  second token in `DdsControl`, on a `height:` argument to either widget, and on
  any control hand-sized 24–40. A thing that genuinely is not a control (a map
  pin, a switch track, a list row's tap floor) says so on the line:
  `// not-a-control: <what it is>`.

**Why a lint and not care.** This defect has been reported five times, on five
different rows, and fixed five times. Every fix matched two controls to each
other and left the next one out, because the height was a number a caller chose.
Removing the choice is the only version of the rule that survives the screen
nobody has written yet.

---

## 4. Spacing, radii, borders, elevation

- **Spacing:** 4px grid — xs 4 · sm 8 · md 12 · lg 16 · xl 20 · xxl 24 ·
  screen_padding **20** · card_padding **17** · card_gap 9 · section_gap 23
- **Radii:** cards **20** · tasks and doc rows **18** · buttons **16** ·
  photo tiles **16–22** · hero **22** · tags and chips **full pill** ·
  frames 40
- **Borders:** hairline 1px is still the default. **1.5px** on secondary
  buttons, **and the only 2px is a red urgent border.**
- **Elevation:** soft and low — `0 10px 28px rgba(15,35,70,.10)` light,
  `0 14px 36px rgba(0,0,0,.55)` dark. Used on the frame and on nothing inside it.

> **This reverses “no shadows, ever” and “no pills, no circles”.** Both were DDS
> rules and dani.ko no longer follows DDS (§0.2). Pills are now the tag and
> button shape; the checkbox is a circle.

---

## 5. Naming rule — English first, Korean in parentheses

**Unchanged.** Every user-visible name reads `English (한국어)`:

> Gyeongbokgung Palace (경복궁) · Seongsu (성수동) · Seongsu Gopchang (성수 곱창)

The English name is the headline; the Korean parenthetical renders at `text_2`
so it reads as annotation, not as a second title.

**Two deliberate exceptions, both on place detail:**

1. **The “show this to a driver” block.** The Korean leads at handover size,
   because its only job is to be read by a Korean speaker or pasted into Kakao T.
   🚨 **Changed 2026-08-06 (P5).** It was `23px/700` in a `blue_soft` container,
   hand-set on that screen. It is now an instance of `DaniKoHandover`
   (`lib/design_system/widgets/dani_ko_handover.dart`), which is the mock's
   `.hand` block — `surface` fill, hairline edge, 20px radius, Korean at **31**
   then **17**, English annotating at **12** — and the **PLACE NAME leads, with
   the 도로명주소 beneath it** (PRD §4.7, Ted's own correction; the address led
   before). The block sets no type of its own, and neither does any other
   handover surface, because PRD §8.3's rule is unenforceable while four screens
   each carry their own display scale.

   🚨 **Changed again 2026-08-16, and there is now exactly ONE of these on the
   page.** Place detail drew this block *and*, directly beneath it, a *Make a
   card for this place* section previewing `여기로 가주세요. KBS 아레나홀.` in
   body type. Two blocks about handing a phone to a driver, six centimetres
   apart — and the one that got the designed card treatment held the weaker
   content. Ted, looking at KBS Arena Hall: *"it shows a cue card for the name …
   remove them all"*.

   The name-only block is gone. `_MakeADriverCard` is the single handover
   surface on place detail and it leads with the **instruction**, with the
   도로명주소 as `koreanSecondary` — same component, same 31/17/12, better
   content. A driver being handed a phone needs a sentence, not a noun.
2. **Road addresses.** Always Korean, never romanised. 🚨 On the driver card
   that means **Korean or nothing**: the English fallback was removed
   2026-08-16 (Ted: *"the card will be shown to a Korean, not a visitor"*),
   which also made the preview agree with the save, whose `address` slot had
   always been Korean-only. A place with no `road_address_ko` shows the reserved
   admission in the footer instead.

🚨 **`KoreanEmphasis` has no `display` member.** Display-size Korean IS handover
typography, PRD §8.3 governs who may be handed it, and a widget that gives the
size to any caller that asks cannot enforce that. `DaniKoHandover` is the only
caller of `DdsKorean.display()` and the only caller of `displayBrightnessProvider`;
`test/widget/handover_typography_test.dart` fails the build if either stops being
true. **Machine output — OCR above all — gets none of the FOUR:** no display
size, no brightness boost, no full screen, **and no voice**. It renders
provisional instead — dashed edge, no fill, Korean at inline size, *“machine
output — not for handing over”* beneath it. 🚨 No red anywhere in that
treatment, and **§2.4 does not change this**: a sentence we are unsure of has no
date on it and cannot injure anybody. Reddening a provisional block would say
*this translation is dangerous* when what it actually says is *we have not
checked it*.

🚨 **THE CARD SPEAKS, AND MACHINE OUTPUT DOES NOT. Decided 2026-08-08.**
`DaniKoHandover` reads the Korean aloud in `ko-KR` through
`handoverSpeechProvider` (`lib/core/services/handover_speech.dart`,
`flutter_tts`). One caller, asserted, for the same reason there is one
typography and one brightness — a screen that speaks for itself is a screen that
can speak machine output.

**PRD §8.3 names four things and speech is none of them, because when it was
written this app had no voice. It binds anyway.** §8.3's subject is the
REGISTER, and a confident synthetic voice reading a machine-translated sentence
to a pharmacist is that register in audio, to a listener who cannot see the
dashed border because they are listening rather than reading. Audio carries no
provenance and there is no audible equivalent of a dashed edge that a stranger
in a loud room would catch. So the control is **absent** on a provisional
surface — not disabled, not qualified — exactly as the lead and the full screen
are absent.

🚨 **PROVENANCE IS A TIER, PER STRING. Decided 2026-08-08 — Ted: _"YOU NEED TO
USE AI TO CONFIRM THE PHRASES FIRST"_.**

`KoreanProvenance` (`lib/core/domain/phrases.dart`) has three members —
**`machine` → `aiConfirmed` → `nativeReviewed`** — because "unreviewed" was
doing two jobs at once: *nobody has read this* and *nobody who counts has read
this*. On 2026-08-08 an AI read all 18 phrases and all 16 dietary terms on five
axes (meaning · naturalness · register · romanisation · **read aloud**),
corrected five defects, and confirmed the rest. `aiConfirmed` strings **hand
over and speak** (Ted's call); `machine` keeps today's behaviour exactly —
provisional, inline, silent.

🚨 **AND ON 2026-08-10 THE SAME PASS WAS RUN OVER THE DOSSIER CORPUS** — the
1,591 `phrase_table` rows across 279 cards, which are 1,323 distinct Korean
sentences and had never been read by anybody. `tool/phrase_review.json` is the
record, `build_content_bundle.py` writes the verdict onto each row as `prov`
and `speak`, and `PhraseRow.provenance` parses it. **1,573 rows confirmed, 18
not, 75 confirmed and silent, 11 defects corrected in the dossiers themselves**
— the worst being A-15's `여기 비접촉 결제[탭 결제] 되나요?`, a bracketed
alternation set at handover size and now spoken, brackets and all, which is the
`을(를)` bug of 2026-08-08 in a second place. See §11.4c.

🚨 **A card shows its phrases ONCE.** 72 of the 279 shipped them twice —
the `phrase_table` block, which hands over full screen and speaks, and a second
flat markdown copy of the same rows a screen below inside `article.markdown`.
`research_body()`'s cut 4 had always meant to remove it but matched the table as
a LITERAL rebuild of the JSON rows, so it stopped firing the moment the `.md`
and the `.json` drifted. `cut_drifted_phrase_table` now matches on SHAPE — a
heading whose whole section is table, no prose at all, more than half of whose
Hangul rows the card already ships — and the no-prose condition is what leaves
C-17's and I-06's own writing untouched.
`test/lint/phrase_table_duplication_test.dart` stops the 41st and fires at a
THIRD where the compiler cuts at a half: a card in that gap has drifted past
what the compiler can prove, and the fix is the standing rule that **a phrase
correction lands in BOTH `<ID>.json` AND `<ID>.md`**. 🚨 Never close the gap by
adding the `.md`'s extra rows to the JSON — an unread row raises the count
`phrase_provenance_ratchet_test.dart` holds, and that number may only fall.

🚨 **The wire default is `machine`.** The compiler writes `prov` on CONFIRMED
rows and nothing on the rest, so a bundle written before the review parses as
provisional in its entirety and a dossier that gains a phrase tomorrow is silent
until somebody reads it. The safe state is the one you get by saying nothing.

🚨 **`kPhrasesNeedNativeReview` IS STILL TRUE and was not touched.** An AI pass
steps *towards* the release gate, never through it. `nativeReviewed` stays
reachable and unreached — and `phrase_provenance_ratchet_test.dart` fails the
build if any shipped row ever claims it.

🚨 **What the reader is told must stay true, in BOTH directions.**
`koreanProvenanceCredit()` (`core/domain/handover.dart`) is the only speller of
that sentence, and `DaniKoHandover.checkedBy` is the only line that renders it:

| tier | the line under the card |
|---|---|
| `machine` | *machine output — not for handing over* |
| `aiConfirmed` | *checked by AI — no native speaker yet* |
| `nativeReviewed` | *reviewed by a Korean speaker · `kPhrasesReviewedOn`* |

A confirmed card carrying the machine warning is an **underclaim**; one carrying
a human's credit is the **overclaim** the gate exists to prevent. PRD §31 ranks
both as failures. The credit renders in `DaniKoTypeRamp.data` at 11pt on
`text2` — **6.40:1 light / 7.68:1 dark**, §9's floor is 4.5:1.

🚨 **AND SPEAKABLE IS A SECOND AXIS, NOT A LOWER TIER.**
`Phrase.speakable` / `DaniKoHandover.speakable`. `1330에 전화해 주세요` is correct
Korean that a ko-KR synthesizer reads as the cardinal **천삼백삼십** rather than
일삼삼공 — the one phrase whose whole job is four specific digits. It hands over
at display size, it carries the AI credit, and it has **no voice and no probe**.
Demoting its tier instead would have printed *machine output* under a sentence
that was read and found right.

The address card, place detail's driver block and Emergency's stay block carry
**no credit line at all**, because a venue's own registered name and its
도로명주소 are the Korean party's words and have no provenance of ours to state.

🚨 **It speaks the KOREAN, resolved through `KoreanName.copyValue()`** — the same
helper the copy buttons use — never the English and never a romanisation. And it
**asks before it offers**: if no `ko-KR` voice is installed the control stays,
inert, reading `NO KOREAN VOICE`. Never a dead button, never silence that reads
as a crash (PRD §31). 🚨 The probe **retries for two seconds and caches only a
`true`**, because `flutter_tts_web` reads `speechSynthesis.getVoices()`
synchronously while Chrome populates it asynchronously — the first answer is a
false negative, and it rendered `NO KOREAN VOICE` on a machine with ten Korean
voices installed. A wrong *no* is worse than no answer.

🚨 **Device TTS is not phrase audio.** `bundle_gates.dart` gate 15 asks a
`korean_phrase` block for an `audio_ref` — a file we ship. Synthesized speech is
not one, the corpus's 1,590 phrases stay `phrase_table`, and the gate is
unchanged.

🚨 **The one component comes in TWO SIZES, and that is a knob, not a fork.**
Added 2026-08-07 (P5, Emergency). `DaniKoHandoverSize.standard` is **31/17** —
`.hand` at its own CSS values, caption 2 band 7. `DaniKoHandoverSize.compact` is
**27/15**, which is what `v2-1-shell.html` **caption 8** overrides the same class
to on the Emergency frame, because that surface stacks two blocks plus four
sections on one bar-less scroll and at 31 the second one starts below the fold.
`DaniKoHandoverType.floor` is therefore **compact's 27, not standard's 31**: both
sizes are handover typography, and nothing is allowed to exist between the floor
and the body register, because that middle ground is exactly the ambiguity §8.3
forbids. The knob buys no way past the provenance rule — `compact` on machine
output still produces the provisional object at inline size, asserted.

**Copy buttons always copy the Korean string.** `KoreanName.copyValue()`
(`lib/core/utils/korean_name.dart`) is the only thing a copy action may read.

---

## 6. Components

dani.ko's own, all prefixed `DaniKo`. The DDS widgets they replace are named so
the refactor has a checklist.

| Component | Replaces | Notes |
|---|---|---|
| `DaniKoCard` | `DotCard` | 20px, 1px border, soft elevation only at frame level |
| `DaniKoButton` | `DotButton` | 16px radius, blue fill, white label, 700 |
| `DaniKoTag` | `DotChip` | **Pill.** Variants: blue fill, red fill, red soft, verified, outline |
| `DaniKoTask` | — | Checklist row: circular checkbox, title, deadline line, optional action tag |
| `DaniKoDocRow` | — | 🚨 Document row that **renders its number** in DM Mono. See §7.4 |
| `DaniKoHero` | — | Full-bleed photo, gradient veil, overlaid title and eyebrow |
| `DaniKoPhotoTile` | — | Masonry tile, 16px, label with text-shadow |
| `DaniKoReadiness` | — | The `7 of 12 ready` numeral + bar. See §7.2 |
| `DaniKoGroupHeader` | `DotSectionHeader` | Label, rule, right-aligned count or urgency tag |
| `DaniKoSummaryBlock` | — | 🚨 `blue_soft` container carrying the plain-English answer. See §7.3 |

**Carried forward unchanged:** `DaniKoAppBar`, `DaniKoSelectionScope`,
`DaniKoDegradedBanner`, `DaniKoPlaceName`, `DaniKoCopyRow`,
`DaniKoProvenanceLine`, `DaniKoShell`, `EmergencyAffordance`, `AnalyticsOnce`,
`SuggestionSection` (including its render-nothing-when-empty rule).

### 6.1 The tab bar keeps its hand-drawn implementation

It was hand-drawn because DDS prohibited Material's pill indicator. That reason
is gone, but the implementation stays — it is four equal cells with an active
label in `blue_text`, it works, and swapping in `BottomNavigationBar` now would
be a change with no user-visible benefit.

### 6.2 Every externally-sourced surface still shows its provenance

Hours, closures, bookability and payment all render a confidence chip and, where
known, a `verified_at`. **“We don't have hours for this venue” must be visually
distinct from “open”.** A hero photograph does not soften this — if anything it
raises the stakes, because a beautiful page reads as a confident one.

### 🚨 6.3 Photography leads. The drawn scenes are the fallback, and they are designed.

**Updated 2026-08-06.** This section previously said the imagery is drawn
*deliberately and permanently*. That was true when no photograph existed. **1,102
photographs from 한국관광공사 포토코리아 are now in hand** under 공공누리 제1유형 —
commercial use and derivatives permitted, **attribution required**
(`docs/collected/imagery/photos.json`).

So the rule is **both, in that order**:

| | |
|---|---|
| **Where we hold coverage** | A real photograph carries the hero. The brief was *"show a lot of images on every page we can"*, and this is what makes that literally true |
| **Where we do not** | A drawn scene carries it — 🚨 **not an empty frame and never a broken-image glyph.** Coverage will never be complete, and the fallback is what makes a gap look *intentional* |

🚨 **Two conditions ride with the photographs.** `source_url` in that manifest is a
**download queue, not an image source** — they are fetched into our own storage and
never hotlinked. And **attribution is required**, which makes the sources surface a
legal obligation rather than a courtesy.

**The drawn set keeps its job, and it is a real one.** Five scenes carry every
gap —
`d-seoul`, `d-palace`, `d-food`, `d-mtn`, `d-market` — flat shapes in the flag
palette over a gradient sky, defined in
`docs/design/taegeuk/taegeuk-sprite.js` and used on every hero in every mock.

**What this buys, and why it is not a placeholder:**

| | |
|---|---|
| **It renders offline** | A traveller lands in Korea with no data. A drawn scene renders in airplane mode; a photograph not yet on the phone is a grey box on the one day it matters. This is why the fallback is not optional |
| **It covers what no provider does** | Nothing supplies photography of every ordinary Korean venue, and it never will. The drawn set closes that permanently instead of waiting |
| **There is no empty state** | Every screen has an image *always*. There is no "photo missing" branch to design |
| **Coherence** | Five scenes in one grammar read as a product where fifty mismatched stock photographs read as a directory |

#### 🚨 6.3c The set was REDRAWN 2026-08-07, and the grammar was never the problem

Ted, on the built app: a section page with no photograph *"renders concentric
circles and rounded bars that read as an abstract test pattern rather than as
`d-food`."* Every one of those shapes obeyed the rules below — flat, flag
palette, no gradient inside the subject — and the scene was still unreadable.
So the rules are necessary and they are not sufficient, and three things were
missing from them:

| | |
|---|---|
| **A silhouette** | Four of the five scenes are ELEVATIONS recognised by their outline in the first fifth of a second. `d-food` was a PLAN VIEW of circles, so it had no outline to be recognised by. It is now a 뚝배기 on a table: steam, a pot, a bowl of rice, chopsticks, a table edge |
| **Support** | `d-market` drew three awnings and nothing holding them up, so they read as coloured slabs floating in a night sky. A canopy on two posts over a shaded interior is a stall. Its crowd was four ellipses; a head and a shoulder line makes them people |
| **Density** | `d-seoul` lit eight windows across nine towers, which is a city with the lights off. A sparse deterministic GRID per tower reads as a skyline |

🚨 **And `d-palace`'s roof curved the wrong way**, which was a correctness bug
rather than a taste one. Both edges of each eave were arcs bowing UP with the
same control point, so the roof rendered as an even band bulging at the centre
with its corners hanging DOWN — the inverse of a 팔작지붕. A Korean roof has a
flat 용마루 across the middle, falls away from both ends of it, and its 추녀
**lifts at each corner**. `test/widget/drawn_scenes_test.dart` measures the
underside of the roof and fails if the eave sits lower at the corner than at the
ridge; it fails on the geometry that shipped.

🚨 **Everything that matters lives between y 60 and y 250 of the 375×300
viewBox.** `DaniKoHero`'s default picture is 206 tall and `slice` crops the
middle, so at full width the visible band is roughly y 47…253. A subject
composed around the centre of the viewBox is a subject half of which is never
seen, and that constraint was not written down before.

The sprite (`docs/design/taegeuk/taegeuk-sprite.js`) was regenerated from the
same numbers, so the mock and the build still agree — §0.0's rule is that the
mock wins where they disagree, which makes leaving them out of sync a trap
rather than a loose end.

**Rules for the set:**

- The grammar is **flat shapes, the flag palette, a gradient sky, no gradient
  inside the subject**. A new scene that departs from that stops matching.
- 🚨 **And a silhouette.** Every scene is an elevation with something holding it
  up, because the grammar alone produced a test pattern — see §6.3c.
- A subject resolves to a scene through **one function**
  (`illustration_for.dart`), with a deterministic default. Never inline a choice
  at a call site.
- 🚨 **`Icons.photo` / `Icons.broken_image` / `Icons.image` are banned** and
  `test/lint/place_photo_render_rule_test.dart` fails the build on them. A broken-
  image glyph tells the user our app failed. Nothing failed — we drew it instead.
- 🚨 **Text over an illustration still sits on a gradient veil** (§9). The scenes
  are light at the top by design and white text disappears into the sky without it.
- 🚨 **No card renders an empty frame, and it is read off the SHIPPED bundles.**
  `test/lint/card_imagery_coverage_test.dart` derives the card set from
  `bundle_core.json` and `bundle_trip.json` — never a typed roster, which would
  be green the day card 281 lands with no imagery — and fails if any card
  carries neither a renderable photograph nor a resolving diagram. 🚨 A drawn
  scene does NOT discharge it: `CardArt.forCard` is total, so accepting the
  scene would make the test unable to fail. The scene is the runtime net under
  authored imagery, and a separate assertion holds that every card's net is its
  own section's scene and one of the five. No ratchet — the number is 0.
  The same file proves a gallery block never resolves zero renderable rows
  (that draws `SizedBox.shrink()`, a promised band with nothing in it) and that
  `source_url` — provenance, never an image source — never reaches a render
  path, with the publisher hosts derived from the corpus rather than typed.

#### 🚨 6.3a A FAILED photograph and an ABSENT one are not the same thing

`errorBuilder: (…) => noPhoto` is right for the user and it silenced the failure
for us. Flutter only rethrows an image error when `errorBuilder` is null; supply
one and the exception is consumed, so the console stays clean while every tile
on the screen quietly draws its fallback.

That is how an app holding **820 photographs** could ship showing none with
nothing anywhere saying so — and how a whole session went by re-deriving that
the URLs return 200. `lib/core/imagery/image_failure.dart` splits the two:

| | |
|---|---|
| **Release** | Unchanged. The designed state, silently. §6.3 stands |
| **Debug** | The designed state, PLUS a magenta band naming the subject and the error, PLUS one console line per distinct failure so it is visible off-screen |

🚨 The band is **text, never an icon** — `Icons.photo` / `Icons.broken_image` /
`Icons.image` stay banned here too, where the intent is honest. Magenta because
nothing else in the Taegeuk palette is: a band the flag colours cannot produce
cannot be mistaken for design.

🚨 And it logs with `debugPrint`, **not** `FlutterError.reportError`.
`flutter_test` treats a reported error as a test failure, and every widget test
rendering a real `NetworkImage` gets a 400 from the test HTTP client by design —
so reporting turned *"the fallback works"* into *"the suite is red"*, which is a
diagnostic that deletes itself the first time anyone runs it.

**Postscript, and it is the finding:** the photographs were never broken. They
paint on web exactly as designed. What the previous session was looking at was
the one-time **welcome screen**, which a fresh browser profile always shows
first — a CDP run instrumented for network requests recorded **zero** requests
to `assets-ko.claivis.com` because the Korea root had never been reached. The
imagery is fine; the ONLY defect was that a credit chip was landing on the
titles.

#### 🚨 6.3d A LOADING photograph is a THIRD state, and nobody had named it

Found 2026-08-07 by MEASURING a driven browser rather than looking at one. The
pixels behind the trips cover's white title came back `(247,248,250)` — the
page's own background — for a contrast ratio of **1.06:1**. Not a dark
photograph and not a light one. Nothing at all.

`Image` paints nothing between the widget mounting and the bytes arriving, and
the §9 veil over nothing is a barely-tinted white. So for the whole of that
window every hero in the product was a white rectangle with white text on it —
on a slow connection, and **permanently on a device with no connection**, which
is the exact condition this app exists for.

§6.3 promised *"there is no empty state; every screen has an image ALWAYS"*. It
was true of the ABSENT case and false of the LOADING one, and the two had never
been distinguished:

| | |
|---|---|
| **absent** | we hold no photograph → the drawn scene. Designed, and it was working |
| **failed** | the decode threw → the drawn scene, plus a magenta band in debug (§6.3a). Working |
| 🚨 **loading** | bytes not here yet → **nothing**. 1.06:1, and invisible to every test |

**The fix is not a spinner.** A spinner is a fifth state to design and it says
*wait*; the drawn scene already exists, already renders offline and already
carries white text safely. It goes UNDERNEATH the photograph, which then fades
in over it in 260ms (§8.1) — the first place in the product where a photograph
arriving is something you can watch happen.

🚨 `wasSynchronouslyLoaded` short-circuits the fade for a cached image, or every
scroll back to a hero re-runs it and reads as flicker rather than arrival. And
`errorBuilder` now renders **nothing** in release, because the designed state is
already painted below it — passing it again would draw the scene twice and, in
debug, put the magenta band under its own subject line.

**2026-08-08 — the rule is not about heroes, it is about `Image`.** The card
page's photograph row (`ImageGalleryBlockView`, §7c) had the identical hole: it
carried an `errorBuilder` and no loading treatment, so a 268×168 tile painted
*nothing* until its bytes landed — pixel-identical to the 103 of 280 cards that
hold no photograph at all, which is the one distinction that whole surface
exists to make. It now takes the same shape as the hero, with one difference
that matters:

| surface | the designed state underneath |
|---|---|
| hero | the section's **drawn scene** |
| card photograph row | the photograph's **alt text** (`shows`) on `surface2` |

The alt text rather than a scene, because a scene behind a 268px tile in a
scrolling row would read as a second photograph rather than as a placeholder —
and because `shows` says *what the reader is missing*, which is the honest thing
a tile can offer while it waits. Absent stays absent: an empty gallery renders
**no row at all**, so the three states are three appearances rather than two.

🚨 Its `errorBuilder` also now goes through `daniKoImageFailure` instead of
drawing its own panel. Any `errorBuilder` **consumes** the exception, so a row
that quietly drew its own fallback silenced the failure for us as well as for
the reader — the exact mechanism by which this app once shipped 820 photographs
as blank slabs with a clean console (§6.3a).

#### 🚨 6.3d.2 — 2026-08-12: WHERE THE PHOTOGRAPH IS COMING, THE LOADING STATE IS THE BLUE FIELD, NOT THE SCENE

§6.3d put the drawn scene UNDERNEATH the photograph and faded the photograph in
over it in 260ms. That is right where the scene is the likely FINAL answer. It
is wrong on the Korea root, and Ted saw exactly why: *"all photos first shown as
graphic and seconds later change to photos. THIS IS UNACCEPTABLE."*

The cause is not the fade, it is **`CityPhotoRegistry` hydrating from the bundle
after first paint**. `TopicArt` truthfully answers "drawn scene" while the
registry is empty, so the page painted fifteen illustrations and a cover, then
rebuilt and replaced every one with a photograph. Two designed states, both
correct, rendered one after the other — which reads as a rendering bug however
defensible each half is.

| | |
|---|---|
| **the rule** | a drawn scene is the answer for a subject we hold no photograph of — **never for a subject whose photograph is one hydration away** |
| **the mechanism** | `DaniKoHero.allowDrawnScene`. False on the Korea root's cover and its fifteen tiles; the pre-photograph state is the flag-blue field (§6.3), a colour and not a second picture |
| **under a photograph** | always the blue field now, never the scene. White on `#0047A0` is 8.6:1, so the loading window is still safe for text — which was §6.3d's actual requirement |
| **the fade** | 🚨 **deleted.** The 260ms cross-fade was the second half of the swap: the photograph did not appear, it dissolved in over whatever was already painted. It goes up when its first frame decodes |

🚨 This does NOT reverse §6.3d. The loading hole it names is still real and still
closed — by the blue field instead of by the scene. Nothing renders white on
white, and `errorBuilder` still renders nothing in release.

🚨 What remains open: on a cold cache the tiles still show a blue card while the
network fetch runs, because the topic photographs live on
`assets-ko.claivis.com`. The only way to zero that gap is bundling them as
assets, the way the 19 in `topic_photos.dart` already are.

#### 🚨 6.3d.3 — A TOPIC TILE CARRIES NO CREDIT CHIP, AND ITS TITLE BOX IS FIXED

Two rules, both from the same 2026-08-12 pass, both about the Korea root's
mosaic.

**The credit chip comes off the tile.** Ted: *"DO NOT SHOW the
title/photograher etc."* — meaning the photographer line, `정규진 /
한국관광공사 포토코리아`. It was also the `BOTTOM OVERFLOWED BY 17 PIXELS` on
two of four desktop tiles: a chip sized for a 206px hero, set on two lines,
under a title already two lines deep. §6.3d.1's width cap bought slack; it could
not buy three rows of text a home in a 210px cell.

🚨 **The licence is still discharged.** 공공누리 제1유형 mandates attribution and
FLOW.md §5 gives it three homes — the chip is one, the **Sources register** at
`/settings/sources` is the full one, and it lists every photograph with its
photographer. `DaniKoHero.creditRenderedByCaller` is what tells the hero the
obligation has been taken off it here. 🚨 A surface that drops the chip and has
no register entry is in breach; do not copy this pattern without checking.

**The title box is a fixed three lines.** Ted: *"The text should show at a fixed
position. # of questions in the top part, the text in a fixed position below the
# of questions. assume 3 lines."* The overlay is bottom-aligned, so a one-word
title sat low and a three-line one started much higher — and since the eyebrow
rides above the title, the QUESTION COUNT moved with it. Four tiles in a row,
four heights for the same line.

`DaniKoHero.titleLines` reserves `lines × fontSize × line-height` and top-aligns
the title inside it, so the eyebrow and the title's first line land at one y
across the row. It comes with `maxLines` and an ellipsis: reserving three lines
and then letting a fourth render would put that line through the bottom of the
tile, which is the overflow this whole subsection exists to stop drawing.

#### 🚨 6.3d.1 THE CREDIT NAMED 한국관광공사 TWICE, AND THE CHIP BROKE A WORD

Found 2026-08-10. `DaniKoPhotoCredit` is 168px wide — enough for
`photographer / 한국관광공사 포토코리아` on ONE line, which is what 686 of the 820
`city_photos` rows carried. The other 134 arrive from 포토코리아 with the
organisation already inside the photographer field (`한국관광공사 김지호`,
`한국관광공사-브이앤드`), and the ingest composed `{photographer} / {provider}` on
top of that. The doubled string wrapped and **broke 한국관광공사 across the line** —
`한국관광공` / `사 포토코리아` — on the Korea root's `Buying` tile, the You root's
hero, and every hero drawn from `itaewon`, `market`, `seokguram` or
`street_food`. The same duplication was in 279 of the 1,140 `image_gallery`
credit rows.

🚨 **A REDUNDANCY WAS REMOVED, NEVER THE CREDIT.** 공공누리 has no type that drops
출처표시, 제1유형 included, and a violation terminates the licence outright. The
source half of the string is untouched; the photographer is kept; and where the
photographer field held nothing but the organisation, the organisation IS the
credit and stands alone. Normalised in **two** places — `tool/ingest_photos.py`
for what a future ingest writes, `tool/build_content_bundle.py` for what today's
`photos.json` already holds, because that file is a networked ingest's output
and is not re-run on a content build.

🚨 **The English does not go in the chip.** CLAUDE.md's absolute rule — never
Korean without the English beside it — is satisfied by the photograph footer
under the same grid, which reads `Photographs Korea Tourism Organization Photo
Korea (한국관광공사 포토코리아), KOGL Type 1 (공공누리 제1유형).` A bilingual expansion
inside 168px is three wrapped lines and is the defect this section is about; the
footer has the measure to be read and states it once per screen. The gallery
credits, which render on a full-width row with `maxLines: 1`, keep their
bilingual form.

#### 🚨 6.3e A VEIL MEASURED AS A FRACTION OF THE PICTURE IS NOT A GUARANTEE

Found 2026-08-08 by MEASURING the built `/trips` in a **headed** browser — the
run §6.3d's postscript said nobody had done. The photographs paint (two
`200 image/jpeg` from `assets-ko.claivis.com`, both visible). Looking at what
they painted found something worse than the loading state:

| | measured |
|---|---|
| trips cover masthead `dani.ko` | **1.21:1** |
| trip card eyebrow `IN COUNTRY` | **1.37:1** |
| trip card title `Spring in Seoul` | **1.85:1** |
| trip card `DAY 3 OF 11` | 2.26:1 |

**Two separate defects, and the second is the generalisable one.**

**1. The covers had no darkening at all.** Both magazine covers draw their own
masthead, 44px title and subtitle on a `Positioned.fill` over the hero, and pass
the hero `title: ''`. The hero draws §9's veil only when it has an overlay of
its own — so `_hasOverlay` was false and every word on both covers sat on a raw
photograph. The Korea cover's own comment claimed the text sat *"under the
hero's own darkening gradient"*; the line above it is what made that false.
`/trips` measured 7.9:1 there only because `photoFor` had returned a dark
photograph. 🚨 **A caller that draws its own text over a hero owes that text a
scrim**, the same way it already owes it the credit (`creditRenderedByCaller`).

**2. The veil started at a fraction of the HERO's height** — 36%, or 18% on an
inset one — **and the text block's height is set by its CONTENT.** The two are
unrelated. A trip card whose overlay is four rows deep starts its eyebrow near
the top of a 172px card, where a ramp beginning at 18% has barely started. The
18% was itself added when 36% turned out not to cover a topic tile: the same bug
being patched twice rather than named once.

🚨 **So the darkening now WRAPS the words** — `DaniKoScrim`, a lead-in that fades
the photograph down (26 phone · 34 tablet · 48 desktop), then a floor held across
the whole of the text however tall it turns out to be. 🚨 The lead-in is a RAMP
LENGTH, not the contrast: what clears 4.5:1 is the 0.66 floor behind the words,
and it is the same at every width. A contrast complaint is fixed by raising the
floor, never by lengthening the ramp. Nothing divides by a height, so no hero size and
no amount of copy can put a word above the ramp. Two forms: `.below` for a text
block at the foot of a picture, `.above` for a masthead at its head. A cover puts
one at each end with a `Spacer` between, so the middle of the photograph stays
bright — a scrim over the whole picture would guarantee contrast by throwing away
the photograph, and the brief was *"show a lot of images"*.

🚨 **The floor is 0.66 and it is arithmetic, not taste.** Against the worst
photograph there is — a pure white one — `#040A16` at alpha `a` composites to
`255 − 245a`, and white text needs a background at or below sRGB 119 to reach
4.5:1, giving `a ≥ 0.556`. **But the eyebrow and the meta line are white at 0.82
and they are the smallest type on the hero**; re-solving for those needs
`a ≥ 0.65`. The floor is set by the annotation, not by the title — the opposite
of the intuition, and why the old veil's comment (*"still 7:1 over the veil"*)
was true only where the ramp had arrived. The deep end stays the mockup's 0.80.

After: cover masthead **19.34:1**, cover title 15.95:1, and every Korea tile
title and eyebrow between 10.8:1 and 19.5:1, all measured on the build.

🚨 **The test changed too, and the old one is the reason this shipped.**
`dani_ko_hero_test` asserted `veil.height == hero.height` — that the gradient
covered the whole picture. It did, and every word could still be illegible. The
assertion is now **containment**: each overlaid line's rect sits inside the held
band. That is only expressible because the scrim wraps the text; a
`Positioned.fill` sibling has no relationship to the words at all.

#### 🚨 6.3f A DIAGRAM IS A THIRD KIND OF PICTURE, and it is not a scene

Added 2026-08-08, `lib/design_system/widgets/dani_ko_diagram.dart`.

Ted, on the 103 cards that hold no photograph:

> *"they should show photos/pictures/diagrams as much as possible. if talking
> about Tmoney card - show a picture of it. if it is to be used at a subway /
> bus gate - show picture of it, show how exactly to use and when to use.
> Korean bus and subway requires you to tap both when entering and
> LEAVING(getting off)."*

**There is nothing to source for those 103, and that is a fact rather than a
gap in our effort.** `docs/collected/imagery/photos.json` holds 1,102 한국관광공사
photographs across 47 topics; every one of those topics is scenery, a place or
food. Not one frame shows a subway gate, a T-money card, an ATM or an
immigration desk — and the 103 image-less cards are precisely the **procedural**
ones. A photograph of a gate could not have taught the thing that costs money
anyway: that you tap on the way **in** and again on the way **out**. One drawing
shows both.

So there are now three kinds of picture and they are not interchangeable:

| | Carries | Semantics | Colour | Where the words are |
|---|---|---|---|---|
| **Photograph** | a licensed frame + its credit | alt text | the picture's own | caption |
| **Drawn scene** (§6.3) | mood, when no photograph exists | `excludeSemantics` | fixed literals, same in both modes | none |
| **Diagram** (6.3f) | **fact — a procedure** | described, `Semantics(image: true)` | **theme tokens**, legible in both modes | real `Text`, over the painter |

The three differences from a scene are the whole of the rule:

1. 🚨 **The labels are real `Text` widgets composed OVER the painter — never
   canvas text.** Canvas text does not scale with the system size, cannot be
   selected, and does not exist for a screen reader. Past a text scale of 1.3
   the two beats under a strip stop sitting side by side and stack
   (SPEC-03 §4.1).
2. 🚨 **It draws from the palette, not from literals.** A scene has its own
   light; a diagram has to be READ, so §9 binds it in both modes. The accent is
   `blueText` and never `blue` — flag blue is 2.41:1 on the dark background and
   the reader is the one shape that may not disappear. Measured on the first
   diagram: labels **6.40:1 light / 7.68:1 dark**, painted shapes 3.75:1 at
   worst against §9's 3:1 UI floor.
3. 🚨 **Red only where §2.4 licenses it, which is DANGER and never a deadline.**
   Rewritten 2026-08-10; it read *"no red, ever"* until then. A drawing may
   paint `red` on the one subject that can injure the reader, and on nothing
   else — not on a fare, not on a date, not on a cost, not on a wall. A penalty
   fare has neither a date nor a body attached, so `tap-in-tap-out` still draws
   none and `test/widget/tap_in_tap_out_test.dart` still scans for `red` and
   `redSoft` in both modes and fails on either. What changed is that the
   assertion now names WHY the drawing is flat — it is absent from
   `kDangerDiagrams` — rather than asserting a blanket ban that is no longer
   true of the corpus. The corpus-wide form of it is
   `test/widget/red_means_danger_test.dart`, which renders **every** diagram in
   both modes and holds one relationship: **a strip contains red if and only if
   its drawing is in `kDangerDiagrams`.**

**It is NOT carried by `media[].asset_url`, and that was the tempting mistake.**
That field means "an https image on our own R2 host, with a credit line", and
both `ImageGalleryBlockView._usable` and `CardArt.isRenderable` enforce it —
because 공공누리 has no type that drops 출처표시 and dani.ko shares the place data
layer with dani.go. A painted diagram has no URL and needs no attribution
because we drew it, so it gets its own `diagram` block whose payload is a
**registry id** (`lib/core/imagery/card_diagram.dart`) and nothing else. The
bundle carries a name; the binary carries the drawing, which is also what keeps
it on screen for a traveller with no data.

🚨 **Its words live in the registry, not in the widget.** `planTextOf` flattens
every block for the exported page — §7b's "artefact that travels furthest" — and
a diagram whose sentences lived inside a `CustomPaint` would print as nothing,
silently dropping the ₩1,550 from the page somebody carries.

The block sits at **index 1 in the bundle, directly under `answer_box`**. On the
card page it draws **under the photograph row and above everything that is
reading** — the body, the venues, the film, the citations. Ted's complaint was
*"the answers are too much reading, not much looking at them"*; a drawing below
2,000 words has not answered it, and a scrolling row of photographs is not
reading.

##### 🚨 6.3g THE COMPOSITION RULE — a diagram of a SEQUENCE is a strip, not panels

Added 2026-08-09, after the first diagram failed its review.

`tap-in-tap-out` originally drew **two square panels side by side**, `Tap in`
and `Tap out`. Same reader, same post, same card, same signal arcs, same grey
gate — the only difference between them was a small arrow pointing right in one
and left in the other. Shown it, Ted said:

> *"they look pretty much the same to me. i don't understand your question"* ·
> *"still don't know what the differences are"*

That is the defect stated, not a question asked. **A picture whose halves are
indistinguishable cannot teach that there are two different moments**, which was
the entire fact it existed to carry.

So three rules now bind any diagram of a sequence, and they are about
COMPOSITION rather than styling:

1. 🚨 **A sequence is drawn as ONE STRIP READ LEFT TO RIGHT**, not as N panels.
   `tap-in-tap-out` is now street → tap → the ride → tap → street, so *"twice in
   one journey"* is the SHAPE of the picture instead of a caption under it, and
   the two taps are told apart by **where they sit on the line** — which no
   glance can confuse. The repeated token is deliberately identical at both ends:
   it is the same action, and repeating it is the claim.
2. 🚨 **Where two cases differ, draw both, and make them look nothing alike.**
   The subway lane is gates and a carriage on rail; the bus lane is two buses
   with the road between. A visitor's actual error — that on a bus the second
   reader is beside the **rear** door — was in small print before and is now a
   lane of its own. If two lanes ever share a silhouette, the distinction is
   back in the small print.
3. 🚨 **The consequence is not a footnote.** The line that says what the missed
   step costs is set at body size in a filled `blueSoft` box with a lead, never
   in `text2` at caption size under the drawing. Prominence is carried by size,
   fill and position — **never by red**. 🚨 §2.4 did not loosen this either: a
   consequence box is CHROME, and danger red is confined to the painted subject
   inside a strip. A red consequence box on a card about an earthquake would put
   danger red in the one place §2.4 reserves for a date.

**A test that proves "the panels differ" is not the same as a test that proves
they differ LEGIBLY** — the old byte-for-byte panel comparison was green the
whole time, because a reversed arrow is a real difference and a useless one.
`test/widget/tap_in_tap_out_test.dart` now asserts the relationship instead: the
accent must appear in exactly **two separated clusters of columns** per strip,
with a clear span of drawing between them, which one picture drawn twice cannot
satisfy.

##### 🚨 6.3h THE COMPOSITIONS — the general form of 6.3g

Added 2026-08-09, with the ten cards about the transit CARD itself
(`what-you-tap-with`, `where-the-money-goes-on`, `cashing-out`,
`climate-card-clock`), and extended the same day with the seven about a card
that lives on a PHONE (`phone-to-gate`, `top-up-by-phone-and-card`,
`three-wallets`).

6.3g is a rule about one drawing. Its general form is that **the composition
has to BE the claim** — that whatever the drawing asserts must be visible in
its shape before a word is read, because anything left to the caption is what
Ted could not see the first time. Thirteen compositions exist, and which one a
drawing gets is not a style choice:

| Composition | The claim its SHAPE makes | Class | Drawing |
|---|---|---|---|
| **Journey** | these things happen in this ORDER | `JourneyDiagram` | `tap-in-tap-out` |
| **Ladder** | each of these reaches FURTHER than the last | `LadderDiagram` | `what-you-tap-with`, `which-map-app`, `pharmacy-clinic-or-er` |
| **Panels** | these cases are NOT the same object | `PanelDiagram` | `where-the-money-goes-on`, `climate-card-clock`, `three-wallets`, `where-a-foreign-card-stops`, `the-price-is-the-price`, `what-counts-as-cash`, `the-car-at-the-kerb`, `where-a-ticket-will-sell-to-you`, `the-road-and-the-camera`, `which-number-for-what`, `the-alert-you-cannot-silence`, `four-ways-to-be-online`, `the-plug-and-the-cell`, `what-the-gate-reads`, `the-till-and-the-stall`, `one-card-the-whole-country`, `three-grades-of-coach`, `two-trips-called-dmz`, `the-terminal-on-the-counter`, `the-queue-is-a-machine` |
| **Bars** | these quantities are not CLOSE | `CeilingDiagram` | `cashing-out`, `the-pass-against-the-fares`, `the-refund-limits`, `one-won-over-the-edge`, `the-flight-is-not-the-journey` (minutes, not won), `three-prices-for-eating-everything`, `the-later-you-cancel` (percent kept, not won), `what-a-day-of-storage-costs`, `the-free-size-coin-toss` (per cent of a rack, not won), `the-antique-line` (years, not won), `three-speeds-one-box`, `more-tests-than-the-law-asks` (items tested, not won), `the-six-fold-jump`, `the-ticket-the-hanbok-waives`, `pack-for-the-swing-not-the-average` (degrees, not won), `one-nursing-room-in-four-bars-fathers` (rooms), `none-of-the-166-in-seoul` (sites, a dot for Seoul), `free-at-the-palace-full-fare-at-the-gate`, `certified-for-export-not-for-lunch` (items, a minimum-width bar) |
| **Route** | 🚨 **Draws nothing.** A route is a heading, a line saying how it differs from the route beside it, and numbered steps — no strip. Four stops across 340 units gives each object 64, and at 64 units an app, a wallet and a fare gate are all a rounded rectangle. A tile holds ONE subject; a route asked it to hold four | `RouteDiagram` | `phone-to-gate`, `refund-before-the-bag-goes`, `the-desk-in-order`, `hailing-without-a-korean-number`, `before-you-walk-away`, `what-your-number-unlocks`, `three-ways-to-get-the-tax-back`, `the-five-hundred-won-comes-back`, `to-the-delivery-zone` |
| **Matrix** | of these combinations, exactly one STOPS | `MatrixDiagram` | `top-up-by-phone-and-card` |
| **Prompt** | the screen asks you something, and one of the two answers is right | `PromptDiagram` | `the-currency-prompt` |
| **Distance** | you did not land where they did | `DistanceDiagram` | `where-you-landed` (B-04 alone since 2026-08-22) |
| **Pass** | 🚨 **Draws no strip.** The answer is printed on the thing in your hand, so the drawing is that thing with its lines as real text and the one line that matters in a filled accent box. The first version drew a fork and two blocks of windows beside the pass; Ted: *"the right diagrams don't look like what it says"* | `PassDiagram` | `which-terminal-is-yours` |
| **Link** | two separate buildings, and these are the only ways across — the one that is not possible stops short | `LinkDiagram` | `two-airports-one-site` |
| **Floors** | what you want is on a different FLOOR from the one you are standing on | `FloorsDiagram` | `inside-either-terminal` |
| **Legs** | these are not four points on one scale: what the fare buys is where it LEAVES you | `LegDiagram` | `four-ways-into-seoul`, `three-ways-out-of-gimhae` |
| **Clock** | this had already stopped by the time you got here | `ClockDiagram` | `the-night-clock`, `the-bag-cut-off`, `open-at-two-in-the-morning`, `the-counter-that-is-open`, `the-clock-after-midnight`, `the-call-comes-once` (minutes, not hours), `the-monday-clock` (two days, not one night), `the-kitchens-after-midnight`, `the-street-wakes-at-five` (quiet hours, and a row with no band at all), `bukchon-after-five`, `the-closing-run` (a window that meets the same wall the doors do), `the-waiver-has-an-end-date` (days across a year's end, a reader drawn as a bracket straddling the wall, and a row with no band), `the-window-shuts-at-the-counter` (hours either side of wheels-down, and one tick on the marked line), `one-approval-many-entries` (months, with entries as ticks), `the-summer-you-booked` (days across a summer, two windows and a full-width band), `who-holds-it-this-week` (days over two months, a band that opens at another's wall), `open-all-night-not-to-everyone` (one night, two windows meeting one wall and reopening at different hours), `free-through-the-holiday-shut-the-day-after` (days over a week, one band ending where another begins), `thirty-five-minutes-nothing-flies` (one working day, a 35-minute wall) |
| **Parts** | one object, and the biggest thing on it is what is read first | `PartsDiagram` | `what-the-driver-reads` |
| **Allowance** | these ceilings are not the same KIND of number | `AllowanceDiagram` | `how-long-the-list-lets-you-stay` |
| **Form** | this is the thing you are about to fill in, and these are the lines that go wrong | `FormDiagram` | `the-arrival-form`, `what-you-may-carry-in`, `the-tablet-at-the-door`, `the-table-tablet`, `the-panel-on-the-wall` |

Worked, each against the failure it is built for:

- **The ladder.** Six instruments, and the `+` tier differs from the tier above
  it by one capability. The tempting drawing is six identical cards with
  different captions and a small `+` badge on one — which is the reversed arrow
  again. Instead each rung physically extends: four reach-glyphs, then five,
  then six, and `travelCardPlus` is **two overlapping cards**, a different
  silhouette rather than a badge. `transit_card_diagrams_test.dart` asserts
  rung 2 ends further right than rung 1 ends further right than rung 0 — an
  assertion six captioned rows cannot pass.
- **The bars.** Four refund ceilings from ₩20,000 to ₩500,000, a factor of
  twenty-five. Four equal rectangles with different numbers beside them is a
  table wearing a picture, so each bar is drawn to length against a shared
  full-width track, and the test asserts the last is over five times the first.
  A legibility floor keeps the smallest visible; the floor may not eat the fact.
- **The panels.** `climate-card-clock` is the one that proves the wall rule. It
  carries four real dates — 31 Aug, 29/30 Sep, 1 Oct 2026 — and §2.1's chrome
  sense of red would license it for exactly that. **It still draws none**, and
  after 2026-08-10 it draws none for a stronger reason than "no red in
  drawings": §2.4 makes red inside a picture mean DANGER, so reddening a date
  here would now say the wrong thing rather than merely too much. The pass that
  ends meets a solid WALL with hatching beyond it, and the pass that continues
  runs off the right edge of the tile, clipped on purpose. A wall stops a track
  more legibly than a hue, and it is now also the only thing that may.

- **The route.** Added 2026-08-09 with the seven cards about a card that lives
  on a PHONE. `A-05.md` is a one-word answer — *can I top up inside Apple
  Wallet? no* — and it keeps being asked because the shape is invisible: the
  card is right there in Wallet, so Wallet looks like where you would reload it.
  Drawn, the surprise goes: the note enters the box on the LEFT and the box in
  the middle has a solid bar where its inlet would be. Two routes, and they are
  told apart by their **box count** rather than by a caption — an iPhone splits
  the money and the card across two apps and gets three boxes, Android holds
  both in one and gets two, with the thing that can stop it (`the default
  contactless app`) drawn as a switch **on the wire**, because a condition on a
  connection is not a place you travel to.
- 🚨 **The matrix, which is the one composition where REPEATING a part is the
  claim.** PRD §32's acceptance test is six device/card combinations, and
  `decision_engine.dart`'s header says why it exists: **Visa cannot top up
  mobile T-money in-app on an iPhone, nowhere in English says so, and the
  traveller finds out at a machine with a queue behind them.** Two columns
  rather than five, because the fact is binary — Visa, and everything else — so
  it is four cells with one that stops. §6.3g would seem to forbid this: three
  of the four cells are deliberately the same picture. It does not, and the
  distinction is worth stating, because it is the rule's actual content: 6.3g
  bans two parts that ought to be **different subjects** and are told apart only
  by a caption. A matrix's cells are the same subject BY CONSTRUCTION — one act,
  attempted four ways — and sameness is the baseline the odd cell is found
  against. So the odd cell is odd by SHAPE: a solid wall across its track with
  hatching beyond, and a balance meter left empty. **Never a red cross**, which
  is what a works/does-not grid pulls hardest towards — and under §2.4 the ban
  is now absolute rather than merely conventional, because a top-up that fails
  is not a threat to anybody's body and red inside a strip means nothing else.
  `phone_payment_diagrams_test.dart` holds it as two relationships — the
  Visa column must diverge across devices several times as much as the column
  beside it, and the cell that stops must paint materially less of the subject
  than the leanest cell that does not.
  🚨 **A matrix is also the composition 200% type destroys first**, and it is
  allowed to lose its alignment there and not one of its facts: past a text
  scale of 1.3 the grid becomes a column and every cell takes the full heading
  `iPhone · Visa`. Its `onPaper` form names both axes per cell for the same
  reason — a printed line reading `iPhone — no in-app top-up`, with no network
  beside it, is the sentence a Mastercard holder would act on wrongly.

- 🚨 **The prompt, added 2026-08-09 with the five cards about MONEY AT THE
  COUNTER.** Every other composition here teaches a fact. This one has to make
  a SCREEN FAMILIAR, because dynamic currency conversion is a prompt that
  appears at a terminal, offers a helpful-sounding choice, and the
  helpful-sounding answer costs a 3–8% service fee on top of a 1–2% exchange
  margin against about 1% for taking won — 7.1% more on the Korea Consumer
  Agency's own $1,000 example. The moment it exists for is five seconds long,
  at a counter, on a device the reader has never seen. So the drawing is the
  prompt itself — the two buttons as they sit on the screen, the right one
  filled and marked, the other an empty outline — with the two costs drawn to
  LENGTH underneath, which is the bars' claim reused inside it.

  🚨 **And the pressed button is not a hand.** Three drawn hands were
  screenshotted at size and none came back as a hand: a finger up through the
  screen crossed the gap between the buttons and put its knuckle on the wrong
  one; a capsule and a lozenge read as a plug going into a socket; a full
  silhouette smoothed into a blob with a nub at 135px wide. An organic shape
  has a size below which it stops being that shape, and a diagram strip is
  below it. The choice is drawn the way a chooser is drawn instead — two round
  marks, one filled and one empty — which is legible at any size and needs no
  gesture to interpret. `counter_money_diagrams_test.dart` holds it as a
  relationship with no coordinate in it: the densest run of subject colour in
  the strip must outweigh every other subject-coloured pixel, which two buttons
  drawn alike cannot satisfy.

  🚨 **Nothing in it is red**, and this is the drawing that tests §2.4's
  cost/danger line hardest in the whole corpus — "do not press this button" is a
  stronger pull towards red than `climate-card-clock`'s four dates. **A 7.1%
  service fee is not a threat to a person's body**, which is the entire
  distinction §2.4 exists to make: if the rule could not separate a DCC prompt
  from an earthquake alert the rule would be wrong. So the answer is carried by
  fill, by mark, by the won sign and by the words `Press this one, every time`
  in the list underneath, each of which says it independently.

- 🚨 **The clock, added 2026-08-09 with the seven cards about the AIRPORT AND
  ARRIVAL.** It is the only composition here that makes an argument out of
  POSITION ON A SHARED AXIS, and `content/research/B-03.md` is why it exists: a
  23:40 landing sounds like a late arrival and is really a 00:40–01:10 arrival
  in the hall, because Incheon itself tells passengers to allow one to two
  hours for immigration and baggage. By then the last express has been gone two
  hours, the last train to Seoul Station an hour, and the last airport bus with
  it — and a traveller finds all three out at the kerb. Seven bands against one
  vertical line show which ones have a wall to the LEFT of where the reader is
  standing; a list of last-departure times only states it. The Gyeonggi service
  is the one row that is not a band at all: ten routes that run exactly twice a
  night are two ticks with empty track between them, because a thin band would
  say "runs briefly" and the fact is "00:30 or 04:10, and nothing else".

  🚨 **It is also the drawing that tests the wall rule hardest in the whole
  set.** `climate-card-clock` carries four dates; this one carries a LAST
  TRAIN, which is a deadline in exactly the sense §2.1 reserves 태극기 red for.
  It still draws none, and §2.4 hardened the reason rather than softening it: a
  missed last train strands you, it does not injure you, so red would now be a
  false claim and not merely an over-loud one. A band that stops meets a solid
  wall with hatching beyond it, and one that carries on runs off the right edge
  clipped, the way `passContinuing` does. `the-bag-cut-off` reuses the
  composition to make the opposite argument — one line at 14:00 and which side
  of it you are on — which is what a composition is for.

  🚨 **A band with no end is drawn PAST the edge, and that was learned by
  looking.** The first train of the morning starts sixteen minutes before the
  clock ran out, and ended at the edge with a rounded cap it came back off the
  screen as a full stop rather than as a service that carries on. The clock's
  right edge moved to 06:00 and the band now runs off it.

- **Distance and legs**, same day. `where-you-landed` puts Incheon Terminal 1,
  Terminal 2 and Gimpo on one line with the city at the end of it, each marker
  at its published all-stop journey time to Seoul Station — so `B-04.md`'s own
  opening, that Gimpo is inside the city rather than "out on an island half an
  hour of motorway away", is the shape rather than a table. 🚨 Two of those
  times are six minutes apart, which at this scale is two markers touching, so
  the painter enforces a minimum separation and pushes the further one further
  out; the distortion only ever runs in the honest direction. Gimpo is told
  apart by SUBJECT and not by position alone: five railways ruled beneath it
  against one under each of the others.

  `four-ways-into-seoul` is `B-02.md`, whose short answer is that the right
  choice depends on where you are SLEEPING and not on which option is fastest —
  which is precisely what a fare table cannot say, because a table sorts four
  options onto one scale and implies a winner. Each row draws the fare to
  length AND the place it leaves you, and the endpoint sits at a FIXED position
  while the bar does not, so the bar reads as money and never as distance. The
  two railway rows share an endpoint because they are the same railway, and
  they are told apart by what is inside the carriage: a rack above a seated
  traveller against a doorway with somebody standing in it and a case at their
  feet, which is the difference `B-02.md` spends a paragraph on.

- 🚨 **Parts, added 2026-08-09 with the eleven cards about GETTING AROUND.** It
  is the only composition here with ONE subject. Every other one compares two
  or more things; this one takes a single object apart and asserts an order of
  precedence *within* it, which is what `content/research/B-22.md` needs and
  what none of the others can carry. Four blocks on one screen — the place name
  in Korean largest, the English under it, the road address split into a long
  segment and a short one, the phone number smallest — say "the driver reads
  the name first" before a word is read, where four bullets would not. PRD §4.7
  is the same claim from the other side.

  🚨 **It is a drawing ABOUT the handover surface and must never become a
  second one.** `dani_ko_handover.dart` is the single place Korean leads at
  display size and `handover_typography_test.dart` holds `DdsKorean.display()`,
  `displayBrightnessProvider` and the speech engine to one caller each. Nothing
  in this composition renders a glyph at any size: the blocks are blocks, and
  every word beside them is ordinary body type.

  🚨 **The pip runs sit OUTSIDE the phone and run ACROSS, and that was learned
  by looking.** Stacked inside the frame — the idiom `_Route` uses, where the
  boxes are far apart — the four-pip column for block 3 ran straight into the
  five-pip column for block 4 and the whole right edge came back as one line of
  dots. A count nobody can take is not a tie to anything.

- 🚨 **Allowance, added 2026-08-09 with the sixteen cards about ENTRY AND
  DOCUMENTS.** It is the first composition here whose rows are quantities of
  DIFFERENT KINDS, and `content/research/G-08.md` is why it had to be: `90 days`
  and `3 months` are one day apart on a 182-day scale and are not the same
  ceiling, because three months runs 89, 90, 91 or 92 depending on which
  calendar months the stay spans. Two bars one day apart told apart by a
  caption is §6.3g's failure with a legal consequence attached, so length is
  not the difference — SILHOUETTE is. A day count is one continuous bar
  stopped by a solid WALL; a months allowance is that many discrete BLOCKS
  ending in three fading ticks and no wall, because where it ends is not a
  fixed number of days; a rolling window is a short filled run inside a long
  bracketed track; and a nationality not on the list has no bar at all and a
  wall standing where the track would begin. Four shapes, told apart before a
  word is read. 🚨 Its counts are the only figures in the set asserted against
  a DATASET rather than a literal: `entry_documents_diagrams_test.dart`
  recomputes the six buckets from `content/entry_rules.json`'s 232 sourced rows
  and fails the day the dataset moves, because a drawing that told somebody
  they may stay longer than they may is the worst defect this corpus can ship.

- 🚨 **Form, added the same day and used twice.** A form is the one subject
  where drawing the thing itself beats every paragraph about it, which is
  Ted's complaint stated as a card — *"the answers are too much reading, not
  much looking at them."* `the-arrival-form` is a phone with a
  passport-photograph box at its head, six rules down it and the address row
  drawn at more than twice the height of the others, because that row asks for
  the same thing in two languages and its height IS that fact.
  `what-you-may-carry-in` is the same composition and a completely different
  object — a square sheet of paper with a fold down it and two DOORS beneath,
  one open and one with a counter and an officer across it. Marked rows are
  FILLED and never reddened, and every marked row says so again in words
  underneath, because on paper there is no fill.

- 🚨 **`two-filings-one-trip`'s waiver box failed twice, and only a screenshot
  caught either.** Drawn as a portrait sheet with rules across it and a blue
  band at the foot it was the box beside it a second time — an upright
  rectangle with lines in it and a blue block, twice, which is §6.3g exactly.
  Redrawn as a landscape poster over a wall line it read as a LAPTOP: the wall
  became a base and the sheet became a screen. §6.3h's list of the same
  failure now runs to a monitor, a station, a printer, a laptop and this. What
  a waiver actually IS, is a window of dates that runs and then stops, so it is
  a calendar with its days filled meeting a solid wall with hatching beyond —
  `climate-card-clock`'s idiom reused, and it carries the one fact the box
  exists for, which is that on 1 January 2027 there is nothing there.

- 🚨 **Four more marks in the entry family were redrawn after being
  screenshotted, and none was wrong in the code.** A police station drawn as an
  outlined box under a rule read as an open SHELF UNIT with something on top;
  it is a wide lamp on a bracket over a solid fascia now, because the lamp is
  what makes a doorway a police station at any size. Two telephone handsets
  nine units tall read as two PILLS; the fact is that a mission publishes two
  numbers, so it is a plaque with two bars of different lengths on it. Four
  uprights on a ground line read as a FENCE; two arches read as two borders,
  which is the claim. And a marker on a stalk over the flagged field on a
  check-in screen cleared its own field and landed on the row above — the same
  failure recorded for a knuckle over the wrong button. One filled field among
  five outlined ones needs no pointer.

- 🚨 **A route's pips are CENTRED under the object and drawn BELOW it**, and
  both halves were learned by looking. Run from the left edge of a box's slot
  they detached from what they counted the moment a lane had only two stops,
  because a two-stop lane gives each box 142 units and the object sits in the
  middle of it: `two-filings-one-trip`'s first lane came back with a single dot
  in the corner of an empty half of the strip. Inside the box they landed among
  the immigration booth's legs.

- 🚨 **`who-moves-first` draws NO NUMBERS, and that is the drawing.**
  `content/research/G-14.md` could not retrieve Appendix Tables 7 and 8 of the
  Immigration Control Act's enforcement rule and says outright that every won
  figure in circulation is second-hand. A bar chart of unverifiable amounts is
  the confident wrong answer this family cannot afford, so the drawing carries
  the two VERIFIED routes — a notice filed with a receipt beside it, and a
  record stamped against you with a wall behind it — and sends the reader to
  1345 for the number. `entry_documents_diagrams_test.dart` asserts the whole
  drawing contains no `₩` at all.

- 🚨 **Four marks in `which-map-app` were redrawn after being screenshotted at
  size, and none of the four was wrong in the code.** A box with a filled
  rectangle in it and a rule beneath read as a LAPTOP rather than as a train —
  §6.3h had already recorded the same failure once, for a station — so the
  transit mark now has a curved roof, lamps below the screen and a rail. Two
  footprints read as two dots, because a print is a shape seen from above and
  everything else in the row is side-on; it is a walking figure now. A car body
  with a roof and no wheels read as a printer. And a page with an arrow across
  it read as "export"; it is two pages now, the front one legible. **Every one
  of these was invisible to the test suite and obvious in one screenshot.**

- 🚨 **Three documents drawn as three outlines are a jumble, not a stack.**
  `the-road-and-the-camera`'s permit panel first drew a passport, a licence and
  a permit as three strokes with nothing behind them; they overlapped
  transparently, the passport ran through the permit and the stamp landed on
  the licence's photograph. Each one is now FILLED with the tile's own ground
  before it is stroked — `walletCard`'s idiom — and only the permit is in the
  accent, because accenting all three would say all three are the answer.

- 🚨 **Hatching has to leave the nothing visible.** The walled sign-up step in
  `where-a-ticket-will-sell-to-you` was first hatched at `pane` weight and a
  12-unit pitch, and it buried the two steps underneath it completely: the
  panel came back as a scribble. Hatching says "beyond here, nothing", and it
  can only say that if what is beyond can still be seen. It is drawn in `rule`,
  the thinnest brush in the file, at a 16-unit pitch.

- 🚨 **HEALTH AND EMERGENCY, added 2026-08-09 — thirteen cards, five drawings,
  and NO NEW COMPOSITION.** This is the only family in the corpus somebody reads
  while frightened, and Ted's argument for the whole run is strongest here: a
  person looking up *"Pharmacy, clinic, or ER?"* at 02:00, or the emergency
  numbers during an accident, cannot read 2,000 words. Every one of the five
  reuses a composition that already existed, which is what a composition is for:

  · `which-number-for-what` is FIVE PANELS for five telephone numbers, and not
    one of them is a telephone. H-01 to H-05 are five cards about five
    ORGANISATIONS and the reader's question is a SITUATION, so the panels are a
    wheeled trolley, a police frontage, a signpost, a passport with a calendar
    on it and a conversation with a third party in it. §6.3h had already
    recorded two handsets nine units tall coming back as two pills; five of them
    with five numbers underneath would be that failure at scale. H-05's answer
    is a NEGATIVE — the tourist police booths an older guide sends a visitor to
    were disbanded in 2023 — so the police panel carries the station and the
    struck-out booth beside it, `climate-card-clock`'s wall reused.

  · `pharmacy-clinic-or-er` is the ladder, and it is the drawing this whole run
    exists for. Five rungs — a 24-hour store, a pharmacy, a clinic, a hospital,
    an emergency department — each reaching one glyph further than the one above
    it, ending at the only rung that can resuscitate you and the only rung that
    costs 90% of the bill if it turns out you did not need it. 🚨 **It states no
    won figure at all**, `who-moves-first`'s rule reused: `H-08.md` says outright
    that Korea publishes no non-resident price list and that every figure in
    circulation is second-hand, so the drawing carries the one cost fact that IS
    sourced and no price. H-10 rides the same ladder rather than getting a
    second drawing, because its bottom three rungs ARE its answer.

  · `open-at-two-in-the-morning` is the clock, and it needed ONE new silhouette.
    Korea's 242 public late-night pharmacies open at 22:00 and shut at 01:00,
    which is neither `stops` (starts at the left edge) nor `resumes` (never
    ends), so `DiagramSpan.window` was added: a band with empty track on both
    sides and a wall at the end. At 02:00 two of the four rows have a wall to
    the left of the reader and two run straight through, which is the answer
    before a word is read.

  · `before-you-walk-away` is the route, and it is the one that ties four cards
    from three different accidents together: being billed for treatment, losing
    something, being overcharged. All three fail in the same place — the
    certificate, the photograph or the plate number you did not take — so all
    three lanes carry a `oneWay` on the last wire, which is
    `refund-before-the-bag-goes`' idiom on a completely different subject.

  · `the-alert-you-cannot-silence` is three panels, and the structural
    difference sits in the SAME PLACE on all three: a toggle on two of them and
    a solid wall standing where the toggle stands on the third. That sameness is
    deliberate in the sense a matrix's is — it is the baseline the odd one is
    found against.

  🚨 **This family was drawn flat and is no longer flat. §6.3j is the pass, and
  it changed five elements of the thirteen** — the casualty, the police lamp,
  the emergency bay, the ground that moved and the water over the line. Every
  other thing in the family, including all four rows of the 02:00 clock and
  every lane of `before-you-walk-away`, is exactly as it was drawn.

- 🚨 **CONNECTIVITY AND ACCOUNTS, added 2026-08-10 — eleven cards, FOUR new
  drawings, and NO new composition.** The efficiency is the finding rather than
  a boast: **five of the eleven were already answered by drawings this corpus
  holds**, and building them again would have been §6.3g at the level of the
  corpus rather than of the strip.

  · `which-map-app` gains **K-06, K-07 and K-13** and was not touched. Its
    Kakao rung already IS Roadview and Skyview and the two-language build,
    which is K-07's card; its Naver rung already IS the four languages and the
    translated place page, which is K-06's; its Google row stopping two glyphs
    in already IS the one thing K-13's own short answer says will really change
    a reader's day. Its caption already carries the romanisation wall both
    setup cards spend a section on.
  · `hailing-without-a-korean-number` gains **K-09**, whose card is literally
    that drawing: Kakao T's payment step runs through a Korean wallet a visitor
    cannot register in, and k.ride hails the same cars with no wall at all.

  The four that were drawn:

  · `four-ways-to-be-online` is **K-01 and K-04**, and it is panels because the
    three options are priced on three DIFFERENT DENOMINATORS — an eSIM per
    person, a rented router per party, a roaming pass per day — which is
    exactly what a fare table flattens onto one scale, implying a winner K-01
    does not have. The four figures are the hardest set of silhouettes in the
    file, because a profile, a router, a bill and an access point are all small
    rounded rectangles: they are told apart by a CLIPPED CORNER with contact
    pads, a charge gauge under a stub aerial, a day track cut by boundary bars,
    and the only one with ground under it.
  · `the-counter-that-is-open` is **K-02**, and it is a clock because its answer
    is an HOUR. Four bands against one line at 20:00, and exactly one of them —
    reseller ACTIVATION, which shuts at 19:00 while collection runs to 21:00 —
    has a wall to the left of where the reader is standing. That is "you can be
    handed a SIM you cannot switch on", drawn.
  · `what-your-number-unlocks` is **K-03, K-05 and K-12**, three lanes told
    apart by WHAT SITS ON THE WIRE: a one-way barrier at the SIM swap, a solid
    wall at the resident registration number, and nothing at all on the
    passport route Naver opened on 4 June 2026. The lane with nothing on it is
    the answer. 🚨 It reuses `smsCode`, `koreanWallet` and `overseasCard` from
    the taxi drawing deliberately — B-20 meets this wall inside one app and
    K-03 states it in general, and two sets of objects would say they were two
    different walls.
  · `the-plug-and-the-cell` is **K-14**, one card about two subjects, which is
    what panels are for. It is also the first danger drawing outside health —
    see §6.3j.

- 🚨 **SIX MARKS IN THE CONNECTIVITY FAMILY WERE REDRAWN AFTER A SCREENSHOT,
  AND NOT ONE WAS WRONG IN THE CODE.** A circle with two holes and an earth
  clip on each side came back as A HEAD WEARING HEADPHONES — two eyes and two
  ear cups is exactly what those five shapes are — so the socket gained a
  square FACEPLATE, because nothing with a face is square, and the clips moved
  to twelve and six o'clock where a Schuko's actually are. The same happened at
  glyph size to the two small sockets in the panel below it, and they took the
  plate too. Three squares in a column beside a router read as three PICTURE
  FRAMES; they are handsets now, upright, with a speaker slot. The router's own
  two lamp dots under its charge gauge made the face a CAR STEREO, and are
  gone. A pool of cover drawn as an open ELLIPSE around the ground line came
  back as two hoops threaded on a wire, the second one cutting through a bus's
  wheels; cover is a stretch of ground, so it is a splay down to a solid bar
  lying on the ground, with bare ground between the two. And a power bank drawn
  as a slab with three dashes and a bar came back as a KEYCAP — it carries a
  run of charge lamps and a USB port with a tongue in it now, which is what
  makes a slab a battery.

- 🚨 **SEVEN MARKS IN THE HEALTH FAMILY WERE REDRAWN AFTER A SCREENSHOT, AND
  NOT ONE WAS WRONG IN THE CODE.** The list §6.3h keeps of things that read as
  something else now runs: a monitor, a station, a printer, a laptop, a shelf
  unit, two pills, a fence, a head in headphones, three picture frames, a car
  stereo, two hoops on a wire, a keycap — and these seven.

  · A wheeled trolley with a **diagonal pair of struts** for its raised head came
    back as a SUN-LOUNGER; two thin diagonals under a reclining shape are what a
    deck chair is made of. The head is a solid panel now. Its occupant was drawn
    as a torso and a separate legs block and read as two cushions with a seam;
    it is one continuous body with a blanket fold cut into it.
  · A curtained bay — three vertical bars on a rail with a bed under them — came
    back as a RADIATOR. Parallel uprights in a frame are a radiator at strip
    scale whatever they were meant to be. It is a bed with a raised head, a head
    on the pillow and a drip stand at arm's length now.
  · A pharmacist drawn as a head above a box above a counter came back as a
    CAMERA ON A STALK: a lump with a ball over it is a camera at glyph size.
    Advice is a speech mark over a counter now, with no face in it at all.
  · A closed booth outlined under a canopy and hatched across the whole of it
    came back as SCAFFOLDING — the booth's own horizontals and the hatch strokes
    crossing into a scribble. §6.3h already said hatching must leave the nothing
    visible; the correction is that it must also cross no horizontal member of
    what it covers. The pitch went to 22 units and the strokes now sit in the
    empty band below the canopy.
  · An earthquake drawn as a ground line with a PEAK in it and a vertical stroke
    with two arcs over it came back as A PERSON WAVING — the peak read as legs
    and the stroke as a body. It is a fissure with the two sides of it at
    different heights now, with something out of plumb standing on one side and
    shaking both ways.
  · A framed plus for the medicine carton was the pharmacy's own hanging sign a
    second time, one strip below — §6.3g inside a single drawing. The carton is
    a carton with a capsule on it now, and the sign is the only cross in the set.
  · A prescription's stamp drawn as a FILLED DISC read as a bullet point, which
    turned the slip into a list; it is a ring with a signature stroke beside it.
    Same lesson as the rounded cap that read as a full stop, from the other side.
  · And a flat rule drawn UNDER the surface to mark the level a flood had passed
    was invisible the moment the water covered it, so the picture said "there is
    water" rather than "it is over the line". A graduated post half-submerged
    says both.

🚨 `CardDiagram` is a **sealed family** rather than a wide enum for this reason:
the switch in `dani_ko_diagram.dart` does not compile until a new composition
has a painter, and a composition with no painter would render a blank strip
with no complaint from anything.

- 🚨 **EVERYDAY CONVENTIONS, added 2026-08-10 — eighteen cards, ELEVEN new
  drawings, NO new composition, and THREE cards took a drawing that already
  existed unedited.** This is the last family of the run: with it, all 103
  image-less cards carry a drawing. It is also the family where "show, don't
  tell" is most literally true, because the answer is usually a SEQUENCE OF
  PHYSICAL ACTIONS performed in public, where a mistake is felt by the people
  standing around you.

  The three that were reused:

  · `the-plug-and-the-cell` gains **F-17** and was not touched. Its first two
    panels ARE that card — 220 volts at 60 hertz set by statute, two round
    pins, `100–240V ~ 50/60Hz` on the brick, and a hair dryer a Korean supply
    destroys. F-17's remaining facts, the KC mark and a 2026 laptop price, are
    prose facts with no shape.
  · `what-your-number-unlocks` gains **C-21** and was not touched. All three
    Korean ticketing platforms refuse a visitor at exactly the wall that
    drawing's middle lane already draws, and that lane's own note already
    names *"ticketing platforms that demand real-name verification"* as
    sitting behind it. Its caption already says to prefer the global build of
    an app, which is NOL World, Melon Ticket Global and YES24 Global stated
    generally.
  · 🚨 `what-the-driver-reads` gains **E-10**, and this reuse is a DESIGN
    CONSTRAINT rather than an economy. "Your hotel's Korean address and how to
    show it" IS the handover surface, and `dani_ko_handover.dart` is the single
    place in this app where Korean leads at display size —
    `handover_typography_test.dart` holds `DdsKorean.display()`,
    `displayBrightnessProvider` and the speech engine to one caller each. A
    second Korean-at-display-size drawing would break that by construction.
    That composition is a drawing ABOUT the surface: the blocks are drawn as
    blocks at their real relative sizes and no glyph is rendered at any size,
    and `everyday_conventions_diagrams_test.dart` asserts its subject
    description contains no Hangul at all.

  The eleven that were drawn, and the composition each one borrows:

  · `nothing-is-handed-to-you-in-the-shop` (**F-09, F-10**) is a ROUTE, and its
    three lanes are told apart by WHAT SITS ON THE WIRE —
    `what-your-number-unlocks`' idiom on a completely different subject.
    Nothing at all on the airport lane, a one-way barrier on the downtown one
    because the goods become an export the moment they are paid for, and a
    switch on the website one because an order placed after the cut-off never
    becomes a collection. Three shop fronts with three captions would be §6.3g
    exactly, and the two lanes that end at a counter end at the SAME counter,
    drawn once.
  · `where-the-haul-goes-home` (**F-12, F-13**) is PANELS, and the postage
    figures — ₩441,000, ₩242,500 and ₩74,000 for one 20 kg box — are drawn as
    bars INSIDE their own panel rather than as the drawing's composition. 100
    mL, 2 litres and ₩441,000 are three different KINDS of ceiling, and one
    shared scale would imply they were comparable, which is the mistake
    `how-long-the-list-lets-you-stay` exists to stop one subject over.
  · `the-phone-you-buy-here` (**F-18**) is PANELS, and its shutter panel
    reuses `the-alert-you-cannot-silence`'s idiom deliberately — a solid WALL
    standing exactly where a control would stand — because that is already
    this corpus's shape for "there is no setting for this". It deliberately
    does NOT reuse its colour: a loud photograph is an embarrassment, not an
    injury.
  · `the-store-inside-the-door` (**D-14, F-20**) is PANELS, and the ₩4,300
    lunchbox against the ₩12,615 restaurant dish is drawn to length, because
    that comparison is the card's argument and a picture of a lunchbox alone
    is a picture of lunch.
  · 🚨 `the-order-you-do-it-in` (**I-22**) is this family's A-25 and its
    hardest drawing. A bathhouse (찜질방) is two buildings stacked on each other
    and the single mistake is not knowing where one stops: downstairs is naked
    and separated by sex, upstairs is mixed and everybody is in the clothes
    the counter issued. It is a ROUTE with TWO LANES — `the-desk-in-order`'s
    shape, because a route is already how this corpus draws a procedure a
    person walks through — and the lanes share not one stop, so the line the
    card is about is visible before a word is read. The one-way on lane one is
    the etiquette the whole floor rests on: you wash, sitting down, BEFORE any
    water. The one-way on lane two is re-entry.
  · `the-side-you-stand-on` (**I-15**), `what-a-couple-does-in-public`
    (**I-17**), `nothing-the-street-will-take` (**I-18, I-19**) and
    `the-room-with-the-drain` (**E-07**) are all PANELS.
  · `two-prices-for-one-room` (**E-16**) is a CLOCK, because its answer is an
    HOUR: at 15:00 a hotel's check-in band has begun and a motel's overnight
    band has not, with the day-use band running underneath both. That is "the
    room is earning money all afternoon", drawn.
  · `two-rains-one-summer` (**J-19**) is PANELS, and the monsoon is a flat
    BAND while the typhoon is a few TICKS — `the-night-clock`'s Gyeonggi row's
    lesson, that a thin band says "runs briefly" where the fact is "these
    times and no others". 3.2 typhoons a year is an event count.

  🚨 **NO KISS IS DRAWN ON I-17, and that is the drawing rather than an
  omission.** What a picture can carry is what people DO — a link at the hand,
  a link at the arm, matching outfits, matching rings, a day count — and what
  the law reaches, which is two lines on a plate at one end of a long empty
  field. "This is uncommon" has no silhouette, and drawing a rare thing at the
  same size as a constant one would say they were equally common, which is the
  opposite of the card.

- 🚨 **FOURTEEN MARKS IN THE EVERYDAY-CONVENTIONS FAMILY WERE REDRAWN AFTER A
  SCREENSHOT, AND NOT ONE WAS WRONG IN THE CODE.** This family is thick with
  human posture and street furniture, which is the riskiest material in the
  corpus at strip scale, and it is the largest single haul the looking gate has
  taken. §6.3h's list of things that read as something else now also runs to a
  magnifying glass, a torch, a washing machine, a fridge, a vending machine, a
  musical stave, a ruler, a window, a bucket and a snail.

  · An escalator drawn head-on as parallel horizontals between three uprights
    came back as A MUSICAL STAVE — and §6.3h already records four uprights on a
    ground line reading as a FENCE, which is the same failure with the strokes
    turned. An escalator is an INCLINE, so the run now tapers as it goes away
    from the reader, and the taper is what makes it rise.
  · A wristband drawn as a ring with a bar off its right side came back as A
    MAGNIFYING GLASS, and moving the bar to hang UNDER the ring made it a
    magnifier held the other way up — a circle with anything on a stalk is a
    lens whichever side the stalk is on. Drawn as a straight band with a block
    on one end it came back as a TORCH. It is an open CUFF with a gap in it
    now, which is the shape a wristband actually has and which nothing else in
    the file shares.
  · A carrier bag drawn as a rectangle with a handle arc over it and the
    boarding pass inside it came back as A WASHING MACHINE. A bag is WIDER AT
    THE BOTTOM, and the pass stands up out of the mouth of it.
  · A duty-free pickup counter drawn as a grey bar with a box above and a box
    below came back as A FRIDGE. What makes a counter a counter is a thick TOP
    with a front panel under it and things lying flat ON the top.
  · An airside shop drawn as an upright slab with a lit panel came back as A
    VENDING MACHINE. A shop is a FASCIA over a doorway.
  · A step with the marks on it, drawn full-height with the figure inside it,
    came back as A RULER with a head over it. The tread is a low band at the
    foot now and the figure STANDS ON IT.
  · A parcel with a tape CROSS on it came back as a WINDOW. It carries one
    horizontal band and a shipping label now.
  · A noodle packet drawn as a trapezoid came back as A BUCKET; it has the
    serrated seal along its top edge that makes a rectangle a food packet.
  · A cyclone drawn as one open spiral with a lead-out tail came back as A
    SNAIL — a single curl with a tail is a shell at any size. It is the pair of
    mirrored arms around a filled eye that everybody reads as a cyclone.
  · A lit cigarette drawn as a stub with two concentric arcs off its end came
    back as A WIFI MARK, which is what those arcs mean everywhere else in this
    corpus — `nfcTerminal` owns that idiom and it means "touch this". It is a
    tube with an ember and one wisp of smoke.
  · Shoes in a locker drawn as two small rounded stubs were two PILLS again;
    each one has a flat sole and a rising toe now.
  · A lift car 76 units tall drew its occupant's legs UPWARDS, because
    `ink.person` puts its hips at 80 — the figure came back as a spider. The
    car is 90 units tall.
  · 🚨 The whole downstairs lane of `the-order-you-do-it-in` was drawn wider
    than its own slot. A four-stop route gives each box 55 units, and a shower
    head at cx − 40 landed ON the one-way barrier in the gap: the barrier, the
    head and the water came back as one scribble. Everything in a four-stop
    lane is now inside ±26 of its own centre.
  · 🚨 And the flood was PAINTED OVER ITS OWN SUBJECT. Drawn on top, the water
    simply erased the stairwell, and the panel came back as a red brick with a
    thermometer beside it — which said "there is water" and never said "it is
    over the opening", the exact failure §6.3h records for a level rule drawn
    under a surface. The water is painted FIRST now and the stair is punched
    back into it in the tile's own ground, and the gauge stands IN the flood
    rather than beside it, red above the surface and punched below.

##### 🚨 6.3j THE RED PASS — 2026-08-10, over all thirty-one drawings

§2.4 is the rule; this is what it did to the drawings that were already landed.
Ted's decision came with the condition attached to it — *"I'd have a pass go
back over the earlier drawings for consistency"* — and a pass that reddened
everything urgent would have been worse than the flat set, so the count matters
as much as the choice: **five elements, in three drawings, out of thirty-one.**

🚨 **ONE more was added with the eighteen cards about EVERYDAY CONVENTIONS, and
it is NOT the typhoon.** That is the sharpest test the boundary has had: J-19 is
a card about weather that can kill, and the dossier itself says a typhoon in
Seoul "almost always means a day of very heavy rain and cancelled flights, not
danger" while naming precisely where the danger is — below street level. So the
cyclone is flat, the monsoon band is flat, and the flooded underpass is not.
Everything else in eighteen cards stays flat too: a ₩200,000 littering fine, a
₩100,000 smoking fine, a voided warranty, a cancelled duty-free order, an
escalator custom and a confiscated bottle are property, paperwork and
inconvenience. **Eight elements, in five drawings, out of forty-six.**

🚨 **Two were added with the connectivity family, and they
are the same object twice — a lithium cell in an aircraft cabin.** That is the
first danger drawing outside health, and it is the test the boundary was written
for: the card it lives on also holds a socket, a power brick and a hair dryer a
Korean supply destroys, and none of those three took the colour. **Seven
elements, in four drawings, out of thirty-five.**

**What gained red, and what the element actually is**

| Drawing | The element | Why it is a body and not a bill |
|---|---|---|
| `which-number-for-what` | the casualty on the trolley (119) | somebody is hurt or something is burning |
| `which-number-for-what` | the lamp over the police frontage (112) | a crime, or you feel unsafe |
| `pharmacy-clinic-or-er` | the trolley under the emergency bay | the one rung that can resuscitate you |
| `the-alert-you-cannot-silence` | the ground that moved | an earthquake of M6.0 or greater |
| `the-alert-you-cannot-silence` | the water risen over the gauge | typhoon, flood, landslide, radiation |
| `the-plug-and-the-cell` | the two power banks under the sealed bin | Korea wrote this cabin standard after a fire aboard an aircraft, and ICAO adopted it |
| `the-plug-and-the-cell` | the power bank whose cable a wall stops | the same cell; the ban on using one in flight exists for the same reason |
| `two-rains-one-summer` | the water standing over an underground opening | `J-19.md`: "basements, underground shopping arcades and low-lying road underpasses are where that becomes dangerous; the streets above them are merely wet" |

**What was deliberately LEFT FLAT, and each of these was argued rather than
skipped**

| Drawing | The pull towards red | Why it stays blue |
|---|---|---|
| `open-at-two-in-the-morning` | a shut pharmacy at 02:00 | inconvenience. The answer is which row has a wall to the left of you |
| `before-you-walk-away` | three accidents, four cards | the loss is money and paperwork; the harm has already happened |
| `who-moves-first` | overstay, a fine, an entry ban | your record and your plans. Not your person |
| `the-night-clock` | a LAST TRAIN | a deadline, and a deadline is a wall |
| `the-currency-prompt` | 7.1% on $1,000 | expense. §2.4's worked example |
| `climate-card-clock` | four real dates | dates. The wall idiom the rest of the corpus borrows |
| `two-filings-one-trip` | a waiver that stops on 1 Jan 2027 | a date |
| `top-up-by-phone-and-card` | a cell that STOPS | a failed top-up injures nobody |
| `the-plug-and-the-cell`'s socket panel | 220 volts, and a rule set by statute | a socket is not a threat, it is a fitting |
| `the-plug-and-the-cell`'s brick-and-dryer panel | a 120-volt hair dryer destroyed by a Korean supply, sometimes loudly | 🚨 **the load-bearing exclusion on that card.** It is expensive and it is property. The wall says it, flat |
| `four-ways-to-be-online`, `the-counter-that-is-open`, `what-your-number-unlocks` | a doubled roaming day, a SIM that will not activate, a refused app | a failed check injures nobody |
| `the-road-and-the-camera`, `what-you-may-carry-in`, `the-arrival-form`, everything in T2, T5, T8 | fines, refusals, forms | none of them is a body |

🚨 **The three drawings within `which-number-for-what` and
`pharmacy-clinic-or-er` that stayed blue are the point of the pass.** 1330 is a
signpost, 1345 is a passport with a calendar, and the interpreter bridge is
three speech blocks — all blue, beside two red panels, on one card. The
convenience store, the pharmacy, the clinic and the hospital block are blue
beside a red emergency bay on another. **If a reader cannot see the distinction
inside a single drawing, the rule is decoration.**

**How it is built so it cannot be violated by hand.** `kDangerFigures`
(`lib/core/imagery/card_diagram.dart`) names the seven painted elements;
`kDangerDiagrams` names the four drawings they belong to. `_FigurePainter`
resolves its subject colour as *"`danger` if this figure is in the set, else
`accent`"* — so **no painter body decides its own colour and none was edited**.
Every reddened shape is the identical geometry that passed screenshot review
flat, which is what makes §2.4's colour-blind clause true by construction rather
than by care.

🚨 **The two sets must agree, and a test says so in both directions**
(`test/widget/red_means_danger_test.dart`): every drawing in `kDangerDiagrams`
must actually use a danger figure, and no drawing outside it may use one. A
figure quietly added to `kDangerFigures` and used on a fare diagram fails there,
not in review.

#### 6.3b If a licensed photograph ever exists

Then it goes **through `PlacePhotosService.resolvePhotoForPlace` and nothing
else**, and the illustration remains the fallback rather than the reverse.
A place we *recommend* would then carry a photograph, enforced the way the content
bundle's fifteen gates are — gate 1 already fails any card with zero
`source_urls`, and a sixteenth would fail a recommended place with no resolvable
photo.

Two halves, and they are different components:

| | Ours — recommended | Theirs — user-added |
|---|---|---|
| Photo | **Always.** Gate-enforced | Often none, and that is normal |
| Source | dani.go's `PlacePhotosService.resolvePhotoForPlace` | Whatever the user gave us |
| Treatment | Photo-led card, hero on detail | Typographic card: name, district, facts. Reads as **personal**, not as a failed photo |

🚨 **Never route a photo any way but through
`PlacePhotosService.resolvePhotoForPlace`.** dani.go's
`Blueprint/PLACE_PHOTO_RENDER_RULE.md` is a load-bearing invariant with a lint
behind it, and reaching past the resolver into `photo_urls` is the exact bug
that has bitten dani.go repeatedly. `Icons.photo` / `Icons.broken_image` /
`Icons.image` remain banned.

#### 🚨 6.3i HOW TO PHOTOGRAPH A CARD'S ANSWER. Added 2026-08-09.

The content gate landed on 2026-08-08 — browsing is open, the ANSWER is replaced
by `SignInInvite` until there is a session — and it took the camera with it. An
agent that same day: *"Card prose could not be shot — the sign-in gate needs a
real Supabase session and the dev-seed route does not sign in."* Everything
§6.3d–h is about lives **behind** that gate: the prose, the diagrams, the phrase
tables, the citations, the answer page's order (§7c.1). None of it could be
looked at, and looking at it is what caught every defect in that list.

**The bypass is a `--dart-define`, and it is `lib/core/config/dev_capture.dart`.**
It flips one boolean — the one `isSignedInProvider` reads — and mints no session,
no token and no user, so `canCreateTripsProvider` stays false and nothing writes
a row under an owner who does not exist.

🚨 **Two compile-time locks, and the second is the one that matters.**
`bool.fromEnvironment` means there is nothing to type at runtime, no gesture and
no route. And the resolved constant is `define && !kReleaseMode`, so **a release
build is signed out whatever the command line says.** That was verified in the
artifact, not only in a test: a `--release` build carrying the define was served
and shot, and it renders the invitation. `test/config/dev_capture_test.dart`
holds it — it renders the card at the release resolution *with the define on* and
finds `SignInInvite`, because a test that merely asserts a flag is false is green
precisely because the test command did not set it.

**So the capture build is a PROFILE build.** Nothing anybody installs is built
that way.

##### The recipe, verbatim

```bash
WT=/path/to/your/worktree

# 1 · A STATIC PROFILE BUILD. Not `flutter run -d web-server` — see the note in
#     scripts/eyes/README.md: debug never boots the app under CDP.
cd "$WT" && flutter build web --profile \
  --dart-define=DANIKO_DEV_CAPTURE_SIGN_IN=true

# 2 · Serve it over plain HTTP.
cd "$WT/build/web" && python3 -m http.server 8092 --bind 127.0.0.1 &

# 3 · 🚨 KILL CHROME BEFORE DELETING ITS PROFILE. In this order, always.
pkill -f claude-cdp-profile-headed; sleep 1
rm -rf /tmp/claude-cdp-profile-headed
ls -d /tmp/claude-cdp-profile-headed   # must say "No such file or directory"

# 4 · Shoot. `--headed` is not optional — §8.1b: headless stalls the ticker and
#     never fetches a photograph, so the hero comes back empty.
cd "$WT" && python3 scripts/eyes/shot.py \
  "http://127.0.0.1:8092/#/handbook/a/a-01-can-i-tap-my-visa-at-the-subway-gate" \
  out.png --width 460 --height 3600 --settle 25 --headed
```

`--height 3600` is what puts the whole answer in one frame: Flutter scrolls
inside its canvas, so a 900px viewport shoots one screen however you scroll. A
full A-01 answer — hero, question, short answer, prose, the tap-twice diagram,
key points, video band, phrase table, sources, related — measures about 3,600px
at `--width 460`.

##### Three traps, each of which has cost a session

- 🚨 **Step 3's order is the whole of step 3.** `shot.py` uses a PERSISTENT
  Chrome profile and the content bundle lives in that profile's IndexedDB. `rm
  -rf` fails *quietly* with "Directory not empty" while Chrome is alive, and the
  next shot is of a STALE bundle that looks entirely plausible. Three agents have
  been fooled by it. Always `pkill` first and always `ls` afterwards.
- 🚨 **Rebuilding onto the same port needs step 3 again.** Same origin, same
  IndexedDB — a new build served at `127.0.0.1:8092` inherits the old bundle
  unless the profile is cleared between shots.
- 🚨 **`flutter run -d web-server` in debug never boots under CDP** — 1,786 DDC
  modules load and no `flutter-view` is ever created. It must be a static
  release/profile build over plain HTTP.

##### What this does NOT reach

A card's answer needs nothing but the bundle, so every card, diagram, phrase
table and citation is reachable by URL alone. **Anything needing a trip, a saved
item or a real `owner_id` is not** — the bypass deliberately produces no user.
That content comes from `/dev/seed?go=1` (see `scripts/eyes/README.md`), which is
`kDebugMode`-gated and therefore absent from a profile build. The two tools do
not compose: a card answer is a profile build with the define; a seeded trip is a
debug run. Nothing yet needs both at once.

#### 🚨 6.3k WHAT THE FIRST LOOK THROUGH THAT CAMERA CAUGHT. Added 2026-08-10.

§6.3i landed the camera; this is the first pass of ~30 captures taken with it,
and the three defects it found were all in the ARTICLE renderer, all invisible
to a green test suite, and all on every card that had the shape.

1. **A paragraph is a run of lines, not a line.** The corpus is hard-wrapped
   markdown and `_render` emitted one `Text.rich` per SOURCE line, with a full
   paragraph gap under each. b-14's fare paragraph read as seven fragments —
   *"The fare"* · *"rose on 28 June 2025, from ₩1,400 to"* · *"₩1,550, so
   anything…"*. **20 of 280 articles are hard-wrapped, which was 938 spurious
   paragraph breaks.** Plain lines now accumulate and are joined with a single
   space; a blank line, a heading, a bullet, a numbered item or a table flushes
   the run.
2. **Raw markup was on screen.** `**₩1,550**` in a table cell, and `[rose on
   **28 June 2025**](…)` in a link label. The inline pass cannot run inside a
   monospace table without breaking the column alignment the table exists
   inside, so the markers are STRIPPED in both places — the same thing the
   heading branch already did.
3. 🚨 **`bodySmall` is the MONO role, and it had reached a sentence again.**
   `PhraseTableRow`'s English gloss was set in it, so an English sentence came
   out as a serial number, directly above the Korean and in the same face as
   the romanisation under it — which is the one line there that is *meant* to
   look machine-set. `screens-3-handbook.html` sets that gloss in Manrope 12.5 /
   `text2`, and the mock wins. It is `bodyMedium` now.

**What was NOT changed, and is Ted's call.** A markdown table still renders as
monospace pipe rows in a horizontal scroller — deliberate (a five-column table
does not fit a phone) but it still reads as source.

### 🚨 6.3j `titleSmall` is not a role this theme has, and is now unnamable

**Closed 2026-08-10.** The six call sites listed here as outstanding —
`clip_sheet`, `trip_home_screen`, `day_differences_band`, `interests_screen`
and `document_confirmation_sheet` ×2 — all take `DaniKoTypeRamp.bodyStrong`
now, and `test/lint/absent_type_role_test.dart` fails the build on the seventh.

The Taegeuk `TextTheme` fills `displayLarge · displayMedium · displaySmall ·
titleLarge · titleMedium · bodyLarge · bodyMedium · bodySmall · labelLarge ·
labelMedium · labelSmall` — **and no `titleSmall`.** Flutter does not warn about
a role the theme leaves null: it falls back to Material's own `Typography`, so
`text.titleSmall` returns a perfectly valid Roboto 14/500 and renders it beside
Manrope everywhere else on the same card. Nothing throws. It simply looks like a
different app for one line, which is why it was fixed three times
(`research_blocks`, `video_blocks`, `card_detail_screen`) and came back six more
at once.

- **The lint reads `lib/**`, not a rendered screen.** All six sat behind a
  signed-in trip, an onboarding flow and a document scan; no capture build
  reaches them. Reading the source covers the screen written next week.
- **The name is the trap, not the intent.** Everyone who reached for it wanted
  "a heading a bit smaller than `titleMedium`", which is a real need the ramp
  answers with `bodyStrong` (Manrope 14/600). So the fix is to make the name
  unavailable, not to warn about it.
- **Prose naming it is allowed; code is not.** Three comments in `lib/` are the
  record of why not to use it, and the walk strips `//` before matching.

🚨 **The same defect one step down: `bodySmall` IS the mono role**, and a
video's `why` reached a card in it for the SECOND time — `video_blocks.dart`
fixed one renderer on 2026-08-09 and `research_blocks.dart` has another for the
same field. Fixed. The gallery captions (`image.shows`) stay in mono on purpose:
a caption under a photograph is the deliberate idiom here, and the credit line
below it is `data` at 9.5.

---

## 7. Information architecture — the four rules the redesign exists to fix

These are the substance of the 2026-08-05 brief. Styling without them changes
nothing.

> **The navigation these rules sit inside is `docs/design/taegeuk/FLOW.md`**, drawn
> in `flow.html`. Four tabs with labels — **Trips · Handbook · Search · You** —
> and three structural decisions that the rules below depend on:
>
> - **Trips and the trip are one tab.** A lobby in front of the thing the product
>   exists for makes it one peer of four. One trip opens directly.
> - **Resources, ideas and seasons are Handbook shelves**, not top-level doors.
>   Four front doors means none of them is the front door.
> - **`You` holds a person's own things** — the document vault, the carry cards,
>   what is on the phone, the account. 🚨 The vault is fully built and had no entry
>   point anywhere in the app; nobody could reach their own passport scan.

### 7.1 A topic opens with the answer — and where there is no answer, with the questions

**Ted, 2026-08-06:** *“It just lists K-ETA questions without even describing what
ETA is. This is nonsense.”*

**⚠️ Ted, 2026-08-09**, holding a screenshot of `Entry & Identity` — a subject
that holds sixteen answers: *“This screen should directly list the questions —
The short version / Worth having on your phone ARE BOTH UNNECESSARY!”*

Both are right, and the second is what ships, because the intervening three days
established that step 2 **has nothing to render and structurally cannot get
anything to render**: there is no `section_summary` column, model field or build
gate anywhere in the bundle, and no resource in this app carries a subject. A
band that can only ever draw its own absence is not honesty about coverage, it is
furniture — and a reader met two of them, plus a `Show all 16 questions` button,
before reaching the first of sixteen answers.

Every section therefore renders, top to bottom:

1. **Hero photo** with the topic's title.
2. **“What you have”** and **“Your checklist for this”** — the visitor's own rows
   and their own checklist rows scoped to this subject. Not links to them. The
   rows. Both COLLAPSE when there are none; §2.2's flip puts them above the
   questions when there are.
3. **The questions**, listed directly, every one, each its own tappable row
   (SPEC-01 S-11's row shape). **No door, no “Show all N”, nothing in front of
   the list.**

🚨 **PRD §31 is not weakened by that deletion, and the two statements it requires
are still made — at the fact, not above the content:**

- a subject with **no questions at all** says so, in one sentence, where the list
  would be;
- a subject whose questions are **not written yet** says how many, once, at the
  **foot** of the list it is about, counted off `is_stub`.

🚨 **When `section_summary` is finally sourced**, step 2 comes back as
`DaniKoSummaryBlock(summary:…, sources:…)` — which throws in debug on an empty
`sources` list, PRD §31.11 as code — rendered **only where a summary actually
exists**. It does not come back as a band that renders its own absence.

### 7.2 Readiness is always visible

A single numeral and a bar: **`7 of 12 ready`**. On Trip Home and at the top of
the checklist. It answers the only question a pre-trip user actually has, and it
is the one idea kept from the rejected proposal C.

### 7.3 Lists are grouped and ranked, never dumped

**Ted:** *“We should not expect users to read all that.”*

Checklists group by **when**, in fixed order:
`Urgent` → `Before you fly` → `Once you land` → `Done` (collapsed).
Documents group by **shelf**. Handbook cards group as §7.1 describes.

Every group header carries its count. A group with zero items is not rendered.
The `Done` group is collapsed by default and states its count.

🚨 **The zero rule binds a TILE the same way it binds a header, and the user's
own content is listed by name rather than counted.** The Trip root drew fifteen
category tiles whether or not a category held anything, so one saved thing came
wrapped in fourteen greyed zeroes and was two taps away. Ted: *“If user saved
something, the user wants it HANDY. WHY DOES HE HAVE TO BROWSE THROUGH
UNNECESSARY INEFFICIENT UI? SHOW THE LIST OF QUESTIONS.”* So:

* **A category that holds nothing gets no pixels** — no tile, no row, no greyed
  placeholder — at *every* total, not only at zero. “A wall of zeroes is not a
  starting point, it is a chore list” is a rule about zeroes, not about totals.
* **A surface over the user's own things lists the things**, by title, one tap
  from the thing itself. A count is what you show *about* a list, never
  *instead* of one.
* **Grouping may not cost a tap.** A group header sits above rows that are
  already on screen. A header that hides its contents is a menu wearing a
  heading's clothes; it may additionally open a filtered view, but only as an
  extra.
* **Both ends scale honestly.** One item looks deliberate, not like a broken
  grid; a long list truncates to a sensible count with the rest one tap behind
  a `Show all N` — and the first screen still shows saved things, never a menu.

The Trip root's `_SavedList` and the Saved root's single collapsed empty state
are the two reference implementations.

#### 🚨 7.3a The shelf page's SUBJECT runs are an explicit exception, 2026-08-12

Ted overruled two of the rules above, for the handbook's question list only.
They still bind everywhere else, and in particular they still bind every surface
over **the user's own things** — which is what §7.3 was written about, and which
is why this is an exception rather than a contradiction.

**A subject heading carries NO count.** *"it is worse — what is the difference
between the 2 and the 'all 9'???"* The heading had a count of the questions on
this shelf and a subtitle with the subject's total across Korea; both were true
and telling them apart required knowing our taxonomy. One number that must be
contrasted with a second number is a lesson about how the app is organised,
which §7b forbids in copy and which is no more welcome as a numeral. The page
header still counts (distinct questions drawn).

**A subject heading DOES hide its contents, and they start hidden.** *"by
default all should be folded!"* This is the rule §7.3 states as *"grouping may
not cost a tap"*, and what changed underneath it is that a group is no longer a
slice — see 7.3b. Five headings on one screen is a contents spread over a shelf;
the alternative at the same fidelity is ~65 rows and 14,000px, which is the
*"pages are way too long"* complaint in a different costume.

🚨 **This is NOT the collapsed door §7.1 deleted.** That door was one button
hiding a whole list behind an answer that did not exist, and it named nothing.
These name every subject the shelf touches, so the reader sees what is here
before choosing what to open — the thing the door never allowed.

#### 🚨 7.3b A SUBJECT run holds the whole subject, not the shelf's slice

*"all 9 seem relevant and should be included in customs, shipping."* A run under
*Customs, shipping and what you cannot carry* on `Entry & Identity` used to hold
the 2 questions that shelf files. It now holds all 9, refilled from
`topicCardsProvider` — **the same provider the subject's own page reads**, so the
two surfaces cannot disagree about what is under a heading.

🚨 **The cost, stated rather than discovered later: a shelf stops being a
boundary.** `Entry & Identity` draws all 21 of *Safety, emergencies and getting
help* on the strength of the 2 it files. What distinguishes one shelf from
another is now **which subjects appear**, not which questions.

#### 🚨 7.3c The SECTION titles are never rendered

The twelve `CardArt.sectionTitles` (`Entry & Documents`, `Getting There &
Around`, …) are the handbook's **authoring** taxonomy — they name the letter in a
card id and are 1:1 with a shelf for ten of the twelve. They were rendering as
the topic page's run headings, so a reader who opened a subject from the shelf
`Entry & Identity` met a heading reading `Entry & Documents` over the same
sixteen cards and reported the questions as missing. Ted: *"This is chaotic and
is a total mess."*

The user-facing answer to *"what subject is this card under"* is
`topicTitleForSegment(letter)` — by construction the title of the page
`/handbook/<letter>` actually draws — and the heading **opens that page**. A
heading a reader cannot navigate to is a name they cannot check.

`test/lint/section_title_render_rule_test.dart` fails the build on any render of
`CardArt.titleFor`, and asserts the two vocabularies really do disagree (ten of
twelve letters) so the rule cannot decay into folklore.

#### 🚨 7.3d The first paint is the final layout

A band whose data arrives from a future may not render a DIFFERENT SHAPE while it
waits. The shelf page fell through to its flat 16-question list until the runs
resolved, then collapsed into five headings in front of the reader — *"the icon
row is rendered After displayed! it should prerender!"* Two causes, both general:

* the synchronous half of the data (which subjects, in what order) is computed
  and painted immediately; only what is hidden under a fold waits;
* 🚨 `AsyncValue.valueOrNull` reads **null for "still loading" and for "genuinely
  absent"** — opposite correct answers. Reading the `AsyncValue` is the only way
  to tell them apart, and the flat list is correct only for the second.

A **count** is the one thing that legitimately waits: `Questions 16` corrected to
`Questions 56` a frame later is a wrong number shown confidently.

Fonts are the other half — `web/index.html` preloads the icon and text fonts,
because Flutter web cannot even begin fetching them until it has booted and read
`FontManifest.json`. `test/lint/font_preload_rule_test.dart` guards the paths and
the required `crossorigin`, both of which fail silently when wrong.

### 7.4 🚨 The trip is the container, and it holds the numbers

**Ted:** *“Where does the user collect information, checklist, places,
documents, numbers/IDs, etc? User's trip should contain all that!!!”*

Trip Home renders, in this order:

1. **Hero photo** — trip name, dates, cities, days-to-go.
2. **Readiness** — §7.2.
3. **Do this next** — at most two urgent tasks, red-bordered, with the action.
4. **Your documents** — 🚨 **with the numbers on the face of the row.**
   `M•••••4821 · exp 2031-06-14`, `PNR 4KX9TL`, `CONF 88213904`, `POL 77-4412-A`.
   Masked where sensitive, DM Mono always, copyable.
5. **Your places** — masonry photo grid.
6. **Shelves** — the ten, as counted entry points.
7. Suggestions (F-35, still below the user's own content, still capped at 3).

🚨 **The documents vault currently has NO entry point in the app.** S-27 is
built — AES-256-GCM, biometric gate, OCR, the lot — and the only code that
navigates to it is `share_intake_listener.dart`. A user cannot reach their own
passport scan. Sections 4 above and the Documents screen fix that, and it is the
single highest-value item in this redesign.

---

## 7b. 🚨 THE VOICE RULE — added 2026-08-07, and it is the most load-bearing rule here

> **No user-facing string may describe how the app is organised.**

Ted installed the build and could not use the product he designed:

> *"the ui bad — i know the features and i don't know how to use it."*

The cause was not layout. It was that dani.ko's screens were shipping their own
design rationale as body copy. Every one of these was live, and every one of them
is **true**:

| Screen | Shipped string |
|---|---|
| Korea root | `15 SHELVES` |
| Korea root | *"You will never read the words handbook or binder in here."* |
| Korea root | `ORDERED FOR PREPARING` — *"a readout, never a control"* |
| Korea root | *"The numbers are the shelf's identity and never change."* |
| Korea root | **Not a shelf** — *"…it is a lens over all fifteen, and a lens has no row."* |
| You root | *"No search field here — Trip and Korea get the field, everywhere else gets the glyph."* |
| You root | *"Referenced by every trip, copied into none."* |
| You root | *"…a document is a per-document, per-recipient act with its own consent."* |
| You root | *"Machine output never renders in handover typography."* |

Being true is exactly what made them hard to see. They are accurate sentences
about the app's internal model, addressed to the person who built it, printed on
the screens a visitor to Korea meets. A visitor has no use for the word *shelf*,
has not asked what determines the order, and cannot act on the news that a lens
has no row.

**The test:** if a sentence would be at home in `FLOW.md` or this file, it belongs
in `FLOW.md` or this file. A user-facing string says what the reader will find or
what they can do.

🚨 **Enforced, not merely stated.** `korea_root_test.dart` fails the build on the
words *shelf · shelves · handbook · binder · lens · identity* appearing anywhere
on the Korea root, and `every_screen_builds_test.dart` fails on `⊕` or *"Hold it
for Emergency"* returning to the welcome.

### 🚨 Enforced across EVERY screen since 2026-08-07, not just the Korea root

The widget test on the Korea root worked and was not enough. A sweep of the
other screens found our vocabulary still live in **twelve files**, including the
places it does most damage:

| Screen | Shipped string |
|---|---|
| **Welcome** — the first screen a user ever sees | `Fifteen shelves — entry, money, moving, eating…`, three lines under a comment promising our words stay ours |
| **Search** | every result the user had saved was filed under `In your binder` |
| **Privacy** | *"the handbook, your binder, your checklist…"* — on the screen that most needs to be readable |
| **Capture sheet** | *"we never ask which shelf it belongs on"* |
| **Clip sheet, screen reader** | `Shelf 4, Money, selected` — our word AND our index, spoken to the one user who cannot see that neither is on the chip |
| **The exported PDF** | `What you kept from the handbook`, on the artefact that travels furthest |

`test/lint/voice_rule_test.dart` now reads the SOURCE rather than a rendered
screen, so a screen that does not exist yet is covered on the day it is written.
It flags **shelf · shelves · handbook · binder** inside any prose literal — a
literal with a space in it — after stripping comments and `${…}` interpolations,
because `'Added to ${target.shelf.title}'` renders as `Added to Money`.

🚨 `lens` and `identity` are on the list above and are deliberately NOT in the
lint: `Entry & Identity` is a topic title that renders by design, and `lens` is
ordinary English. `korea_root_test` catches both, case-sensitively, on the one
screen where they were a problem. A lint that cried wolf on a real title would
be switched off within a week — which is also why there is a one-line
`// voice-rule: ok — reason` escape hatch, used exactly once, for the SHOP shelf
a price tag is stuck to.

### 🚨 7b.0 The other half of the voice rule: our MARKERS are not their prose

Added 2026-08-10. §7b is about our nouns. This is about our punctuation, and it
had leaked further: **🚨 — the siren this document, CLAUDE.md and half the
source comments put in front of a warning to an ENGINEER — reached the reader in
99 places**, in 46 `article` bodies and five third-party video titles.

That was the **fifth** shape of internal-authoring leakage found in one week,
after a `## Open questions` heading rendered as a section, an inline
`**Open question for a curator:**` memo, a duplicated `## Cannot verify`
section, and an editorial instruction in a video's `why`. The sweep that removed
it found a **sixth**: `"this pass"` — a research thread narrating its own run —
in eight shipped bodies.

`test/lint/internal_marker_rule_test.dart` reads **both shipped bundles** and
fails on 🚨, ⚠️, `TODO`/`FIXME`/`XXX`/`HACK`, `"this pass"`, `"the researcher"`,
`"for a curator"` and `"we should"`.

- 🚨 **It reads the BUNDLES where `voice_rule` reads the source, and the reason
  is the opposite one.** A screen that does not exist yet needs a source lint; a
  dossier field that is deliberately internal needs a bundle lint. 124 sirens
  still live in `open_questions`, `brief`, `sources[].notes` and the superseded
  `answer_md`, correctly, and a source lint would either flag them or carry an
  exemption list that goes stale the day the builder changes what it emits.
- 🚨 **The four shouted markers match CASE-SENSITIVELY.** Matched loosely they
  flag `#travelhacks`, `[Seoul Hacks] Ep.6`, `thingsToDo` in a visitkorea URL
  and `a **6xxx** is working the south-west`. All five are fixtures in the test.
- **Honesty about coverage is not a marker.** "cannot verify", "open question",
  "not confirmed" stay — PRD §31.12 wants a gap surfaced. The difference is the
  audience, not the doubt.
- **FIX THE SOURCE, then rebuild.** Editing a bundle json by hand passes this
  test and is overwritten by the next build; and a phrase correction lands in
  BOTH `<ID>.json` and `<ID>.md`.

### The sibling rule: never claim more than the content can keep

Two strings shipped in the same session claiming answers we do not have —
`16 questions answered` on a topic row, and *"…answered once"* in the page
header — while **277 of the 280 cards are stubs**. Both were caught by looking at
a screenshot, not by a test.

A count of what we HOLD is ours to state. A claim that we ANSWERED it is the
unsourced assertion PRD §31.11 exists to forbid, and it renders identically to a
true one. The rows say `16 questions`.

### 🚨 7b.1 The article renderer parses FOUR things. Everything else prints as SOURCE.

Added 2026-08-09, from Ted's screenshot of A-01, where the reader was shown
`see [A-04](A-04.md) and [A-15](A-15.md) for how often that's *not* the case`.

`_spansFor` in `lib/design_system/blocks/research_blocks.dart` understands
exactly **`**bold**`**, **`` `code` ``** and **`[text](https://…)`**, and the
line loop above it understands `#` headings, `-`/`*` bullets, `1.` numbered
lines and `|` tables. **Nothing else is markdown here.** Anything else a writer
types is not styled — it is printed, character for character, to a traveller:

| Written in a dossier | What the reader saw |
|---|---|
| `*not*` | `*not*` — 332 of them, across 134 cards |
| `> 제3조 …` | `> 제3조 …`, the marker included — 78 lines, 22 cards |
| `[A-04](A-04.md)` | the whole thing, because the link arm requires `http` |

All three are gone from the corpus: emphasis is `**bold**`, blockquote markers
are stripped, and a cross-reference to another card is now the target card's own
QUESTION in quotes — which is what a reader can act on, since `A-04` names
nothing to anyone outside this repo.

**The rule for anyone writing content: those four inline forms, or plain
prose.** If a surface genuinely needs italic or a set-off quote, that is a
RENDERER change and it belongs in `research_blocks.dart` — do not smuggle it in
as source and hope.

🚨 **And the corollary, settled 2026-08-09: a card id is never a reference.**
The link fix above caught the 47 cross-references that PRINTED as source. It
did not touch the 878 that rendered perfectly — `see A-06 for the mechanics`,
`(A-09, A-12)`, `` - `B-02` — getting from Incheon into Seoul`` — across 153
cards, because rendering correctly was never the defect. `A-06` is our
identifier. It is on no screen, in no URL a reader sees, and in nothing they
could look up, so it hands a traveller a reference and no way to follow it —
the same complaint Ted filed against the search screen that day, one layer
down. All 878 are now the target card's own QUESTION in curly quotes, all 217
distinct targets were checked against the shipped card list, and
`test/content/no_bare_card_ids_test.dart` holds it at zero over BOTH tiers —
which it must, because `bundle_core.json` carries no `article` block at all and
a test reading only the core tier would pass over the whole body of the corpus.

🚨 **AND SINCE 2026-08-26 THE QUOTATION IS A LINK.** The paragraph that stood
here said the renderer *"cannot"* do it — the link arm required `http`, a card
lives on a route rather than a URL, and `[question](/card/a/a-06)` would print
as source — and closed with *"until the renderer learns an internal target"*.
It has now learnt one, because the gap it described was the whole defect: a
reader met *"see “Can I top up a Tmoney card at a subway station?”"* mid-sentence
and the only way to go there was the Related list at the foot of 2,000 words.

- **The form is `[“…”](card:A-04)`.** `card:` is the one non-`http` scheme the
  inline tokeniser accepts (`article_markdown.dart`, `cardLinkScheme`), and
  `openInlineLink` routes it to `BlockRenderContext.onOpenCard` — the plain-id
  callback the router already had — so it **never reaches the platform's URL
  handler**. A `card:` link handed to `launchUrl` would die silently, which is
  worse than the dead text it replaces.
- **The LABEL does not change.** Still the question, still in curly quotes. The
  id lives only in the target, which is why the rule above still holds: no
  string a reader READS names a card by its id.
- **`tool/build_content_bundle.py` wraps them at build time**
  (`linkify_cross_references`), matching a quotation against the shipped
  question list — so a rewritten sentence keeps its link and a renamed question
  loses it loudly rather than pointing somewhere wrong. **2,025 links on all
  286 cards**, in `article` and `key_points` only: those two go through
  `tokenizeInline`, and a reference row's `why` is a plain `Text` that would
  print its own brackets, so the 6 quotations sitting in a `why` stay prose.
- The label is **collapsed to one line** when it is wrapped. `parseArticle`
  joins a paragraph before tokenising, but a bullet and a table cell are
  tokenised line by line, so a label still carrying the source's newline would
  parse in a paragraph and print its brackets in a bullet.

🚨 And the same trap one layer down: `_ReferenceRow` prints `ref.format`
**uppercased**, and says `… DOWNLOAD` only for `pdf`, `hwp`, `xlsx`, `doc`. So a
`format` of `html (국가법령정보센터 statute viewer — full 본문…)` reaches the reader as
that whole sentence in capitals. Keep `format` to the bare token; say what the
file is in the TITLE.

---

## 7c. Magazine layout — the Korea and You roots, 2026-08-07

Ted, after the first rebuild removed the imagery to shorten the page:

> *"the pages are ugly — no colors, no photos, no logos, no videos … it looks
> like a hospital chart."* … *"it looks like a list of things — no magazine looks
> like that."*

Four things carry the register. None is decoration for its own sake:

1. **A full-bleed cover.** Edge to edge, no card, no margin; masthead and title
   over the photograph. A page that opens on a bordered box reads as a form.
2. **Numbered section openers.** `DaniKoSectionOpener` — a two-digit mono numeral
   in blue, a hairline to the margin, then a large title. 🚨 Numbering is only
   honest when the order is FIXED, which is why the Korea root's six groups no
   longer phase-reorder.
3. **Two type scales, far apart.** 44px cover / 26–38px section title against the
   10.5px letterspaced label. A page where everything sits between 13 and 19px
   reads as a table — which is what it was.
4. **Every ITEM carries a picture, not just every section.** This is the one that
   separates a magazine from a list. A photograph bolted to the top of a row list
   is still a row list; the fifteen topics are now photo tiles and the
   composition varies by section — 1 topic is a full-measure feature, 3 is a
   lead-plus-pair, 4 is a grid.

🚨 **Photography goes through `DaniKoHero.fromArt` and nothing else.** It pairs
the image with its credit by construction, so fifteen tiles are not fifteen
chances to forget the attribution 공공누리 제1유형 requires.

### 🚨 You gets photographs — on the rows that hold something. Rewritten 2026-08-08, with the rebuild

This section used to read *"You gets no photographs, deliberately — it is your
passport, your people, your documents, and Korean tourism photography over that
is decoration pretending to be content."* Ted overruled it on 2026-08-07, asked
directly whether You keeps photographs: **"Korean photography, like everywhere
else."** The rule was left struck-and-annotated rather than rewritten until the
screen actually changed, because a rule describing a screen nobody has built is
the trap the note existed to avoid. `you_root_screen.dart` is that rebuild, and
this is the replacement rule.

**What the old rule got right, and it survives:** a licensed photograph of Korea
laid over an ABSENCE is decoration. **What it got wrong:** it read that as an
argument for a typographic page, and the typographic page was nine numbered
sections of small grey outlined rows — People `NOT YET`, all three library rows
dashed, How-you-travel dashed. A screen that is mostly absence, drawn as
identical rectangles, so the empty states looked the same as each other **and the
same as the full ones**. It read as a form nobody had filled in.

🚨 **So the picture KIND carries the state, and that is the rule:**

| | |
|---|---|
| **A row that opens something real** | a **photograph**, through `DaniKoHero.fromArt` — Carry, Documents, Offline (with a trip), Sources, Settings, Privacy |
| **A row whose layer does not exist yet** | a **drawn scene** behind a dashed edge, never a photograph — People, the three library rows, How-you-travel, Offline with no trip |

That is §6.3's own photograph/drawn-scene split reused one level up, and it buys
the thing the rectangles could never buy: **an empty row is a different OBJECT
from a full one, recognisable before a word is read.** `you_art.dart` is the one
place that decides, and `you_root_test.dart` asserts the RELATIONSHIP — a
not-yet tile is `HeroDrawnScene`, always — rather than counting dashes, which
would have been green through the entire life of the defect.

Three supporting calls, each because the alternative was worse:

- **The cover is section 1's picture.** The person card underneath it is
  typographic — it holds an email and a stated gap about name spelling, and a
  photograph behind *that* is the decoration the old rule named.
- **Carry keeps its rows.** `CarryRow` is the one widget here whose TYPOGRAPHY
  carries a product rule: machine output renders italic, lighter and dashed and a
  reviewed string does not, so the difference survives being handed to a
  pharmacist who never reads a badge (PRD §8.3). That cannot be drawn on a
  photograph. The section leads with a picture and keeps the rows under it.
- **The library's three empty tiles are three DIFFERENT drawn scenes.** A run of
  identical pictures is the same rendering-bug read the run of identical
  rectangles already had.

🚨 **And the text-recognition state stopped being a tile.** It is a fact about the
platform with nowhere to tap, and a picture-led tile that swallows no tap teaches
the reader the screen is broken. It is a line of mono under the pack tile.

### 🚨 The pass MISSED `/trips`, and it was created the same day

Ted, 2026-08-07: *"why do the trips and You screens still look so bad?"* On
`/trips` that was not a judgement call. This section's pass ran over the Korea
root and the You root; `/trips` was a brand-new screen landed the same day and
was never in it. A `grep` for any imagery widget in `trips_root_screen.dart`
returned **zero** — two grey outlined rows, a blue button, and about 1,400px of
white underneath.

🚨 **And the picture already existed.** `TripHeroImage.art(trip)` had resolved
photograph-or-drawn-scene for any trip since 2026-08-06 and the trip page's own
hero used it. The screen simply never called it. **A new screen is not covered
by a design pass that ran before it existed** — the pass is a list of screens,
not a property of the codebase.

Closed 2026-08-07: a full-bleed cover, and every trip is now a `DaniKoHero`
inset in a card rather than a row with a chevron. 🚨 **A trip card IS a hero**
rather than a second photo card — which discharges the 공공누리 attribution by
construction, pairs a gap with its drawn scene, and starts the veil at 18% on an
inset hero, so there is no second place to get any of those wrong.

🚨 **And the app was showing ONE photograph per city.** The bundle carries 820
of them — 25 of Seoul alone — and `CityPhotoRegistry.heroFor` returns
`photos.first`, so every Seoul trip, the trips cover and every trip card
resolved to the same picture and 24 rows sat unused. `photoFor(seed:)` picks
per-trip, seeded on the trip's own id through a **stable** hash: `String.hashCode`
is not guaranteed stable across launches, and a trip that changed its photograph
every launch would be worse than one that never varies, because people recognise
their trip by its picture.

### 🚨 7c.1 The answer page — the ORDER is the argument, 2026-08-08

The 280-dossier corpus made the card detail the longest reading surface in the
product: a ≤15-word answer, a 68–139-word paragraph, 900–3,100 words of body, up
to 32 evidence rows, 57 phrases, 41 references and 20 photographs. Rendered in
the order the compiler emits them — which is what the generic `CardBlockList`
does — the reader meets **what we could not establish** somewhere past the fold,
after seventeen source links. Every card has at least one and it is written *for
the reader*, so the page sorts the blocks rather than streaming them:

> hero · question · the `short_answer` paragraph · key points · the rest of the
> photographs · the body · what we checked · Korean · **what we could not
> establish** · sources / videos / documents · related · source footer

> 🚨 **THE ANSWER IS ONE BLOCK, 2026-08-10.** This line used to read *"the
> ≤15-word answer · the `short_answer` paragraph"* and the page drew both, in
> the same `blueSoft` container, one under the other, on all 280 cards.
> `answer_box` held sentence one of `short_answer` and a `summary` block held
> sentences 2..N. Ted, holding L-01: *"there are three groups of text — first
> two surrounded by box … the first summary box still looks like one. you have
> to assume most users will read the summary only. it should be longer than
> just 1 sentence"*, and of the second box: *"very confusing. Definitely not
> for users — i don't know who is it for."*
>
> Two measurements say why the split could not survive a reader: `answer_box`
> ran a median of **23 words** against the second box's **94**, and **46 of
> 280** second boxes opened on a bare pronoun or connective whose antecedent
> sat in the first box — which is what made a coherent paragraph read as a
> briefing note arguing with itself.
>
> 🚨 **The mock settled it and always had.**
> `design/taegeuk/screens-3-handbook.html` caption 3, the one answered card
> detail it draws, puts ONE lead answer under the question. So the `summary`
> BLOCK TYPE is deleted — compiler, `blocks.dart`, every renderer and the PDF
> — the same disposal `evidence` and `cannot_verify` got, and `answer_box`
> carries the whole paragraph in the paragraph voice (`body` at 1.55, with the
> hangul fallback the deleted block already had and this one did not).
>
> **Two deviations from that frame are deliberate and open:** the mock's lead
> is **unboxed** and carries `<b>` on its load-bearing phrases. The box is kept
> because §7.1 reserves the tint for *"here is the answer"* and un-boxing would
> also change the three authored cards and the `warn` variant; the bold is an
> authoring pass, since no `short_answer` in the corpus carries markdown. Both
> are logged in `TODO.md` for Ted.
>
> **`key_points` IS the answer box.** The tinted container at the top of a
> research card holds the bullets, not the `short_answer` paragraph — a card's
> answer is nearly always a SET (conditions that must all hold, places with
> their floors and gates, steps in an order), and prose has to nominalise a set
> where a list does not. The tint is what says *"here is the answer"* (§7.1),
> so the list inside it carries no heading; the standalone band, where a card
> has one, still carries `The details · 6`. `short_answer` is still written and
> still indexed — it is the FTS `secondary` column, the one-line preview on a
> card row, what the exported plan carries, and the fallback for a card with no
> bullets.

`card_answer_page.dart` owns that sort. Four calls in it are rules:

| | |
|---|---|
| **No app bar** | The header row is in the page and SafeAreas itself — the same call `section_screen.dart` made from mock caption 3. §7c: a page that opens on a bordered box reads as a form |
| **The question is NOT on the picture** | Mock caption 3 puts the section label on the hero and the question in the reading column below it. A 15-word question at display size over an arbitrary sky is four lines of white text on a photograph, and the hero already owes the credit that corner |
| **Hero height 214** | The shelf's measured number, borrowed rather than re-picked, so the drawn-scene crop comparison does not have to be re-run. 103 of 280 cards land on a drawn scene, so it is the common case here rather than the fallback |
| **The body folds at the second `##`** | Progressive disclosure, never truncation: one control reveals every remaining section together. Ted's *"pages are way too long. way too long."* against a median 2,000 words |
| 🚨 **The videos are ABOVE the body, and OPEN** (2026-08-08) | Ted: *"the answers are too much reading, not much looking at them."* 1,700 researched videos shipped as folded link text under the citations and nobody ever saw one. Position and openness are the fix together — a band that is above the article but still collapsed does not answer the complaint. Slot 5, `VideoReferenceBandView`, `initiallyExpanded: true` |

🚨 **THINGS ARE COUNTED RATHER THAN STACKED.** 41 references are longer than the
answer. Each such band keeps its heading and its count unconditionally and folds
its contents.

> 🚨 **`evidence` AND `cannot_verify` ARE DELETED (2026-08-08, Ted's call).**
> This section used to describe folding 16 *What we checked* rows and placing
> *What we could not establish* above the sources. Both types are gone from the
> compiler and from both bundles — *"these are all NOISE to users."* Every row
> was true and not one was addressed to a traveller. **PRD §31 is discharged
> instead by `source_footer` + `reference_list` + `verify_due_at`,** which are
> claims with provenance rather than a per-sentence audit trail; gates 1 and 3
> are untouched. Do not re-add either: a bundle carrying one now falls through
> to `UnknownBlock` and renders nothing, which is correct.

The **phrase table folds above 10 rows** (2026-08-08). It was the one long band
that did not fold, and the asymmetry had a consequence rather than just an
inconsistency: I-06's 57 rows at three lines each pushed everything under it
roughly six screens down a page whose whole argument is that the answer comes
before the apparatus — the provenance footer, which gate 3 puts last on all 280,
is the band that actually falls off. 10 is taken from the corpus rather than
picked — the shipped bundle has
**5 of 276** tables above it (57, 17, 15, 12, 11), so 271 cards render exactly as
they did. The header still counts the whole 57 before the tap: folded is not
hidden, and the reader decides.

**Measured on the built page** (headed browser). L-09 over 전주한옥마을: hero
eyebrow **8.82:1**, meta 10.55:1, credit chip 16.25:1; on a drawn scene 11.80:1
and 14.31:1. Re-measured 2026-08-08 at the ends of the distribution — L-08 over
경복궁, the worst text-on-photograph in the set: eyebrow **8.48:1** against the
brightest background under it, meta 10.85:1, credit chip 14.78:1; and A-01, a
no-photograph card on `d-market`, 12.28:1 and 15.22:1. All above §9's 4.5:1, and
the floor under them is §6.3e's 0.66 scrim rather than the photograph's luck.

### Responsive — phone · tablet · desktop

`DdsBreakpoint` already existed and the roots ignored it, so a 1440px browser got
a 420px phone column floating in the middle of the window.

| | phone <600 | tablet <1024 | desktop ≥1024 |
|---|---|---|---|
| Tile columns | 2 | 3 | 4 |
| Cover height | **sized by its words** | **sized by its words** | **488** |
| Cover title | 26 | 34 | 44 |
| Topic tile | 118 (wide 140) | 138 (wide 164) | 210 (wide 280) |
| Topic tile title | 17, 2 lines reserved | 19, 2 lines | 23, 3 lines |
| Section title | 22 | 28 | 38 |

🚨 **A COVER PICKS NO HEIGHT ON A PHONE OR A TABLET, AND THAT IS STRUCTURAL.**
There, the column of words is the `Stack`'s sizing child and the photograph is
`Positioned.fill` behind it (`DaniKoMaybeFill` / `DaniKoCoverBox`), so the cover
is the masthead, the two scrim bands and the title and nothing else — about 150
in a browser and 197 on an iPhone, at any notch and any text scale.

Picking a number instead is the thing that does not work, and it was tried three
times: the words sit in a `Positioned.fill` column, so the number is a CAP, and
one number has to clear an iPhone's 47px status bar *and* be tight in a browser
that has none. Desktop keeps a fixed box, where a cover really is a band taller
than the words on it.

🚨 **The DESKTOP cover grew by 48 on 2026-08-08**, and the half tile from 164 to
176 before the phone sizes above replaced it.
Neither is a taste change: both covers gained a `DaniKoScrim` at each end (§9a)
and the trip card went 172 → 212, because a scrim needs photograph above it to
darken or the card is a dark rectangle with a picture faintly behind it. At the
old numbers the trips cover overflowed by a measured 19px and the
`Connectivity & Accounts` tile by 1px.

✅ **Closed 2026-08-07 — the desktop navigation leaves the bottom edge.** Three
tabs stretched across 1440px put every destination a third of a screen apart
along the bottom edge, which is where a phone's thumbs are and where a desktop
pointer never goes.

### 🚨 2026-08-16 — IT IS A TOP MENU NOW, AND IT IS ALWAYS ON SCREEN

The first answer was a 232px left rail drawn by `DaniKoShell`. Ted killed it the
day he used it: *"the desktop/chrome ui has the 4 sections in the left panel,
however, depending on the screen on the right, this panel disappears"*, then
*"instead of using left sidebar, use top menu for desktop/chrome"* and *"this top
menu should always be shown"*.

**The disappearing act was structural, not cosmetic.** The shell draws the rail,
and only the four destination screens use the shell. Every card, topic, place,
trip interior and settings sub-page is PUSHED OVER those four as a full-screen
route, so it covered the rail and the window lost its navigation entirely. **Any
menu a screen owns has that bug** — widening the rail would not have touched it.

So the menu is not owned by a screen. `DaniKoTopNav` mounts `DaniKoTopMenu`
**once, in `MaterialApp.builder`, above the router's Navigator**, which makes the
Navigator — with every push, pop and transition inside it — the thing that
renders below the menu. No route can cover it, Emergency and Show included.

🚨 **Read the router from `appRouterProvider`, never from the context.** The
first cut called `GoRouter.maybeOf(context)` and got **null**: `InheritedGoRouter`
is inserted by the router *delegate*, inside the `child` that builder is handed,
so the builder's context is one level ABOVE it. No placement in that builder can
satisfy that lookup — `join_deep_links.dart` was burnt by the identical trap one
day earlier. `maybeOf` made it worse than a crash: a null returned a perfectly
working app with no menu in it, and that is what Ted screenshotted.

Because it sits above the router it is never handed a `tab`. Its one input is the
location: `DaniKoBar.tabForLocation` keeps the parent lit on a pushed detail
(`/handbook/…/t-money` → Korea, `/trip/:id` → Trips) and lights **nothing** on
`/emergency` or `/auth`, which is the honest answer.

The cut is still at **≥1024 only**, and it is deliberate: this table's own row
already said *"≥ 1024. Persistent nav"*, and a tablet is a touch device held in
two hands — iPadOS keeps its bar at the bottom for the same reason a phone does.

🚨 **It reuses `DaniKoBar.labelFor` / `glyphFor` / `pathFor` and
`daniKoTabKey`**, so there is one destination list in the product and a test
that taps `Trips` taps the same thing at every width. And it keeps all THREE of
§9.1's signals — tint, thickened stroke, and the cap, which moves from the bar's
top edge to the menu's bottom edge, always on whichever edge faces the content. A
menu that dropped two of them because they were awkward horizontally would be
breaking exactly what §9.1 forbids.

---

## 8. Motion

**160ms, standard ease.** Longer and softer than the retired 120ms linear,
because surfaces are now larger and photographic. Still no bounce and no spring.
Confidence chips still do not animate on load — a state that flickers reads as
unreliable.

### 🚨 8.1 PARALLAX IS PERMITTED, 2026-08-07. Ted overruled it.

This section read *"no bounce, no spring, no parallax"* and the third of those is
struck. Ted, on the built app: *"this app is supposed to look good and fun with
lots of photos and videos and animations."*

**The other two stand.** An overshoot on a photograph of somebody's holiday reads
as a toy, and nothing in the ask was about elasticity — `easeOutCubic` carries
every motion added under this amendment.

What that permits, and the three shapes it has taken so far, all on `/trips`:

| Motion | Where | Number |
|---|---|---|
| **Parallax** | a cover lags the page it sits behind | 0.45× the scroll offset, inside a `ClipRect` |
| **Staggered entrance** | a list of the user's own things | fade + 18px lift, 340ms, 70ms apart, capped at 6 |
| **Press response** | a card that is a whole photograph | scale 0.985, 110ms |
| **Photograph arrival** | every hero, and every card photograph row | 260ms cross-fade over the designed state — the drawn scene on a hero, the alt text on a card tile (§6.3d) |
| **Hero flight** | a trip card's picture into the trip page's | shared element on the trip id, on the route's own 120ms |

#### 8.1a The hero flight — and no, it does not fight the cross-fade

2026-08-08. `lib/features/trip/presentation/widgets/trip_hero_flight.dart`.

Tapping a trip card flies its photograph into the trip page's hero instead of
cross-cutting to it. The worry going in was that a flying picture over
`daniKoPage`'s cross-fade would read as a double image. **Measured, it cannot
be:** for the whole flight `Hero` replaces its child with an invisible
placeholder **on both routes at once** and draws the picture exactly once, in
the `Navigator`'s overlay. There is never a second copy. Both are also driven by
the **same** animation — a flight's clock IS the destination route's
`transitionDuration` — so the picture lands on the frame the page reaches full
opacity. `test/widget/trip_hero_flight_test.dart` asserts all of it frame by
frame.

Three things had to be fixed to get there, and each was found by measuring:

- 🚨 **The trip page now renders on frame ONE.** `tripProvider` is a Drift
  stream, so `TripHomeScreen` used to render nothing until it opened — and
  Flutter collects the two ends of a flight in a post-frame callback on the push
  frame, ONCE, never retried. With no hero on that frame the push silently did
  not fly while the pop did. It seeds from `tripsProvider`, which is the list the
  user just tapped.
- 🚨 **A straight `RectTween`, not `MaterialRectArcTween`.** The Material
  default swings each corner along an arc; between these two near-concentric
  rects the bow was sideways, leaving the bounding box of the two ends by a
  measured **12.3px** and returning. §8.1 permits parallax and forbids bounce —
  a sideways excursion that reverses is nearer the second, and nothing about a
  picture growing in place moves right.
- **A `flightShuttleBuilder` that morphs the ends.** They differ: the card is
  212px, inset, `DdsRadii.hero`, badge-led and eyebrow-less since 2026-08-16 —
  the phase word `AFTER` above a badge reading `TRIP COMPLETE` said the same
  thing twice, in vocabulary only this codebase uses; the page's is full-bleed
  and square. The departing end stays OPAQUE underneath while the arriving one
  fades up — a symmetric cross-fade puts both at 0.5 mid-flight and thins the
  picture. Both ends resolve the same photograph, so only the chrome morphs.

**Not covered:** an IN-COUNTRY trip. That posture is the day page, which is
typographic and has no photograph, so there is no far end and the card
cross-fades as before. A `Hero` with no partner is a silent no-op, which is why
there is a test for it.

⚠️ **THE RESIDUE, AND IT IS `daniKoPage`'s, NOT THE FLIGHT'S.**
`_CurrentRouteSelection` picks between `DaniKoSelectionScope(child: child)` and
bare `child` — **a change of widget TYPE at one position**, which Flutter cannot
update across. So every push tears the whole subtree of the route below down and
every pop rebuilds it, losing scroll offsets, controllers and `initState` work
**on every navigation in the app**. On `/trips` that replays the entrance, and
the returning picture lands on a card measured at **0.4476 opacity**, settling
over the rest of the 340ms. It is in the right place at the right size — dimmer
for a moment, not misplaced — and it is not new: `/trips` already replayed its
entrance on every back-navigation.

🚨 **This is a SECOND, DISTINCT bug from the frozen entrance in §8.1b, and it is
still open.** It was checked as part of resolving that one: the teardown makes
the entrance *replay*, which is a cosmetic dimming under a healthy ticker. It is
not what held `New trip` at opacity 0 for 4.16 seconds on a device — that was
frame starvation on a cold start, with no navigation involved at all. Fixing the
teardown would not have fixed the frozen entrance, and the deadline in §8.1b does
not fix the teardown. What the deadline *does* do is put a floor under this one
too: a replayed entrance can no longer outlast its grace either.

🚨 **And the obvious fix crashes.** A `GlobalKey` on `TripsRootScreen` makes
Flutter reparent the subtree instead of rebuilding it, and every assertion then
passes at 1.0 — but reparenting across a `SelectionContainer` boundary throws
`Null check operator used on a null value` from
`_SelectionKeepAliveState.remove` when the tree is finalized. Flutter's
selection registrar cannot survive it. **That is very likely why the ternary
tears the subtree down in the first place**, and it means the teardown is not a
free thing to "fix" — it needs a design, not a key.

🚨 **An entrance starts at opacity ZERO, so it can hide the page.** That is the
one thing about this amendment that is a rule rather than a taste:

- it must honour `MediaQuery.disableAnimations` by being **absent**, not faster —
  an `Opacity(1.0)` still puts a frame of machinery between a person and their
  content, and a platform that never runs the ticker then shows nothing at all;
- it must not use a `Timer` or `Future.delayed` to stagger. The delay is an
  `Interval` inside the widget's own controller, so a row scrolled out of the
  list cannot leave a pending callback behind — the same rule
  `capture_sheet.dart` records for itself;
- 🚨 and it needs a **deadline**, because the two bullets above guard the only
  case we can *detect*. See §8.1b — this is now `DaniKoEntrance.grace`, and it
  is the rule, not an implementation detail.

#### 🚨 8.1b The frozen entrance was REAL. Resolved on a device, 2026-08-08.

**Verdict: PRODUCT, not harness.** The previous session's headed-Chrome
observation — a trip card at opacity 0.32 forever, `New trip` never appearing,
`status: forward · isAnimating: true` with a value that does not move — was
correct about the symptom and could not attribute it. It is attributed now.

**Device: Ted's iPhone 16, iOS 27.0, debug, wireless, `flutter run --route=/trips`.**
Both entrances on `/trips` were instrumented with a *silent* frame counter — one
`debugPrint` at completion, so the measurement could not be the thing perturbing
it — and printed:

```
idx=0   nominal 340ms · actual   94ms · frames=1
idx=1   nominal 340ms · actual 4160ms · frames=2      ← `New trip`
```

Two findings, and the second is the bug:

- 🚨 **`frames=1`.** The controller is ticked ONCE and goes straight from 0.0 to
  1.0. **The fade and the 18px lift are never rendered at all** on a cold start —
  what a person actually sees is a pop, not the motion this section specifies.
- 🚨 **`frames=2` over 4160ms.** The `New trip` button sat at **opacity 0 for
  four and a sixth seconds** on real hardware. Not dim. Invisible.

The mechanism is not exotic and it is not fixable by tuning: a cold start has no
frames to spare — Drift opens, the content bundle hydrates — and an entrance
that gates opacity on a ticker inherits **every** frame stall as invisibility.
The web build was neither lying nor an artefact of CDP; it was the *unbounded*
case of exactly this, where the stall simply never ended.

**The fix is a deadline.** `DaniKoEntrance.grace` (500ms) past the animation's
own nominal length, the entrance gives up and snaps the controller to 1.0. Under
a healthy ticker it never fires. Under a stalled one it bounds the damage
instead of leaving it unbounded. If the event loop itself is blocked the timer
is blocked with it — but that is a frozen app, a different failure, and the
guard still fires the instant the loop resumes.

**Verified on the same device, same route, after the change:**

```
idx=0  nominal 340ms · visible at 1121ms · byDeadline=true
idx=0  nominal 340ms · visible at 1961ms · byDeadline=true
```

`byDeadline=true` is the finding: the controller still does **not** complete on
its own during a cold start, so the guard is load-bearing rather than
theoretical. 4160ms of invisible content became ~1–2s.

⚠️ **AND THE RESIDUAL IS HONEST: ~1.1–2.0s is still longer than the 840ms
deadline, because the event loop itself is congested on a cold start.** The
timer fired late for the same reason the ticker did. That is a **cold-start
main-thread cost** — Drift opening, the content bundle hydrating — and it is a
performance problem, not a motion one; no widget-level guard can beat it. Both
figures are from a **debug** build, where a release build would be materially
faster. What the deadline changes is that the delay is now bounded by how soon
the loop runs again, instead of by whether the engine ever chooses to produce a
frame — which is what made the web case unbounded. 🚨 If `/trips` still feels
slow to populate in release, the thing to profile is cold start, not §8.
**That profile was done — §8.1c. The congestion was real, it was a full table
scan per indexed row, and the deadline above is still load-bearing.**

🚨 **This is the one place a `Timer` is permitted in an entrance.** The ban above
is on a timer *for the stagger*, for the reason given there. The deadline is a
single timer cancelled in `dispose` before the controller it touches is disposed.

🚨 **And `trips_root_screen.dart`'s private `_Entrance` copy is gone.** It was a
byte-identical duplicate of `DaniKoEntrance`; fixing one and leaving the other is
how this comes back on the You root instead. One implementation.

⚠️ **WHY THE EXISTING TESTS WERE GREEN THROUGHOUT.** `trips_root_test`'s two
entrance assertions run after `pumpAndSettle()`, which pumps until nothing is
scheduled and therefore **always** completes the animation. It tests a healthy
ticker and calls the result a guarantee; it cannot fail this way. The new
`test/widget/entrance_deadline_test.dart` mutes the ticker with
`TickerMode(enabled: false)` — reproducing the measured signature exactly — and
asserts **the content is reachable**, never a dimension. Both guard cases fail
without the deadline and pass with it; that was verified by removing it.

> ✅ **VIDEO — DECIDED AND BUILT, 2026-08-08.** This paragraph used to say video
> was not covered here, because it needed a product decision rather than an
> implementation. Ted took that decision: shown that an embedded player needs a
> new package and cannot work offline, he chose it over linking out and over
> shipping nothing.
>
> **The shape.** The card's `reference_list` of kind `video` renders through
> `VideoReferenceBandView` (`lib/design_system/blocks/video_blocks.dart`)
> instead of the generic link list. ONE video sits on a 16:9 stage and the rest
> are text rows that promote themselves when tapped — the corpus's biggest card
> carries 16 videos, and sixteen WebViews is a dead page, not a heavy one.
>
> 🚨 **POSITION AND OPENNESS, REVISED 2026-08-08.** This band first shipped as
> the LAST slot, folded, below the citation apparatus. That is where 1,700
> researched videos had already been invisible for the life of the corpus, and
> Ted's complaint — *"the answers are too much reading, not much looking at
> them"* — is not answered by a band that is last and collapsed whatever
> renderer is inside it. `card_answer_page.dart` lifts it OUT of the references
> loop to **slot 5, above the article, with `initiallyExpanded: true`.** The
> header still counts and still folds back; what changed is the default.
>
> **English leads; Korean is folded, never dropped.** 1,121 of the corpus's
> 1,701 rows are English and 580 are not. The stage and the promotable rows come
> off `ReferenceListBlock.english`; the rest sit behind *"Also in Korean · N"*,
> counted before the tap. 🚨 A card whose videos are ALL Korean shows them — an
> "also" over an otherwise empty band is a fold over nothing. `isNotEnglish` is
> *not plainly English*, not `lang == 'ko'`: the corpus writes `ko`, `both`,
> `en/ko`, `zh`, `ja`, `ne` and free text, and an equality test would pass a
> Mandarin film off as English.
>
> **The offline promise got sharper, not looser.** Nothing above or below the
> band waits on the network, and now that an open stage sits above the body,
> that is load-bearing: the article, the phrases, the citations and the
> provenance footer all render whether or not YouTube ever answers.
>
> 🚨 **THERE IS NO PLAYER PACKAGE, AND THAT IS THE DECISION (2026-08-08).**
> `youtube_player_iframe` shipped with the first version of this band and was
> removed the same day. It is itself a wrapper around a WebView loading an
> `<iframe>`, so it bought nothing and cost three real things: it had to be
> pinned below 5.2.0 (5.2.2+ needs `web ^1.1.1` while `share_plus` 9 pins
> `web ^0.5` — the collision that made `file_picker` unusable), web playback was
> never once observed through it, and it dragged in `webview_flutter` anyway.
> What replaced it is the thing it was wrapping. **Web** is a real `<iframe>`
> through `HtmlElementView.fromTagName` — no WebView in the path at all.
> **iOS and Android** load an HTML string containing that same iframe into a
> `webview_flutter` WebView, with `playsinline=1` in the URL *and*
> `allowsInlineMediaPlayback: true` on the WKWebView — either one missing and
> pressing play throws the reader into iOS's fullscreen player.
> `youtube_iframe.dart` builds the URL and the document; `youtube_embed_view.dart`
> is the conditional-import seam; `share_plus` did not move.
>
> **The licence line, and it is the one that is easy to cross.** Playback is
> YouTube's own official embed. We do not download, cache, proxy or re-serve the
> video — **and that includes the poster frame.**
> An `Image.network` pointed at `i.ytimg.com` is hotlinking a third party, which
> §6.3's photograph rule already forbids; the embed fetches its own poster
> internally, and where we draw a state ourselves it is `DaniKoScene`, never a
> fetched picture and never a glyph.
>
> **Four states, because three of them are not "it plays".** §6.3d's third-state
> rule applies verbatim: *starting* must not look like *absent*. So the stage is
> `starting` (the drawn scene, the scrim, the video's title) → `playing`; and
> falls to **needs connection** offline or after a 25s load timeout, or **plays
> in your browser** on a platform with no player. Both fallbacks carry an action.
> 🚨 The button on those panes forces `DaniKoTheme.dark()` — it stands on §6.3e's
> ink in BOTH modes, and in light mode it first shipped as near-black on
> near-black, invisible.
>
> **The language is said before the tap.** 580 of the corpus's 1,701 video rows
> are not in English and `lang` is not a clean enum — `ko`, `both`, `en/ko`,
> `zh`, `ja`, `ne` and free text all occur. Anything not plainly English gets a
> word on the stage and in the row (§31 honesty, not a nicety).
>
> ✅ **WEB PLAYBACK IS SETTLED, AND IT PLAYS (2026-08-08).** The earlier note
> here said it was unconfirmed. It was looked at: a `--profile` web build served
> statically, L-18 opened at `/handbook/l/l-18-service-animals`, the band
> expanded, and the network log shows exactly one request —
> `https://www.youtube.com/embed/O0lb9-XnK4M?playsinline=1&rel=0&modestbranding=1`
> — followed by the poster, and then, after pressing play, a mid-film frame
> inside the card. No `ytimg` request was made by us at any point.
> 🚨 **`flutter run -d web-server` in debug never boots the app in a headless
> CDP browser** (1,786 DDC modules load and no `flutter-view` is ever created),
> so it is the wrong tool for this gate; a static `--profile` build is the one
> that works. `/dev/gallery` is `kDebugMode`-only and therefore absent from that
> build — reach the band through a real card instead.
>
> ⚠️ **STILL UNLOOKED-AT: iOS and Android.** Both compile
> (`flutter build ios --debug --no-codesign` is green and `ios/Podfile.lock` is
> unchanged, because `webview_flutter_wkwebview` was already in the pod set),
> but nobody has watched a video play on a device. Windows is settled the other
> way: no federated `webview_flutter` implementation, so it falls back honestly.

> **Implemented deviation (M1, 2026-07-30, still in force): page transitions
> CROSS-FADE, they do not push.**
>
> Flutter's slide transitions are built on `RenderFractionalTranslation`, which
> has no size on the first frame of a push. The text-selection delegate sorts its
> selectables by screen position and walks the render tree through that un-sized
> object, asserting `RenderBox was not laid out`. Since **all user-visible text
> must be selectable** and that is non-negotiable for an app whose primary
> in-country action is copying a Korean string, the transition gives way.
> `lib/core/router/dani_ko_page.dart`.

> 🚨 **And ONE route does not transition at all: `/emergency`.** Added 2026-08-07.
> PRD §7.2 forbids animation on that surface outright, and 120ms of partial
> transparency on the first line of the screen someone reaches in the worst moment
> of their trip is 120ms too many. `daniKoPageInstant` is the zero-duration page;
> `/emergency` is its **only** caller and `test/router/route_set_test.dart`
> asserts that it stays the only one. Where the motion went instead is drawn:
> `v2-1-shell.html` caption 1 sub-frame **f** puts the 0.5s ring on the bar's ⊕,
> *"so that surface can stay dead still."* Anything else asking for an instant
> page is a product decision, not a refactor.

#### 🚨 8.1c The congestion §8.1b could not attribute. Measured 2026-08-08.

§8.1b ends by naming a residual it could not explain — `byDeadline=true`, the
entrance ticker never completing on its own, blamed on *"Drift opening, the
content bundle hydrating"* — and says the thing to profile is cold start, not
§8. This is that profile. It is here rather than in a spec because it is what
sets the ceiling on every entrance, every photograph and every card body in
§8.1a–b.

**Target: the web profile build (`flutter build web --profile`, dart2js),
headless Chrome 151 on macOS, served over plain HTTP** — so drift takes its
IndexedDB fallback rather than OPFS. This is the platform the stall was
originally reported on ("the TRIP tier had not applied within 36 seconds"), and
it is the slowest of the shipped four; native is the same shape and roughly a
tenth of the magnitude. 🚨 **The iPhone numbers are still missing** — the device
was locked for the whole session and `devicectl` cannot launch into a locked
device — so nothing here is an iOS figure and none of it should be quoted as
one.

Timestamps are milliseconds from the first line of `main()`, printed by
`lib/core/content/cold_start_trace.dart` (debug and profile only, a bare
`debugPrint` so the instrument cannot be the congestion it is measuring).

**FIRST launch** (app data wiped between runs):

| | before | after |
|---|---|---|
| first frame | 301 | 276 |
| core tier applied | 2,242 | **1,462** |
| **trip tier applied** | 18,341 | **15,060** |
| article block readable | 18,299 | 15,022 |
| `hydrateCityPhotos` done | 20,736 | 15,078 |

**WARM launch** — and read this one carefully, because the headline number is
mostly not ours. End to end it went 12,653 → 5,607ms, but the content path is
only 226 → 159ms of that; **all the rest is `bootstrap`, i.e. drift opening the
WASM database over IndexedDB, which measured 12,386ms and then 5,374ms for the
same database.** That is run-to-run variance in the browser's storage, not a
thing this change touched, and quoting the 2× as a win would be dishonest.

The peek's real value is on native and is unmeasured here: on web, `jsonDecode`
is the browser's own `JSON.parse` and cost 8ms and 14ms for the two bundles, so
skipping it saves almost nothing. On the Dart VM the same two parses cost 47ms
and 61ms in a warm host run and 239ms and 133ms in a cold one — **on the UI
isolate, on every launch** — and that is the frame budget §8.1b watched
disappear. 🚨 Whoever next gets the phone unlocked should put the iOS figure
here.

**🚨 The cause was a full table scan per indexed row, and the schema says so out
loud.** `search_index` and `search_trigram` declare `kind` and `ref_id`
`UNINDEXED` — that is what the word means, FTS5 keeps no index on them. So
`DELETE FROM search_index WHERE kind = ? AND ref_id = ?` cannot seek; it reads
every row. Reindexing card-by-card did 280 of those against a table the same
loop was making bigger, each row holding a 900–3,100-word article body. It is
quadratic in the corpus, which is exactly why it was invisible while the
handbook was short and cost **14.5 seconds** once all 456,000 words had landed.
One `IN (…)` sweep per index (`deleteFtsRows`) deletes the same rows in one
scan: the trip reindex fell from **4,588ms to 876ms** on native (host, in
`flutter test`), 14,461ms → 8,541ms on web.

Two smaller ones fixed alongside, same shape:

- **`_reconcileMedia` was 820 awaited DELETEs per tier** — one per photograph in
  the manifest, 1,640 per cold start, against a table that is empty until
  something has actually been downloaded. Now one batch.
- **Every launch parsed 10.5MB of JSON to read one integer.** `ensureTier`
  fetched and fully decoded both bundles, and only then did `apply` compare
  versions and return `upToDate` having written nothing — on the UI isolate, on
  every launch after the first. `BundleSource.peekVersion` now reads
  `bundle_version` off the first 512 bytes; a source that cannot peek returns
  null and falls back to the parse, so the fast path can be absent but never
  wrong. `test/content/article_reachable_test.dart` asserts the peek and the
  parse agree on both shipped tiers, and that a bumped version still lands.

**🚨 It is NOT a one-time install cost. It recurs on every `BUNDLE_VERSION`
bump.** `ensureTier` writes nothing while the versions match — which is why
`d778774` had to bump 2 → 3 to ship a recompiled bundle at all — so every
content release re-applies the whole 7.8MB trip tier on the next cold start, for
every user. A content release and a cold-start regression are the same event.

🚨 **This is why binary assets live OUTSIDE `assets/content/`.** The topic
index's 19 photographs (`assets/topics/`, 1.48 MB) and the cue-card audio are
both media a curator swaps without any of the content changing, and putting them
inside the bundle would attach that swap to a version bump and to this 7.8 MB
re-apply. They are declared as their own `pubspec.yaml` asset directories, read
directly by `AssetImage`, and never parsed by `ensureTier`.

⚠️ **AND THE RESIDUAL IS AGAIN HONEST: the web first launch is still ~15s, and
what is left is the index build itself, not a loop.** Probed on the same target:
writing 280 card bodies into `search_trigram` cost **11,588ms** against
**5,192ms** for the same text into `search_index`. The trigram tokenizer emits a
token per character position, so 456,000 words of editorial prose is roughly 2.9
million tokens — and `fts.dart` says the trigram index exists for *names*:
Korean partial matching, typo tolerance, mid-word English. **Whether article
prose belongs in it at all is a search decision, not a performance one, so it is
left as it is and written down here rather than quietly changed.**

> 📋 **FOR TED — three things this measurement found and did not decide.**
> 1. **Editorial prose in the trigram index** costs ~11.6s of the ~15s web cold
>    start (~2/3 of the card-body indexing on native too). Dropping `body` and
>    `korean` from `search_trigram` for `card` rows would remove most of it. The
>    cost is that a Korean substring occurring only inside an article body stops
>    being reachable by the S-13 fallback; the unicode61 index still holds the
>    full text. This is the single biggest remaining number.
> 2. **Every photograph in the app waits for the trip tier.** Four screens
>    `ref.watch(coreBundleProvider)` purely because `CityPhotoRegistry` has no
>    listeners, and that provider resolves only after `hydrateCityPhotos()`,
>    which runs after the trip apply. Core carries all 820 `city_photos`, so the
>    photographs are ready ~13s before anything shows them. Splitting the trip
>    apply into its own provider would fix it and touches `card_answer_page.dart`
>    — handbook territory.
> 3. **Core's whole reindex is thrown away by trip's**, because the trip tier is
>    a strict superset of core (`tool/build_content_bundle.py` says so). It is
>    only ~120ms on web, so it is not worth the risk today — but applying core
>    first is what buys the question-and-summary at 1.5s instead of 15s, and
>    that trade is the reason the order is what it is.

Haptics: light impact on copy, selection click on tab change, warning haptic when
a blocking conflict appears. Nothing else.

### 🚨 Emergency is the one surface allowed to be PLAIN

Not an exemption from the design system — a requirement of it. PRD §7.2: no app
shell, no tab bar, no illustration, no animation, no spinner, monochrome. Battery
is a first-class input on that screen, and the mock's own annotation is the whole
argument: *"Plain, high-contrast, monochrome — which is what a dying battery wants
anyway."* 🚨 **It is not red.** Red means a DEADLINE; an emergency is not a
deadline, and spending the product's one urgency colour there would make the
screen indistinguishable from a K-ETA countdown. What it uses instead is a
**full-contrast 1.5px edge** on a transparent fill (`.emrow`, and the print
control), which is where the screen's whole visual character comes from.

---

## 8b. Safe areas — non-negotiable, unchanged

**No screen may draw under the status bar, notch, or home indicator.**

This shipped broken once: the back button rendered underneath the system clock
on every screen that had one, leaving no way out of the app.

The cause is worth knowing. `DotAppBar` is a plain `Container` with fixed padding
and applies no status-bar inset. `Scaffold` reserves
`preferredSize.height + MediaQuery.padding.top` for its app bar, but it only
*insets* a Material `AppBar` — a custom `PreferredSizeWidget` gets the extra
height and still draws from y = 0.

- Any custom app bar applies **`SafeArea(bottom: false)`** itself.
  `DaniKoAppBar` does; do not remove it.
- A `Scaffold` with no `appBar` wraps its `body` in `SafeArea`.
- 🚨 **A full-bleed hero photo is exactly the case this rule was written for.**
  The photo may extend under the status bar; **the title and eyebrow on top of it
  may not.** Overlaid content is inset even where the image is not.
- Fix it in dani.ko's wrapper, **never in `dot_flutter`**.
- Covered by `test/widget/app_bar_safe_area_test.dart`, which renders under a
  real 59px inset and asserts geometry.
- 🚨 **Five roots have NO bar at all and each takes its own inset**, asserted
  separately in that file because none of them inherits it from the others: the
  Korea root, the You root, the Saved root, S-13 search, and — from 2026-08-07 —
  **S-35 Emergency**. Emergency is the one where it costs the most: the elements
  that would land under the clock are the 33px word *Emergency* and the close
  control beside it, so a person who cannot read the first line also cannot leave.

### 🚨 The rule is not "app bars". It is "anything that can reach the top".

It shipped broken a **second** time: the New trip modal bottom sheet drew
`New trip` on top of the system clock. A sheet is its own route, so it never goes
near `DaniKoAppBar`.

- **`showModalBottomSheet` with `isScrollControlled: true` MUST pass
  `useSafeArea: true`.**
- **And the content must be scrollable.** `useSafeArea` alone only relocates the
  failure; wrap it in a `SingleChildScrollView`.

**Reproducing this needs a TALL keyboard, which is why it escaped.** At a plain
QWERTY's ~336px the sheet still fits and nothing looks wrong. It only breaks past
~460px — any keyboard carrying a candidate row, i.e. Korean, emoji, or
autocorrect. For an app whose users are by definition typing Korean, that is the
*normal* case. Covered by `test/widget/new_trip_sheet_safe_area_test.dart` at
460px, verified red with `useSafeArea` removed.

---

## 9. Accessibility

- **Text on any accent fill ≥ 4.5:1** — `#FFF` on flag blue is 8.6:1, on flag red
  5.2:1.
- **Blue as text on dark uses `blue_text`** (4.55:1), never the flag value
  (2.41:1). §2.1.
- **Colour is never the only signal.** Urgent rows carry a red border *and* a
  deadline in words *and* a red action tag. Done rows carry a filled check *and*
  strike-through. 🚨 **Danger red (§2.4) is the strictest case of this rule and
  not an exception to it:** every reddened subject is the same silhouette it was
  drawn as flat, named in real text beside it and again in `onPaper`, so a
  colour-blind reader and a printed page both lose the hue and keep the fact.
- 🚨 **Danger red's ground is a diagram strip, which is `surface2`, and dark
  `surface2` is the tightest pair in the app at 3.01:1** — over §9's 3:1 painted-shape
  floor by 0.01. `test/theme/contrast_test.dart` measures it in both modes.
  §2.4 has the table and the reason it is allowed to be this thin.
- 🚨 **Text over a photograph always sits on a gradient veil**, never directly on
  the image. Contrast against an arbitrary photograph is unprovable otherwise.
- Confidence states distinguishable without colour — `unknown` dashed,
  `blocking` strike-through on the item title.
- Dynamic type respected via the DDS viewport scale.
- All user-visible text selectable.

### 🚨 9.0a Selectable means EVERY surface, sheets and dialogs included — 2026-08-21

Ted: *"All text should be select & copy-able"*.

The container was already on every ROUTE (`daniKoPage`, which explains why it is
attached there and not above the Navigator). A modal bottom sheet and a dialog
are their own routes pushed over that one, so every word inside one — the
confirmations, the editors, the account sheet, the document reader — sat outside
every container in the app. They are all wrapped now:

- `showTripSheet` wraps once, for the ~20 sheets that come through it;
- every remaining raw `showModalBottomSheet` / `showDialog` wraps its own
  builder.

🚨 **A new sheet or dialog that calls `showModalBottomSheet` or `showDialog`
directly must wrap its builder in `DaniKoSelectionScope`.** Prefer
`showTripSheet`, where it is free.

### 🚨 9.0b Every text box answers the return key — 2026-08-21

Ted: *"All text boxes should have an enter action. If there are multiple input
fields, enter moves the cursor to the next. If not, if there is an action button
(like 'enter', 'do' …) run the action as if the button is clicked"*.

| the box | the key says | pressing it |
|---|---|---|
| one of several fields in a form | `next` | moves the cursor to the field below |
| one field over an action button | `done` / `go` | runs the button's own callback |
| a live-filtering search box | `search` | closes the keyboard over the results it already showed |
| a field that saves as you type | `done` | closes the keyboard — there is no button to press |
| **a multi-line field** | — | **a new line, and nothing else** |

🚨 **The last row is not an exemption, it is the rule applied.** In a note, a
review or a paste box the return key is a paragraph break, and taking it away to
run an action would break the only thing those boxes are for. Four fields are
deliberately without an action for this reason: the clip sheet's note, a place
review's body, the import screen's paste box and an item's note.

🚨 **`TripField` cannot be configured into a dead key.** Give it `onSubmitted`
and the key becomes `done` and runs it; leave it null and the key becomes `next`
and moves focus — and on a one-field form `nextFocus` finds nothing and closes
the keyboard, which is what `done` would have done anyway.

### 🚨 9.1 Three pairs in the approved palette measure under AA — open, and Ted's call

Computed from the tokens by `test/theme/contrast_test.dart` when R0.2 landed, not
estimated. All three are **light mode**; dark mode clears 4.5:1 on all of them.

| Pair | Ratio | Where it shows |
|---|---|---|
| `text3` on `bg` — `#8593A3` on `#F7F8FA` | **2.95:1** | Eyebrows, section labels, counts (10.5px/700) |
| `verified` on `verified_soft` — `#12855A` on `#DFF5EB` | **4.06:1** | The done / verified tag |
| `red` on `red_soft` — `#CD2E3A` on `#FDE7E9` | **4.39:1** | The urgent tag and the urgent row's deadline line |

AA wants 4.5:1 at these sizes. **Two of the three cannot be fixed from the code
side:** flag red is the exact 태극기 value and §2.1 forbids moving it, so only
the container could change — and the container is what was approved by eye on
2026-08-05.

**What was done instead of quietly changing either the tokens or the test:** the
tokens stay exactly as chosen, the test asserts the 3:1 UI floor so none of the
three may drift further down, and the numbers are written here. Tightening the
test to 4.5 is not a code change; it is a palette change and it needs Ted.

Three ways out, if he wants one: darken the soft containers a little (cheapest,
invisible at a glance), reserve these pairs for ≥16px/700 text where 3:1 is the
correct floor, or accept them as recorded deviations. Nothing else in the palette
is affected.

🚨 **One call site has already been moved off `text3` rather than waiting for
that decision, and it is the one that could not wait.** *"machine output — not
for handing over"* — §8.3's refusal, the sentence that tells a reader NOT to hand
a machine translation to a stranger — was drawn in `text3`, which measures
**3.13:1** on `surface` in light mode and **4.01:1** in dark. Both are under
4.5:1, on the one line whose entire job is to be read. It is `text2` now:
**6.40:1 light, 7.68:1 dark**, at all three call sites (`CarryRow`,
`DaniKoHandover`'s provisional block, the Handy-cards tile).

The §8.3 distinction is untouched, because greyness was never carrying it. The
line is still DM Mono at 11px against a reviewed line's 11.5px Manrope 600, still
inside a block that is dashed, unfilled and silent, and it still says something
no reviewed surface ever says. `test/widget/absence_costs_nothing_test.dart`
asserts both halves — the ratio in both modes, and that the machine register
survived the fix. This is not a licence to lift `text3` generally: it stays where
it is, on eyebrows and counts, and the table above is still open.

---

## 10. Logo and app icon

**dani.go's lockup, in flag blue.** Same mark, same geometry, same proportions —
only the string and the colour differ.

| | |
|---|---|
| Tile | **`#0047A0`** — flag blue, replacing `#000080` |
| Mark | `#FFFFFF` `d` + dot — **8.6:1** on the tile |
| Wordmark | `dani.ko`, `text` token — inverts with the theme |
| In app | `DaniKoLogo` (`lib/design_system/widgets/dani_ko_logo.dart`) |
| Icon source | `tool/build_icon.py` → `assets/images/app_icon.svg` |
| Regenerate | `.venv/bin/python tool/build_icon.py && dart run flutter_launcher_icons` |

🚨 **The icon IS the accent — regenerate it whenever §2.1 moves.** §2.1 has just
moved. This is outstanding work, not done.

🚨 **Run the regeneration from the worktree, checking cwd.**
`dart run flutter_launcher_icons` has already silently regenerated the *main
checkout's* icons from the main checkout's `pubspec.yaml` while reporting
success. `CLAUDE.md` names this specifically.

**The mark does not follow theme.** `DaniKoBrand.blue` stays `#0047A0` in both
modes: a logo that changes colour between modes is a different logo. It stays
legible on the dark background because the white glyph sits **on** a filled tile
rather than beside it. `test/widget/logo_test.dart` asserts the mark is the brand
token and not the UI accent — **update that test's expected value with §2.1.**

Geometry is dani.go's **artwork** — `danigo/assets/images/dani_logo_dds_v3_light.svg`,
the lockup the site and the brand files show. Tile 1.96× nominal height, radius
0.22 of the tile, then as fractions **of the tile**: `d` 0.658 (79/120), dot 0.125
(15/120), gap 0.28, **wordmark 0.867** (104/120) with −3% tracking and a
**+0.075× baseline nudge**.

🚨 **dani.go's artwork and dani.go's Flutter widget disagree, and the artwork
wins.** `danigo/lib/.../dani_logo.dart` sets the wordmark at 2.02× nominal
*height* — 1.031 of its own tile against the SVG's 0.867, a word 19% larger.
Copying the widget is a mistake this file has already made.

**The face is dani.go's too — Space Grotesk Bold, and NOT `DdsType.ui`.** The UI
voice moved to Manrope on 2026-08-05; the mark did not follow, because a logo is
artwork and not text. `tool/build_icon.py` outlines the same face for the app
icon, so the tile in `DaniKoLogo` and the tile on the home screen are one glyph.
See Phase 10 in `REDESIGN_PLAN.md`: `SpaceGrotesk-Bold.ttf` / `weight: 700` is
exempt from the font deletion for exactly this reason.

> **2026-08-30, two rejected versions, both TOO BIG.** First: the word sized by
> fitting its measured ink block (1464/2000 em above the baseline, 30/2000 below)
> to the tile edge with a `TextPainter` baseline probe, in Manrope — 1.34× the
> tile. Ted: "i like dani.go font and size/offset". Second: dani.go's *widget*
> constants, 1.031× the tile — Ted: "icon is totally different in letter size,
> the logo font is different too". It was never a font difference: the bundled
> `SpaceGrotesk-Bold.ttf` and the file `GoogleFonts.spaceGrotesk` fetches for
> dani.go are both Space Grotesk v2.000, identical outlines and advances,
> verified glyph by glyph. **A wordmark a fifth too large reads as a heavier
> face.** If you are about to make the word bigger, you are going backwards.

> **Three numbers exist for the wordmark and only one is right.** The SVG
> artwork sets it at **0.867× the tile**; `shared/design/dot-tokens.yaml § logo`
> says **0.928×**; dani.go's Flutter widget draws **1.031×**. This file follows
> the artwork. The baseline nudge is a `Transform.translate`, **not** a
> `Padding`, so it adds nothing to the laid-out height for the enclosing
> `FittedBox`.

**Copied, not imported.** dani.ko shares dani.go's data layer and none of its UI.

🚨 **A LOGO HAS A MARK.** This took three attempts. First the app bar hand-set
`Text('dani') + Text('.') + Text('ko')` — nothing tied it to the brand, so it sat
unchanged through two accent changes. Then it became a PNG of the same word:
different code, identical screen. Neither was a logo.

**Every shape is a filled path; nothing is stroked.** Android vector drawables
and several SVG rasterisers drop stroked shapes silently, failing as a blank icon
rather than an error. The master is square and full-bleed — iOS and Android mask
their own. Only `web/favicon.svg` carries rounded corners. The Android adaptive
foreground is **not** pre-scaled; the web maskable **is**, to 0.85.

---

## 11. Deliberate deviations

### 11.-1 🚨 THE PROFILE AND THE CHECK-IN CARD — 2026-08-16

Four deviations, all Ted's calls in a live sitting, all with the argument at the
code site. Recorded here because the next person will otherwise "fix" them.

| Deviation | Rule it departs from | Why |
|---|---|---|
| **The check-in card leads in KOREAN at display size** — 성명, 여권번호, 예약번호, with English beneath | `CLAUDE.md`: English first, Korean in parentheses | It is the second sanctioned exception, the **show-this-to-someone** block. This screen's reader is a front-desk clerk, not the traveller: the traveller already knows their own passport number, and the person who needs the word 여권번호 is the one being handed the phone. The SUMMARY tile on the You screen is English-first, because that one the traveller reads |
| **`showDatePicker` is banned in this app.** All three dates — birthdate, check-in, check-out — use `DaniKoDateInput`, three dropdowns | Material's standard date affordance | The dialog returns null on the platform dani.ko is tested on. It cost two rounds of misdiagnosis: with optimistic local state the label still would not update, which is what proved the dialog and not the data layer was at fault. A calendar would be the better control for a reservation date and is not used there either — one control known to work beats two, one of which is known not to. **Revisit only if `showDatePicker` is ever understood** |
| **A validation error is drawn in the ACCENT, not in red** — the duplicate-trip-title message | Red is the error colour nearly everywhere else | §6's rule is absolute: *if it is red, it has a date attached.* 태극기 red is the deadline colour. A naming collision has no date, and spending the deadline colour on it is how the colour stops meaning anything by the time a real deadline needs it. Weight carries the emphasis instead — the border thickens |
| **The You card's `DaniKoGap` for the Korean name spelling is DELETED** | PRD §12 — state a gap, never paper over it | Ted: *"useless because we are not doing anything about it"*. §12 exists so the app does not INVENT facts about Korea it cannot source. It is **not** a licence to advertise a feature with no implementation: a permanent gap notice is a promise the product re-breaks once per visit. What replaced it does the job out of what we already hold |

### 11.0 🚨 SURFACES REMOVED ON 2026-08-15 — the mock still shows them

One live-testing sitting with Ted removed several controls the mocks specify.
They are listed here because the mock is the shapes and this file is the rules,
and the next person building from `docs/design/taegeuk/index.html` will
otherwise put them back. The full argument for each is a comment at the removal
site in the code; the short version and the rule are here.

**The test every one of them failed: what does this ask the reader to do, and
can the app answer it?**

| Surface | Mock | Why it is gone |
|---|---|---|
| S-21's `Entry · Closed · Allow` fact tiles | screen 4 | Mapped onto `Price · Closed · Payment`. `google_price_level_text` is null on 100% of a 1,000-row sample of `attractions` and `restaurants`; `Closed` renders 정기휴무일 ONLY, whose one writer never runs (below). Three tiles under the venue name, two permanently `Unknown` and the third — `CARDS` on an open-air plaza — sourced and meaningless. |
| `TAKES FOREIGN BOOKINGS` row | S-21 `What we know` | 🚨 `foreignBookable`'s only writer is `ReferenceDao._applyEnrichment`, called only when a synced place row carries an `enrichment` object — and the synced view `dani_ko_clipping_places` **has no such column**. The row said *"We don't know if this takes foreign bookings"* on 100% of places, permanently. The same dead branch is why `closure_rule` and `reservation_system` are never populated. |
| `OPENING HOURS` row | S-21 | Reaches us only through `PlaceRecordFetcher`'s Google columns, null on the same sample. |
| Shelf chips on the clip sheet (S-20) | S-20 | PRD §10 rule 2 already said *"shelf is a pre-filled suggestion, never a question"* — and the sheet asked it in its widest row, above what the user came to say. |
| The shelf eyebrow on item detail (S-26) | S-26 | Same vocabulary, most prominent slot on the screen. |
| `Where it is at` — status + shelf mover (S-26) | S-26 | Nothing in the app READ `status`: no list filtered on it, no badge showed it. Bookkeeping with no reader. |

🚨 **§0.3 is not weakened by this.** §0.3 says an honest gap must be spelled out
rather than hidden — it does not ask for a FIXED FRAME that advertises the same
three absences on every place in the app. The facts we do hold still render, in
rows that exist only when there is something in them.

🚨 **The columns are all untouched.** `status`, `price_band`, `foreign_bookable`,
`closure_rule` are still in the schema and still parse. Restoring any of these is
a widget, not a migration — and for the bookings row, a server-side change to the
view first.

### 11.0b The time picker is a GRID, not a wheel — 2026-08-15

`TripTimeWheel` (K28) keeps its name and no longer keeps its shape. Two
`ListWheelScrollView` columns became 24 tappable hour chips split under two
headings, `MORNING · AM` and `AFTERNOON & EVENING · PM`, plus four minute chips.

🚨 **The split is the whole point and it is a correctness fix, not a style one.**
On a wheel, `07` and `19` are the same gesture twelve stops apart and identical
at rest — a flick that overshoots by half a turn lands on the wrong half of the
day silently. Ted: *"it is easy to make a mistake and add an event to an am time
when the user wanted a pm time (7am instead of 7pm)."* Under two headings that
mistake stops being a slip of the thumb and becomes reading the wrong label.

🚨 **24-hour remains the primary form** (§5's argument: a visitor reading a
Korean booking confirmation is reading 24-hour). The readout prints `19:00`
beside `7 PM`; the AM/PM is an ANNOTATION in the muted role, there to prevent an
error, not to become the format. Noon and midnight are NAMED rather than written
`12 PM` / `12 AM`, which is the one place the twelve-hour clock is itself
ambiguous.


### 11.1 The basemap rule — **SPLIT 2026-08-14. Trip keeps it; Places does not.**

#### Trip surfaces (S-31, the group map): still no basemap

`TripMapBox`. Points at their real coordinates inside the bounding box of the
set, longitude scaled by `cos(latitude)` so east–west distance is not stretched
(a 21% error at Seoul's 37.5°N), with a scale bar. **No streets, no landmarks,
no tiles.** A trip must open in airplane mode; that has not changed.

#### The Places tab: OpenStreetMap tiles, zoom, pan, you-are-here

`PlaceTileMap` (`features/places/presentation/widgets/`). Ted asked twice —
*"maptile still not rendered, cannot zoom, move"* — and the two original reasons
did not survive being re-examined for THIS surface:

- **"A tile layer needs a network."** True, and the property is kept rather than
  traded away: when tiles cannot load, `FlutterMap` paints its background and
  the markers still plot at their real coordinates — which is the basemap-less
  canvas §11.1 asked for. Offline is now one map's degraded state instead of the
  only map.
- **"Korea prohibits exporting detailed map data to foreign servers."** That is
  why ROUTING hands off to Naver, and it still does. Rendering openly-licensed
  OSM tiles exports nothing. The rule was over-applied.

Binding on the new map:

- **Attribution is drawn on the map, always** — OSM's licence requires it.
- **Marks are circles**, per §11.3 — and it is also the engineering answer: a
  teardrop anchors at its tip and needs a half-height offset, so circles cannot
  drift as you zoom.
- **A mark carries its kind's glyph in its kind's colour**, both taken from
  `PlaceKind` — the same icon and the same colour the row for that place draws
  in the list below. One value, two drawing sites: a mark and its row are one
  thing to the reader, and two palettes would be two ways to be wrong.
- **The colours spread WITHIN a family, not across families.** Every place on a
  shopping guide is a shop, so one colour per family would give a whole screen
  one colour and answer nothing. Families reuse hues between screens, which
  costs nothing — no screen shows two families at once.
- **A place whose kind is unknown falls back to blue**, and to the generic pin.
- **You-are-here is `live`.** Never a kind colour, and never blue — the person
  is not one more pin.
- 🚨 **No `cameraConstraint`.** flutter_map 7.0.2 ASSERTS on every options
  change that the camera already satisfies it rather than clamping, and options
  change on every rebuild — a keystroke in the search box took the screen down.
  Both `contain` and `containCenter` trip it. The recentre button covers what it
  was guarding.

### 11.2 ~~The "floating pill" on S-18 is not a pill~~ — **STRUCK 2026-08-05**

It was not a pill because DDS §4 prohibited pills. §4 now specifies pills as the
tag and button shape. **It is a pill.**

### 11.3 ~~A status dot is a circle, and that is the one exception~~ — **STRUCK**

There is no longer a no-circles rule to except. Checkboxes, status dots and map
points are all circles.

### 11.4 The handover surface sizes to the viewport, not to the type ramp

**Narrowed 2026-08-06 (P5): it is one component now, not three surfaces.**
SPEC-03 §4.1 requires the handed-over surfaces to scale beyond 200% and to
support landscape, and a fixed size cannot do both. Still the only place in the
app that does this — and there is exactly one of it.

🚨 **CORRECTED 2026-08-08, and the old rule was a lie on every phone.** It read
`MediaQuery.sizeOf(context).width / 11`, which on a 390pt phone is **35.5pt**
against the block's own 31 — a 14% step nobody can see. The 68pt ceiling needed
a **748pt-wide** viewport to bind, so no phone ever reached it. Ted, holding the
build: *“Provide FULL SCREEN MODE. BIG FONTS!!”*

Two things were wrong:

1. **It measured the WINDOW, not the box the Korean gets.** The safe area, the
   screen padding and the action row all come off first, and in landscape the
   HEIGHT is the binding constraint the window never mentions. It now reads a
   `LayoutBuilder` inside the `Expanded`. 🚨 This also hid the bug from the test
   suite: `setSurfaceSize` changes what the tree is laid out in and leaves
   `MediaQuery` at the harness default of 800×600, so a window-sized component
   measured 800 at every viewport in tests and something else on a phone. Any
   test that asserts a viewport-derived size must pass `MediaQuery` explicitly.
2. **The formula was arbitrary.** It is now a stated measurement: the lead is at
   most **a fifth of the available width** — five Korean glyphs to a line, which
   is what a place name needs at the ~80cm of a car seat — and at most **0.26 of
   the available height**, which is the landscape overflow guard. Ceilings are
   96/52. The second line derives from the lead so it holds `.hand`'s own 17/31
   ratio rather than running a second formula that can drift.

Measured, before → after:

| Viewport | Before | After |
|---|---|---|
| 390×844 | 35.5pt | **70.0pt** (8.3% of viewport height) |
| 430×932 | 39.1pt | **78.0pt** |
| 320×568 | 31.0pt | **56.0pt** |
| 844×390 (landscape) | 68.0pt | **68.6pt** — held, and now height-bound rather than by luck |
| 820×1180 | 68.0pt | **96.0pt** |

No viewport scrolls on ordinary content, landscape included at 81pt of inset —
more than landscape ever takes. Past 200% dynamic type it scrolls and does not
clip; the scroll view is the valve, not the plan. **The BLOCK is untouched:**
31/17 is the mock and stays the mock.

### 11.4b Every handover block reaches full screen in one tap

Added 2026-08-08. The address card and a phrase **are** full screen and place
detail's driver block has had a `FULL SCREEN` tag since it was written — but the
allergy card and both Emergency blocks had **no way there at all**, while
`_AllergyLink` promises *“a full-screen card in Korean”* and `_NothingToShow`
promises *“this becomes a full-screen Korean card”*, on two separate screens,
about a card no tap could enlarge.

`DaniKoHandover.expandable` is a knob on the one component, not a button each
screen builds, and it opens the **same** component with `fullScreen: true`.
🚨 **A fullscreen dialog, not a route** — `test/router/route_set_test.dart` holds
the route set Ted approved on 2026-08-06, and a handover block is not a
destination. Refused for machine output, and absent when already full screen.

### 11.4c A `phrase_table` row IS a handover surface — tap it

Added 2026-08-10. Ted, on a card's *Say it in Korean* band: *“These are very
important helpful feature. They should full screen presentable cards to Korean
people. When clicked, it should popup a full screen card, with large korean
fonts (english below it), and there should be a button that reads it with tts.
of course user should be able to bookmark (or save it) it to my trip area.”*

The band is a list of things you say to somebody, and until this it was a
reference you copied from — three lines tall, read at body size across a
counter. A row now opens `showDaniKoHandover()`, which pushes the **same**
`DaniKoHandover` the address card and the driver block use. Not a second
large-Korean page: **70.0pt / 8.3% of a 390×844 viewport, identical to §11.4's
measured baseline, because it is the same component.** Measured on the built
page in both modes.

🚨 **The provenance decides, per row, and 18 rows do not get it.** The corpus
ships 1,591 phrase rows over 279 cards and all 1,323 distinct Korean sentences
were read on 2026-08-10 (`tool/phrase_review.json`, five axes, §12 below and
`phrases.dart`'s header). 1,573 rows are `ai_confirmed` and hand over. The rest
are TEMPLATES — `저는 ___ 알레르기가 있어요`, `[이름]으로 예약했어요`, `유심/이심` —
and at display size they do not say what the English says, so they stay plain
rows carrying `machine output — not for handing over`, with no way in and no
voice. **That is a correct outcome, not a gap**, and
`test/lint/phrase_provenance_ratchet_test.dart` holds it as a ratchet that may
only fall.

🚨 **75 confirmed rows hand over and are SILENT** — the `1330` rule, already
settled: a ko-KR voice reads digits as cardinals and Latin letters as letters,
so any Korean carrying either is `speak: false`. A second axis, never a lower
tier.

**Save** is on the full screen, not on the row: a row already carries five
things and a sixth on all 57 of them is the density the fold exists to fight.
It opens the ordinary clip sheet with `ClipTarget.phrase`, so the write goes
through `TripDao` — the only write that syncs — onto shelf 15, keyed by the
KOREAN so double-add is idempotent across two cards that word the English
differently. A visitor with no trip gets the sheet's own one-field creator.

### 11.5 🚨 Horizontal rows must accept a mouse drag — **still in force, five fixed, three unfixed**

Flutter's default `ScrollBehavior` leaves `PointerDeviceKind.mouse` out of
`dragDevices`, so on web and Windows a mouse drag never reaches the `Scrollable` —
it falls through to the page's `SelectableRegion` and selects text while the row
sits still. S-10's fourth chip was **permanently unreachable** at 420px because
of this. `DaniKoDragScrollBehavior`
(`lib/design_system/widgets/dani_ko_drag_scroll_behavior.dart`) fixes a row; a
global override would let a mouse drag every vertical `ListView` and break
drag-to-select app-wide.

**Fixed:** S-10's filter chips, `DaniKoMasonry` (R1.8) at construction,
S-32's checklist filter pills (`ChecklistFilterRow`, R2.4 — four tappable pills
measure past 350px, so `IN KOREA` starts clipped on an iPhone), S-21's
`PlaceHeroCarousel` (R2.5), and S-26's shelf mover (`ShelfMoverRow`, R4.1).
🚨 A carousel is the worst case of the defect: its page dots are an
**indicator, not a control**, so without the behaviour every photo after the
first is unreachable on web and Windows — not clipped, gone. The shelf mover is
the worst *geometry*: **ten** chips at the 44px floor, so at 360px everything
past the third shelf was unreachable, and a misfiled item is the one change
F-11 gives an undo toast for.
**Three still carry the same latent defect:** the block comparison table, the
clip sheet, and the resource library. Fix each the same way, and add it to
`test/widget/horizontal_rows_drag_test.dart`, which is the register of every
horizontal scroller in the app.

### 11.6 🚨 The ⊕ capture sheet deviates from caption 7 in exactly two places

> ✅ **It has an invocation again, 2026-08-07.** The ⊕ that opened it was
> deleted on Ted's complaint and nothing replaced it, so for two sessions the
> sheet was built, documented and covered by a test that pumps the widget
> directly — a suite green with four intake routes unreachable from every screen
> in the product. `DaniKoKeepButton` is the home: a **labelled** `+ Keep` beside
> the pinned field on the Trip, Korea and Saved roots.
>
> 🚨 **The problem was never the plus.** It was a NAVIGATION CELL carrying a
> symbol, in a row of labels, doing something that was not navigation. A labelled
> control beside a field is the object every other app calls compose, and PRD
> §10 rule 1 — *the `+` is everywhere and always means the same thing* — is why
> the glyph itself stays. It is deliberately NOT back in the bar: the bar has
> three cells and every one of them is a place.

Landed 2026-08-06. `v2-1-shell.html` caption 7 is otherwise reproduced line for
line — "Keep this.", the focused mono paste box with the mock's placeholder, the
clipboard chip, the three tiles with their captions, "It also arrives without
you", and the "Afterwards" paragraph. Two things differ, both on purpose:

1. **The strip of four recent screenshots is a `DaniKoGap`, not four thumbnails.**
   Nothing in the tree can enumerate the camera roll: `image_picker` opens a
   picker and returns one file, and listing the library means a new package plus a
   full photo-library permission. Four tiles would have been a claim about the
   user's phone, so the strip says what is missing and opens the picker instead.
   The habit the mock is meeting — *"people already screenshot their bookings"* —
   is still named underneath it. Revisit if the library permission is ever taken.
2. **The tab bar is covered by the sheet; the mock draws it on top with the ⊕
   pressed.** `showModalBottomSheet` resolves to the app's root navigator, so
   keeping the bar visible means a bespoke non-modal overlay. The pressed ⊕ is
   caption 1's sub-frame *e* and is already built; the bar being visible behind
   the sheet carries no information the sheet does not.

🚨 **And one thing the mock draws that must NOT be reproduced literally:** the
dashed row titled `"Screenshot, 2 Mar"`. That is a worked example, and a row with
that title in a shipped app is an item the user never captured. It renders as the
RULE — *"A screenshot we cannot read still lands"* — with the mock's explanation
intact.

### 11.7 🚨 The day page deviates from caption 2 in three places

Landed 2026-08-07. `v2-1-shell.html` caption 2 is otherwise reproduced band for
band — all seven, in its order, with its copy. The mock's `.anno` blocks are
commentary about the design and are deliberately not rendered as UI. Three real
deviations:

1. **Band 6's `OPEN THE WEATHER APP` chip is not built.** iOS will not answer
   `canLaunchUrl` for a custom scheme until it is declared in
   `LSApplicationQueriesSchemes`, Android publishes no equivalent scheme, and web
   has none — so on two of three platforms the chip would be a control that
   silently does nothing, which is exactly what `DaniKoGap`'s own contract forbids
   ("a dashed block that swallows a tap teaches the user the screen is broken").
   The gap's sentence already points at the phone's own weather app, which is the
   useful half. Revisit if the iOS plist entry is ever taken.
2. **Band 4's marked row says `HAPPENING NOW`, not `YOU ARE HERE`.** The mock's
   wording is a location claim, and this band has no fix — the mark is derived
   from the window the USER saved. Same mark, honest wording. §9's rule that colour
   is never the sole signal is why the wording matters rather than only the dot.
3. **Band 5 has no `CASH LEFT` row.** There is no `cash` key in the fact registry
   (`core/domain/trip_facts.dart`), so there is no way for a user to tell us that
   figure — and a dashed gap for a field the product offers no way to fill is a
   promise, not an honest gap. It becomes a row, with the transport card's "what
   you last told us" treatment, on the day the registry gains the key.

🚨 **And two things the mock draws that are correct and must not be "fixed":** the
`MOVE IT` / `LEAVE IT` chips on a closure row are not built (the row opens the item
instead — a chip that reschedules is a planner action and §1.1 excludes the
planner), and the transport-card row keeps the DASHED gap treatment even when a
figure is present, because what is unknown is not the figure the user typed but the
balance on the card right now.

### 11.8 🚨 The Daily schedule's date controls are TWO FACTS, not four verbs

Landed 2026-08-14. `flows 0.2` draws the days screen with the date controls as a
stack of secondary buttons and the build reproduced it: `＋ Add a day at the
start`, `＋ Add a day at the end`, `Move the whole trip`, `Change your dates`.

Ted: *"add a day to end/start, move the whole trip, change your dates currently
has the ugliest, least thoughtful, bad ui."* All four are gone.

- **The `＋` is a cell at the end of the date strip**, dashed, reading `ADD` and
  `＋`. The row of dates IS the list of days, so the affordance to extend it
  belongs at the end of that row. It is deliberately not drawn as a day: a solid
  cell in a row of dates reads as a date, and this one has none yet.
- **Everything else is one sheet** — `trip_dates_adjust_sheet.dart` — reached
  from a `Change` action beside the range, or by holding the first date. It asks
  two questions: **when does it start** (moving it moves the whole trip) and
  **how many days** (growing appends, shrinking removes from the end). All four
  old buttons are positions in that pair; `add a day at the start` is start −1
  and days +1, visible as you do it.
- **Its live preview replaces three of the four confirms.** The resulting range
  and every consequence are on screen at the moment `Update dates` is pressed.
  The exception is the **shrink**, which keeps a dialog because it is the one
  action reversing the control does not undo — the days come back empty.
- **The calendar behind it picks ONE date and writes nothing.** A range picker
  there was a trap: `ensureDaysForDates` only ADDS, so moving a 16–23 Oct trip to
  1–8 Nov left the October days alive carrying every event.

### 11.9 🚨 A weekday is an annotation: `15 Nov 2025 (Sat)`

Landed 2026-08-14, and it is §5's rule applied to dates. Ted: *"the day of the
week should be shown in parenthesis and use standard format."* The thing leads,
the gloss follows in brackets — exactly as `Gyeongbokgung Palace (경복궁)` does.
`tripFullDate` in `trip_root_state.dart` is the only implementation; `Sat 15 Nov`
existed in four private copies and all four are deleted.

The year rides along wherever a surface can MOVE a trip, and once only where both
ends share it: `16–23 Oct 2026`, `28 Dec 2026 – 3 Jan 2027`.

### 11.10 🚨 An absent thumbnail draws the KIND of thing, not a missing picture

Landed 2026-08-14. `TripThumb` drew `?` on `surface2` wherever there was no
photograph, which is most rows. Ted: *"what are these ugly things to the left of
the places. For all Places and Events, use ICONS that shows the type of
place/events."*

It answers the wrong question — *do we have a picture?* — twelve times down one
screen, in the slot the eye is using to ask *what is this?*

- The glyph is `glyphForShelf`, the vocabulary the clip sheet and the item row
  already use, so one place carries one glyph everywhere it appears.
- 🚨 **This does not weaken §6.3.** `Icons.photo` / `Icons.broken_image` /
  `Icons.image` stay banned and the lint still fails the build on them — those
  depict a missing PICTURE. This depicts the THING.
- `?` survives for a row whose kind is unknown too — an unresolved saved place.
- **`ShelfRouter.forPlace` gained a `name` fallback** because the clipping-places
  view carries no `category_code`: every place arriving through it filed under
  the catch-all and drew a ticket. The name is matched only when the code says
  nothing, so a real code always wins.

### 11.11 🚨 The rail's spine runs node to node, and it is drawn per row

Landed 2026-08-14. It was one `Positioned(top: 6, bottom: 14)` behind the whole
stack — two constants standing in for "stop at the nodes", which cannot work
because a row's height depends on whether it carries a Korean name and a booking
reference. Ted: *"do not draw line above the first circle and the last
circle!!!"*

Each row now draws its own segment, so the geometry is exact by construction. A
day with ONE thing on it has no spine — there is nothing to connect. The node is
centred on the line by arithmetic: 1px line at x=51 → centre 51.5; 9px node →
left edge 47; rows inset 60 → offset −13. It was −10.

**`Date not assigned` uses `railed: false`** — no line, no node, no time gutter.
All three marks mean *when, in order*, and every one is a lie above a list of
things with no date.

### 11.12 🚨 A sheet clips to its shape — every sheet, from the theme

Landed 2026-08-14. `bottomSheetTheme` rounded sheets to `DdsRadii.hero` and left
`clipBehavior` at its `Clip.none` default, so any child that painted a background
painted straight over the corner. Ted, with a screenshot: *"the slide ups (not
only this one but all) have this weird corners!!"*

Fixed at the theme rather than per sheet: eleven callers use
`showModalBottomSheet` directly and only one goes through `showTripSheet`. That
one also carried its own 26pt radius against the theme's 22 — two corner radii
for the same object — and drew a square-cornered hairline across the top. Both
removed.

### 11.13 🚨 An item is an EVENT CARD, and the venue behind it slides up

Landed 2026-08-14. Ted, against dani.go: *"when a place card is clicked, show the
same information as dani.go's event card"* and *"if a place is attached, clicking
on the place card inside the event details should show the place details slide
up."*

dani.go's card answers **when · where · what is booked** in one glance. S-26
answered none of the three: it opened on names, badges, a note and a shelf mover,
and the only route to the venue was a secondary button reading `Open the place`.

- **Three fact blocks, in that order.** *When* (date · time · how long), *Where*
  (the place card), *What is booked* (reference · deadline · price · payment).
  They are `TripFactRow`s, so `unknown` is a sentence rather than a blank.
- 🚨 **The date reads the DAY, not `whenStart`'s date part.** The two agree
  whenever a time is set; where they cannot — an item on a day with no time —
  only the day knows, and the timestamp would print "no date" for something
  visibly sitting on Thursday. The whole block opens the time sheet, the same
  one the schedule row's clock opens.
- 🚨 **`What is booked` renders NOTHING when all four are absent** — the one
  place the kit's *unknown-is-a-state* rule is deliberately broken. A walk
  through a neighbourhood has no booking reference, and four "not known yet"
  lines on the majority of items is noise, not honesty.
- **A place is attached under either `refId` or `placeRef`.** The first is the
  item that IS a clipped venue; the second is the venue a booking HAPPENS AT. A
  hotel confirmation is the second kind — the item most likely to need a
  driver-facing address, and the one reading only `refId` would leave bare.
- **The venue is a SHEET, not a push** (`place_detail_sheet.dart`). It renders
  `PlaceDetailView` — the body `/place/:id` renders, not a summary of it — so
  the page and the sheet physically cannot disagree about a venue. The reason to
  open it (*where is this, is it open, what do I show a driver*) is a glance, not
  a destination, and a push makes the pair feel like a chain to walk back out of.
- 🚨 **An arrow on a page, an × in a sheet.** `PlaceHeroCarousel` takes
  `backIcon`/`backTooltip` for exactly this. An arrow over a sheet promises the
  screen behind it is somewhere you *were*; it is the screen the sheet is still
  sitting on.
- 🚨 **The sheet removes the top inset** (`MediaQuery.removePadding`). §8b has
  the carousel bleed under the status bar and GROW by that inset, which is right
  when the first pixel of the photograph is the first pixel of the screen. In a
  sheet it is 8% of the way down and `MediaQuery` still reports the same inset —
  left alone, the picture grows by a status bar nowhere near it.
- `Open the place` is **deleted**. The place card is the same door and names the
  venue before you touch it; two controls onto one destination teaches the reader
  that a screen's buttons are decorative.

### 11.14 A drop SELECTS the day it landed on

Landed 2026-08-14, one line of Ted: *"when drag dropped, the dropped date should
be selected!!!!"*

Hovering a date already selected it (§11.8's strip), but the drop is the moment
the decision is made, and two cases hovering cannot cover: the `＋` cell and any
date the trip does not reach have **no day to select** until `dayForDate` creates
one on Save, and the user can change the day inside the time sheet after
dropping. So `showDayTimeSheet` now returns the id of the day the item LANDED on
— not the date it was dropped on — and the schedule under the strip follows it.
A cancelled sheet, or one that detaches the item, returns null and moves nothing.

---

## 12. What was NOT adopted

`docs/design/` keeps every proposal as reference.

| Proposal | Verdict | Worth revisiting |
|---|---|---|
| **A · Postcard** (Pinterest + Airbnb) | Layout **adopted wholesale** into D | — |
| **B · Compass** (M3 Expressive + HIG) | Not adopted | Filled tonal containers made status unmissable. Borrow if red-means-deadline proves too subtle |
| **C · Passport** (Duolingo + Monzo) | Readiness spine **adopted** into D | The per-shelf colour system, and the passport stamp for completed items |
| **A · Ledger** (2026-07-28, superseded) | Retired | Its provenance discipline survives in §6.2 |
| B · Passport / C · Atlas (2026-07-28) | Retired | C's cluster canvas, if S-31 ever gets revisited |
