# Creator Gap Closure — Phase 3 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Give a creator a way back into their account when they cannot log in, and finish the one channel type the prototype cannot actually connect.

**Architecture:** All work lands in `creator/main.dc.html`. Auth screens are gated on `s.auth` (`splash` → `login` → `signup` → `terms` → `welcome` → `connect`/`analyzing`/`draft` → `live`); in-app screens are gated on `s.route`. Flags and handlers are properties on the object returned from `Component`'s render method. This phase adds five new `s.auth` states and one new route.

**Tech Stack:** Plain HTML + CSS custom properties + the `x-dc` template runtime. Tokens in `creator/_ds/startview-design-system-*/tokens/*.css`. Local server: `python3 server.py`, port 8123.

**Spec:** [`docs/specs/2026-08-19-creator-prototype-gap-closure-design.md`](../specs/2026-08-19-creator-prototype-gap-closure-design.md) §5 Phase 3 — **superseded in part by "Scope correction" below.**

---

## Scope correction (read before starting)

Every spec item was checked against the code *and* against the repo's prior decision records before this plan was written. Two of the four were already implemented.

| Spec item | Reality | Verdict |
|---|---|---|
| Password reset | The login screen's `비밀번호를 잊으셨나요?` (line ~184) is a bare `<span>` — not a button, no handler, no destination | **Real gap — Tasks 1–2** |
| Find email | Zero references anywhere in the file | **Real gap — Task 3** |
| Channel theme / category selection sheet | `pickerSheet:'topic'` + `openTopicSheet()` (~6950) already exist, reachable from `주제 추가` (~494) and 프로필 편집 `채널 주제` (~2343), capped at 3 with the copy "브랜드가 이 주제로 나를 찾아요" | **Already done — dropped** |
| Per-channel disconnect | Already exists: `해제` control (~1445) wired to `pc.onDisconnect` (~7491) | **Already done** — but it fires immediately with no confirmation, which is Task 5 |

Two further real gaps were found that the spec did not list, and they are included: the connect screen cannot register a Naver Clip channel (Task 4), and a freshly connected channel has no "collecting" state (Task 5).

**Deliberately not planned:** importing a channel's profile photo as the creator's own (a small confirm dialog in the legacy app, zero references here, marginal value in a prototype). Raise it later if it turns out to matter.

### Decision records consulted

`docs/specs/2026-08-11-cross-role-consistency-design.md` was checked for anything that retires or excludes this phase's subject matter. **Nothing in it touches auth recovery or channel registration** — unlike 수요조사 (§5.3, retired) and 공동구매/어필리에이트 (§10, excluded), which is why those were cancelled. §3 of that spec also says *"Creator wins on flow and UX"*, so the creator prototype is free to define these journeys rather than mirror the legacy Angular app.

---

## Global Constraints

- **All edits in `creator/main.dc.html`.** No new files. Nothing under `startview-platform`.
- **Branch:** `creator-phase3`.
- **No test runner exists.** "Verify" means serve the prototype and observe it. Never write pytest/jest steps.
- **Commit messages Korean** (`feat(creator): …` / `fix(creator): …`). No AI attribution, no `Co-Authored-By`, no mention of Claude or AI.
- **Colors/spacing/radius from CSS custom properties only.** Hardcoded hex is a defect except `#fff` on dark button fills.
- **Korean copy**, `class="sv-kr"` on Korean text that *wraps*. Short button labels are conventionally bare — do not add `sv-kr` to them.
- **Reuse Phase 0 primitives.** Canonical instances live in the dev gallery (`route:'devStates'`). A near-copy that drifts in padding or structure is a defect; that failure was caught three times in earlier phases.

### Verified codebase idioms — do not deviate

1. Back handler is `back`. There is no `goBack`.
2. No `disabled` attribute anywhere. Guard inside the handler (`if (blocked) return;`) and swap the button's background token, as `submitPayout` and `doLogin` do.
3. `<sc-for list="{{ rows }}" as="row" hint-placeholder-count="N">`. There is no `each=`.
4. Event handlers take the event: `(e) => … e.target.value`.
5. Partial interpolation inside a style attribute works (`style="…;background:{{ x }}"`); whole-attribute object binding (`style="{{ someStyle }}"`) is also used.
6. `this.after(ms, fn)` is the timer helper — never a bare `setTimeout`.
7. Destructive actions go through `askConfirm({title, body, reversible, confirmLabel, onConfirm})`.
8. Input errors follow the blur convention: `markTouched(name)` on blur, `clearTouched(name)` on focus, and the error string returns `''` until `s.touched[name]` is true.

### Facts this plan relies on (already verified — do not re-derive)

- `ACCOUNTS` (~line 5017) is a map keyed by email with exactly two entries: `existinguser@test.com` (보미, onboarded, 2 channels) and `newuser@test.com` (new, no channels).
- `doLogin` (~6746) guards on empty fields and sets `loginErr`; `loginErrorText` (~6740) names both demo accounts.
- `s.loginEmail` holds whatever was typed on the login screen — this is the prefill source for password reset.
- The login field placeholder is `이메일 또는 아이디`, so "find email" means recovering the account's email address.
- A channel object is `{ id, platform, handle, followers, price, price2 }`.
- The connect screen (~2539) lists platforms with `c.onToggle`; `connectDone` (~7102) guards on `s.channels.length`.

---

## How to verify anything in this plan

Start the server once from the repo root:

```bash
python3 server.py
```

Then for every check:

1. **Byte parity** — `curl -s http://127.0.0.1:8123/creator/main.dc.html | wc -c` must equal `wc -c < creator/main.dc.html`. Use `127.0.0.1`, never `localhost`: a stale IPv6 listener on this port has served a different working copy three times in earlier phases.
2. Navigate to `http://127.0.0.1:8123/creator/main.dc.html?cb=<number>` — the cache-buster is required.
3. First `javascript_tool` call after navigating, paste the accessor:

```js
(function(){
  window.svLogic=function(){const host=document.querySelector('[data-sc-name="main"]')||document.body;
    const key=Object.keys(host).find(k=>k.startsWith('__reactFiber$'));let f=host[key];
    while(f){if(f.stateNode&&f.stateNode.logic&&f.stateNode.logic.setState)return f.stateNode.logic;f=f.return;}return null;};
  var l=window.svLogic(); l.setState({auth:'login'}); return {ok:!!l};
})()
```

4. **Read the DOM in a SEPARATE call.** `setState` re-renders asynchronously; a same-call read returns the stale tree.
5. **Assert on rendered element text, never `innerHTML.indexOf`** — the page's own `<script>` source sits inside `document.body`, so any Korean string that is also a JS literal false-positives:

```js
Array.from(document.querySelectorAll('span,h1,h2,p,button,label,a,input')).some(n=>(n.textContent||n.placeholder||'').includes('<korean>'))
```

6. **The console accumulates across same-origin navigations.** An error you see may be minutes old and already fixed. This cost real time in Phase 2 — a stale `TypeError` was mistaken for a live crash and chased through a full bisect. Before believing any console error: reload with a fresh cache-buster, read the console *immediately*, and if it still appears, confirm it reproduces from a clean pane. `read_console_messages {"onlyErrors":true}` — `/api/comments` 404s are pre-existing polling noise and are always ignorable.

**Known pre-existing, not yours to fix:** a react-dom crash if `channels` is seeded with an incomplete shape (missing `platform`/`handle`); and a duplicate `draftBio` key (~6682 vs ~7096) that makes an AI-rewrite handler unreachable.

---

## File Structure

One file. Five regions:

| Region | Approx. lines | Contents |
|---|---|---|
| `<style>` | 1–75 | Global CSS, keyframes, `.sv-*` utilities |
| Auth markup | 130–660 | login, signup, terms, welcome, analyzing, draft, live |
| In-app markup | 660–4620 | every `<sc-if>` route screen, dev gallery ~4350–4460 |
| Seed data | 4700–5330 | `OFFERS`, `ACCOUNTS`, `SEED`, `REGIONS`, status maps |
| `class Component` + render | 5330–end | `state`, navigation, `askConfirm`, `patchApp`; render object from ~5900 |

Tasks 1–3 touch the auth markup and the auth flag block (~6009–6023). Tasks 4–5 touch the connect screen (~2539) and the channel render block (~7480–7495).

---

## Task 1: Password reset — request screen

Turns the dead affordance into a real entry point. Per the Resetting password checklist the reset link sits next to the password field (it already does) and the request screen prefills the email already typed, so the creator does not retype it.

**Files:**
- Modify: `creator/main.dc.html` — `state` (~5322), auth flags (~6023), login markup (~184), new markup after the login block

**Interfaces:**
- Consumes: `s.loginEmail`; `markTouched`/`clearTouched` (input-error convention); `ACCOUNTS`
- Produces: auth state `'reset'`; render flags `isReset`, `resetEmail`, `resetNote`, `resetBlocked`, `resetBtnStyle`; handlers `openReset()`, `onResetEmail`, `blurResetEmail`, `focusResetEmail`, `submitReset()`; state `resetEmail`, `resetSent`

- [ ] **Step 1: State the observable check**

Tapping `비밀번호를 잊으셨나요?` on the login screen opens a reset-request screen whose email field is prefilled with whatever was typed on login.

- [ ] **Step 2: Confirm it fails**

```bash
grep -c "isReset" creator/main.dc.html
```

Expected: `0`. Also confirm line ~184 is a bare `<span>` with no `onClick`.

- [ ] **Step 3: Add state**

In `state = {`, on the line holding `loginEmail:''`, append:

```js
    resetEmail:'', resetSent:false,
```

- [ ] **Step 4: Add the auth flag and handlers**

In the render object, immediately after `isLive:` (~6023):

```js
      isReset: s.auth === 'reset',
      resetEmail: s.resetEmail,
      onResetEmail: (e) => this.setState({ resetEmail: e.target.value }),
      blurResetEmail: () => this.markTouched('resetEmail'),
      focusResetEmail: () => this.clearTouched('resetEmail'),
      // 계정이 없는 주소인지는 알려주지 않는다 — 어떤 주소가 가입돼 있는지 흘리는 셈이라서.
      resetNote: !s.touched.resetEmail ? '' : !/.+@.+\..+/.test(s.resetEmail) ? '이메일 주소를 정확히 입력해주세요.' : '',
      resetBlocked: !/.+@.+\..+/.test(s.resetEmail),
      resetBtnStyle: { width:'100%', height:52, borderRadius:'var(--radius-lg)', display:'flex',
        alignItems:'center', justifyContent:'center', font:'var(--type-button)',
        background: /.+@.+\..+/.test(s.resetEmail) ? 'var(--ink)' : 'var(--surface-2)',
        color: /.+@.+\..+/.test(s.resetEmail) ? '#fff' : 'var(--text-tertiary)' },
      submitReset: () => { if (!/.+@.+\..+/.test(s.resetEmail)) return; this.setState({ auth:'resetSent' }); },
```

- [ ] **Step 5: Add the Component method and wire the login affordance**

In `class Component`, after `openStates()`:

```js
  /** 로그인 화면에 입력하던 주소를 그대로 들고 간다 — 다시 치게 하지 않는다. */
  openReset() { this.setState({ auth:'reset', resetEmail: this.state.loginEmail || '' }); }
```

Expose it in the render object as `openReset: () => this.openReset(),`, then replace the bare `<span>` at ~184 with a button carrying the same text and styling:

```html
              <button onClick="{{ openReset }}" style="font:500 13px/1.2 var(--font-body-medium),var(--font-fallback-kr);color:var(--text-tertiary)">비밀번호를 잊으셨나요?</button>
```

- [ ] **Step 6: Add the request screen**

Immediately after the login `</sc-if>` block:

```html
    <!-- ─────────── 비밀번호 재설정 · 주소 확인 ─────────── -->
    <sc-if value="{{ isReset }}" hint-placeholder-val="{{ true }}">
    <div style="position:absolute;inset:0;background:var(--canvas);z-index:40;display:flex;flex-direction:column;animation:sv-push 350ms cubic-bezier(0.32,0.72,0,1)">
      <div style="display:flex;align-items:center;gap:10px;padding:14px 16px 0">
        <button onClick="{{ back }}" style="display:flex"><x-import component-from-global-scope="StartViewDesignSystem_7416ce.Icon" name="arrow-left" variant="line" size="22px" color="var(--ink)" hint-size="22px,22px"></x-import></button>
      </div>
      <div class="sv-scroll" style="flex:1;padding:24px 28px 0">
        <h1 class="sv-kr" style="font:500 22px/30px var(--font-display-medium),var(--font-fallback-kr);letter-spacing:-0.3px;color:var(--ink)">비밀번호를 새로 만들까요?</h1>
        <p class="sv-kr" style="margin-top:10px;font:400 14px/22px var(--font-body),var(--font-fallback-kr);color:var(--text-secondary)">가입한 이메일 주소를 알려주시면 재설정 링크를 보내드려요.</p>
        <div style="margin-top:26px;display:flex;align-items:center;gap:8px;height:52px;padding:0 14px;border-radius:var(--radius-md);background:var(--surface-1)">
          <input value="{{ resetEmail }}" onChange="{{ onResetEmail }}" onBlur="{{ blurResetEmail }}" onFocus="{{ focusResetEmail }}" inputmode="email" placeholder="이메일 주소" style="flex:1;min-width:0;border:0;outline:none;background:transparent;font:400 15px/1.2 var(--font-body),var(--font-fallback-kr);color:var(--ink)">
        </div>
        <sc-if value="{{ resetNote }}" hint-placeholder-val="{{ true }}">
        <p class="sv-kr" style="margin-top:8px;font:400 12px/17px var(--font-body),var(--font-fallback-kr);color:var(--error)">{{ resetNote }}</p>
        </sc-if>
      </div>
      <div style="padding:0 28px 22px">
        <button onClick="{{ submitReset }}" style="{{ resetBtnStyle }}">재설정 링크 받기</button>
      </div>
    </div>
    </sc-if>
```

- [ ] **Step 7: Confirm it passes**

From the login screen, type `existinguser@test.com` into the email field, tap `비밀번호를 잊으셨나요?`, and confirm the reset screen opens **with that address already filled**. Then check the blur convention: clear the field, type `abc`, and confirm no error appears while focused; blur and confirm the error appears; refocus and confirm it clears. Confirm the CTA is inert until the address is valid.

- [ ] **Step 8: Commit**

```bash
git add creator/main.dc.html
git commit -m "feat(creator): 비밀번호 재설정 요청 화면 — 로그인에서 치던 주소를 그대로 들고 간다"
```

---

## Task 2: Password reset — sent, new password, success

The remaining three steps of the checklist. The sent screen must explain what arrives and what to do with it; the success screen must return the creator to their original intent, which is logging in.

**Files:**
- Modify: `creator/main.dc.html` — `state`, auth flags, new markup after Task 1's block

**Interfaces:**
- Consumes: `isReset` and `resetEmail` (Task 1); the `sv-done` success pattern from the dev gallery
- Produces: auth states `'resetSent'`, `'resetNew'`, `'resetDone'`; flags `isResetSent`, `isResetNew`, `isResetDone`, `newPw`, `newPw2`, `newPwNote`, `newPw2Note`, `newPwBlocked`, `newPwBtnStyle`; handlers `onNewPw`, `onNewPw2`, `blurNewPw2`, `focusNewPw2`, `openResetNew()`, `submitNewPw()`, `resetToLogin()`; state `newPw`, `newPw2`

- [ ] **Step 1: State the observable check**

Submitting the request lands on a sent-notice naming the address; a `링크 열기` affordance advances to a new-password screen; setting a valid password lands on a success screen whose CTA returns to login with the address prefilled.

- [ ] **Step 2: Confirm it fails**

```bash
grep -c "isResetSent" creator/main.dc.html
```

Expected: `0`.

- [ ] **Step 3: Add state**

Beside `resetEmail:'', resetSent:false,` add:

```js
    newPw:'', newPw2:'',
```

- [ ] **Step 4: Add flags and handlers**

After Task 1's block in the render object:

```js
      isResetSent: s.auth === 'resetSent',
      isResetNew: s.auth === 'resetNew',
      isResetDone: s.auth === 'resetDone',
      // 프로토타입이라 메일함이 없다 — '링크를 열었다'를 버튼 하나로 대신한다.
      openResetNew: () => this.setState({ auth:'resetNew', newPw:'', newPw2:'' }),
      newPw: s.newPw,
      newPw2: s.newPw2,
      onNewPw: (e) => this.setState({ newPw: e.target.value }),
      onNewPw2: (e) => this.setState({ newPw2: e.target.value }),
      blurNewPw2: () => this.markTouched('newPw2'),
      focusNewPw2: () => this.clearTouched('newPw2'),
      newPwNote: s.newPw.length && s.newPw.length < 8 ? '8자 이상이어야 해요.' : '',
      newPw2Note: !s.touched.newPw2 ? '' : s.newPw !== s.newPw2 ? '비밀번호가 일치하지 않아요.' : '',
      newPwBlocked: !(s.newPw.length >= 8 && s.newPw === s.newPw2),
      newPwBtnStyle: { width:'100%', height:52, borderRadius:'var(--radius-lg)', display:'flex',
        alignItems:'center', justifyContent:'center', font:'var(--type-button)',
        background: (s.newPw.length >= 8 && s.newPw === s.newPw2) ? 'var(--ink)' : 'var(--surface-2)',
        color: (s.newPw.length >= 8 && s.newPw === s.newPw2) ? '#fff' : 'var(--text-tertiary)' },
      submitNewPw: () => { if (!(s.newPw.length >= 8 && s.newPw === s.newPw2)) return; this.setState({ auth:'resetDone' }); },
      resetToLogin: () => this.setState({ auth:'login', loginEmail: s.resetEmail, loginPw:'', newPw:'', newPw2:'' }),
```

Note `newPwNote` deliberately does NOT wait for blur — it is a live strength hint on a field the user is still filling, not a judgment of a finished field. `newPw2Note` (a comparison of two finished fields) does wait for blur, matching the signup convention.

- [ ] **Step 5: Add the sent-notice screen**

```html
    <!-- ─────────── 비밀번호 재설정 · 발송 안내 ─────────── -->
    <sc-if value="{{ isResetSent }}" hint-placeholder-val="{{ true }}">
    <div style="position:absolute;inset:0;background:var(--canvas);z-index:40;display:flex;flex-direction:column;animation:sv-push 350ms cubic-bezier(0.32,0.72,0,1)">
      <div class="sv-scroll" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:0 28px">
        <span style="width:56px;height:56px;border-radius:var(--radius-circle);background:var(--status-review-bg);display:flex;align-items:center;justify-content:center">
          <x-import component-from-global-scope="StartViewDesignSystem_7416ce.Icon" name="mail" variant="line" size="26px" color="var(--ink)" hint-size="26px,26px"></x-import>
        </span>
        <h1 class="sv-kr" style="margin-top:16px;font:500 22px/30px var(--font-display-medium),var(--font-fallback-kr);letter-spacing:-0.3px;color:var(--ink)">메일을 보냈어요</h1>
        <span class="sv-kr" style="margin-top:18px;padding:14px 16px;border-radius:var(--radius-md);background:var(--surface-1);font:400 13px/19px var(--font-body),var(--font-fallback-kr);color:var(--text-secondary)">{{ resetEmail }}</span>
        <p class="sv-kr" style="margin-top:16px;font:400 13px/19px var(--font-body),var(--font-fallback-kr);color:var(--text-tertiary)">메일 속 링크를 열면 새 비밀번호를 정할 수 있어요. 10분 안에 열어주세요.</p>
      </div>
      <div style="padding:0 28px 22px">
        <button onClick="{{ openResetNew }}" style="width:100%;height:52px;border-radius:var(--radius-lg);background:var(--ink);font:600 15px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:#fff">링크 열기</button>
        <p class="sv-kr" style="margin-top:10px;text-align:center;font:400 12px/17px var(--font-body),var(--font-fallback-kr);color:var(--text-tertiary)">프로토타입이라 실제 메일은 오지 않아요.</p>
      </div>
    </div>
    </sc-if>
```

The last line is deliberate: without it a reviewer waits for an email that will never arrive.

- [ ] **Step 6: Add the new-password screen**

```html
    <!-- ─────────── 비밀번호 재설정 · 새 비밀번호 ─────────── -->
    <sc-if value="{{ isResetNew }}" hint-placeholder-val="{{ true }}">
    <div style="position:absolute;inset:0;background:var(--canvas);z-index:40;display:flex;flex-direction:column;animation:sv-push 350ms cubic-bezier(0.32,0.72,0,1)">
      <div class="sv-scroll" style="flex:1;padding:40px 28px 0">
        <h1 class="sv-kr" style="font:500 22px/30px var(--font-display-medium),var(--font-fallback-kr);letter-spacing:-0.3px;color:var(--ink)">새 비밀번호를 정해주세요</h1>
        <p class="sv-kr" style="margin-top:10px;font:400 14px/22px var(--font-body),var(--font-fallback-kr);color:var(--text-secondary)">8자 이상이면 돼요.</p>
        <div style="margin-top:26px;display:flex;align-items:center;gap:8px;height:52px;padding:0 14px;border-radius:var(--radius-md);background:var(--surface-1)">
          <input value="{{ newPw }}" onChange="{{ onNewPw }}" type="password" placeholder="새 비밀번호" style="flex:1;min-width:0;border:0;outline:none;background:transparent;font:400 15px/1.2 var(--font-body),var(--font-fallback-kr);color:var(--ink)">
        </div>
        <sc-if value="{{ newPwNote }}" hint-placeholder-val="{{ true }}">
        <p class="sv-kr" style="margin-top:8px;font:400 12px/17px var(--font-body),var(--font-fallback-kr);color:var(--text-tertiary)">{{ newPwNote }}</p>
        </sc-if>
        <div style="margin-top:12px;display:flex;align-items:center;gap:8px;height:52px;padding:0 14px;border-radius:var(--radius-md);background:var(--surface-1)">
          <input value="{{ newPw2 }}" onChange="{{ onNewPw2 }}" onBlur="{{ blurNewPw2 }}" onFocus="{{ focusNewPw2 }}" type="password" placeholder="새 비밀번호 확인" style="flex:1;min-width:0;border:0;outline:none;background:transparent;font:400 15px/1.2 var(--font-body),var(--font-fallback-kr);color:var(--ink)">
        </div>
        <sc-if value="{{ newPw2Note }}" hint-placeholder-val="{{ true }}">
        <p class="sv-kr" style="margin-top:8px;font:400 12px/17px var(--font-body),var(--font-fallback-kr);color:var(--error)">{{ newPw2Note }}</p>
        </sc-if>
      </div>
      <div style="padding:0 28px 22px">
        <button onClick="{{ submitNewPw }}" style="{{ newPwBtnStyle }}">비밀번호 바꾸기</button>
      </div>
    </div>
    </sc-if>
```

- [ ] **Step 7: Add the success screen**

Copy the canonical `sv-done` block from the dev gallery (`route:'devStates'`, 성공 화면) verbatim and bind it — outcome text first, CTA last. Headline `비밀번호를 바꿨어요`, the middle detail `{{ resetEmail }}`, tertiary line `새 비밀번호로 로그인해주세요.`, flag `isResetDone`, CTA label `로그인하러 가기` with `onClick="{{ resetToLogin }}"`. Do not restyle it; three success screens already share this exact structure and metrics.

- [ ] **Step 8: Confirm it passes**

Walk the whole flow from login: forgot → request → sent (address shown) → 링크 열기 → new password → success → CTA. Confirm the CTA lands on **login with the email prefilled**, which is the checklist's "return them to their original intent". Confirm the confirm-field error waits for blur while the length hint does not. Confirm the CTA is inert until both fields are valid.

- [ ] **Step 9: Commit**

```bash
git add creator/main.dc.html
git commit -m "feat(creator): 비밀번호 재설정 — 발송 안내·새 비밀번호·완료"
```

---

## Task 3: Find email

The login field accepts `이메일 또는 아이디`, so a creator who has forgotten which address they used has no way in. The prototype has KCP identity verification in signup (`suCert`), which is the same mechanism this screen leans on.

**Files:**
- Modify: `creator/main.dc.html` — `state`, auth flags, login markup, new markup

**Interfaces:**
- Consumes: `ACCOUNTS`; the KCP identity idiom used by signup (`kcpBusy`, `suCert`)
- Produces: auth state `'findEmail'`; flags `isFindEmail`, `findBusy`, `findResult`, `findMasked`, `findFound`; handlers `openFindEmail()`, `runFindEmail()`, `findToLogin()`; state `findResult`

- [ ] **Step 1: State the observable check**

The login screen offers `이메일을 잊으셨나요?`; it opens a screen whose identity-verification button reveals the matching account's address, partially masked, with a CTA that carries it back to login.

- [ ] **Step 2: Confirm it fails**

```bash
grep -c "isFindEmail" creator/main.dc.html
```

Expected: `0`.

- [ ] **Step 3: Add state and flags**

State, beside `resetEmail:'',`:

```js
    findResult:null,
```

Render object, after Task 2's block:

```js
      isFindEmail: s.auth === 'findEmail',
      findBusy: s.kcpBusy,
      findFound: !!s.findResult,
      // 전체 주소를 그대로 보여주면 남의 번호로 계정을 캘 수 있다 — 앞 두 글자만 남긴다.
      findMasked: s.findResult ? s.findResult.replace(/^(..)[^@]*(@.*)$/, '$1****$2') : '',
      openFindEmail: () => this.setState({ auth:'findEmail', findResult:null }),
      runFindEmail: () => { if (s.kcpBusy) return;
        this.setState({ kcpBusy:true });
        this.after(900, () => this.setState({ kcpBusy:false, findResult: Object.keys(ACCOUNTS)[0] })); },
      findToLogin: () => this.setState({ auth:'login', loginEmail: s.findResult || '', loginPw:'' }),
```

- [ ] **Step 4: Add the login entry point**

Beside Task 1's `비밀번호를 잊으셨나요?` button, add a second one so both recovery paths sit together:

```html
              <button onClick="{{ openFindEmail }}" style="font:500 13px/1.2 var(--font-body-medium),var(--font-fallback-kr);color:var(--text-tertiary)">이메일을 잊으셨나요?</button>
```

Place the two in the same row with a separator consistent with the surrounding layout; if the row becomes cramped at 375px, stack them rather than shrinking the type.

- [ ] **Step 5: Add the screen**

```html
    <!-- ─────────── 이메일 찾기 ─────────── -->
    <sc-if value="{{ isFindEmail }}" hint-placeholder-val="{{ true }}">
    <div style="position:absolute;inset:0;background:var(--canvas);z-index:40;display:flex;flex-direction:column;animation:sv-push 350ms cubic-bezier(0.32,0.72,0,1)">
      <div style="display:flex;align-items:center;gap:10px;padding:14px 16px 0">
        <button onClick="{{ back }}" style="display:flex"><x-import component-from-global-scope="StartViewDesignSystem_7416ce.Icon" name="arrow-left" variant="line" size="22px" color="var(--ink)" hint-size="22px,22px"></x-import></button>
      </div>
      <div class="sv-scroll" style="flex:1;padding:24px 28px 0">
        <h1 class="sv-kr" style="font:500 22px/30px var(--font-display-medium),var(--font-fallback-kr);letter-spacing:-0.3px;color:var(--ink)">어떤 주소로 가입했는지 찾아드려요</h1>
        <p class="sv-kr" style="margin-top:10px;font:400 14px/22px var(--font-body),var(--font-fallback-kr);color:var(--text-secondary)">본인인증을 하면 그 번호로 만든 계정을 알려드려요.</p>
        <sc-if value="{{ findFound }}" hint-placeholder-val="{{ true }}">
        <div style="margin-top:26px;padding:18px 16px;border-radius:var(--radius-xl);background:var(--status-passed-bg)">
          <span class="sv-kr" style="display:block;font:600 12px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:var(--text-tertiary)">가입된 주소</span>
          <span style="display:block;margin-top:6px;font:600 17px/23px var(--font-body-semibold);color:var(--ink)">{{ findMasked }}</span>
        </div>
        </sc-if>
      </div>
      <div style="padding:0 28px 22px">
        <sc-if value="{{ findFound }}" hint-placeholder-val="{{ true }}">
        <button onClick="{{ findToLogin }}" style="width:100%;height:52px;border-radius:var(--radius-lg);background:var(--ink);font:600 15px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:#fff">이 주소로 로그인</button>
        </sc-if>
        <sc-if value="{{ !findFound }}" hint-placeholder-val="{{ true }}">
        <button onClick="{{ runFindEmail }}" style="width:100%;height:52px;border-radius:var(--radius-lg);background:var(--ink);font:600 15px/1.2 var(--font-body-semibold),var(--font-fallback-kr);color:#fff">본인인증하기</button>
        </sc-if>
      </div>
    </div>
    </sc-if>
```

- [ ] **Step 6: Confirm it passes**

From login, tap `이메일을 잊으셨나요?`, tap `본인인증하기`, and confirm the masked address appears after the delay and the CTA swaps to `이 주소로 로그인`. Tap it and confirm login opens with the **full** address prefilled (masking is for display, not for the field). Confirm double-tapping the verify button does not start two timers.

- [ ] **Step 7: Commit**

```bash
git add creator/main.dc.html
git commit -m "feat(creator): 이메일 찾기 — 본인인증으로 가입 주소 확인"
```

---

## Task 4: Naver Clip channel registration

The connect screen is a platform toggle list. Instagram, YouTube and TikTok can be read by API, but Naver Clip cannot — the review screen already knows this and keeps a URL step for it (`stepUrl: off.platform === 'naver'`, with the copy "네이버 블로그는 자동 연결이 안 돼 주소로 받아요"). Connecting a Naver channel therefore needs a URL, and today there is nowhere to enter one.

**Files:**
- Modify: `creator/main.dc.html` — `state`, connect-screen markup (~2539), channel render block

**Interfaces:**
- Consumes: connect screen's `c.onToggle`; channel shape `{id, platform, handle, followers, price, price2}`
- Produces: route `'clipUrl'`; flags `isClipUrl`, `clipUrl`, `clipNote`, `clipBlocked`, `clipBtnStyle`, `clipGuideOpen`; handlers `openClipUrl()`, `onClipUrl`, `toggleClipGuide`, `saveClipUrl()`; state `clipUrl`, `clipGuideOpen`

- [ ] **Step 1: State the observable check**

Selecting Naver on the connect screen opens a URL-entry screen with a collapsible "how to get the address" guide; saving a valid `naver.me` or blog URL adds a Naver channel to `s.channels`.

- [ ] **Step 2: Confirm it fails**

```bash
grep -c "isClipUrl" creator/main.dc.html
```

Expected: `0`. Also confirm the connect screen currently offers only `c.onToggle` with no per-platform branch.

- [ ] **Step 3: Add state**

```js
    clipUrl:'', clipGuideOpen:false,
```

- [ ] **Step 4: Add flags and handlers**

```js
      isClipUrl: !s.auth && s.route === 'clipUrl',
      clipUrl: s.clipUrl,
      onClipUrl: (e) => this.setState({ clipUrl: e.target.value.trim() }),
      clipGuideOpen: s.clipGuideOpen,
      toggleClipGuide: () => this.setState((st) => ({ clipGuideOpen: !st.clipGuideOpen })),
      // naver.me 단축주소와 blog.naver.com 원주소 둘 다 받는다 — 앱이 주는 건 보통 단축주소다.
      clipNote: !s.touched.clipUrl ? '' : /(naver\.me|blog\.naver\.com|tv\.naver\.com)/.test(s.clipUrl) ? '' : '네이버 주소가 맞는지 확인해주세요.',
      clipBlocked: !/(naver\.me|blog\.naver\.com|tv\.naver\.com)/.test(s.clipUrl),
      clipBtnStyle: { width:'100%', height:52, borderRadius:'var(--radius-lg)', display:'flex',
        alignItems:'center', justifyContent:'center', font:'var(--type-button)',
        background: /(naver\.me|blog\.naver\.com|tv\.naver\.com)/.test(s.clipUrl) ? 'var(--ink)' : 'var(--surface-2)',
        color: /(naver\.me|blog\.naver\.com|tv\.naver\.com)/.test(s.clipUrl) ? '#fff' : 'var(--text-tertiary)' },
      blurClipUrl: () => this.markTouched('clipUrl'),
      focusClipUrl: () => this.clearTouched('clipUrl'),
      saveClipUrl: () => { if (!/(naver\.me|blog\.naver\.com|tv\.naver\.com)/.test(s.clipUrl)) return;
        this.setState((st) => ({
          channels: st.channels.concat([{ id:'nv', platform:'naver', handle:'@bomi_clip', followers:'구독 2,100', price:180000, price2:null, collecting:true }]),
          clipUrl:'', route:null })); },
```

Note `collecting:true` — Task 5 renders that state.

In `class Component`, after `openReset()`:

```js
  openClipUrl() { this.push('clipUrl', { clipUrl:'', clipGuideOpen:false }); }
```

- [ ] **Step 5: Branch the connect screen**

In the connect screen's platform row, route Naver to the URL screen instead of the plain toggle. Read the existing `c.onToggle` construction in the render block first and branch there, so the row's other properties (label, style, connected state) keep working unchanged:

```js
        onToggle: () => (c.platform === 'naver' ? this.openClipUrl() : /* the existing toggle body, unchanged */),
```

- [ ] **Step 6: Add the URL screen with its guide**

```html
    <!-- ─────────── 네이버 채널 주소 등록 ─────────── -->
    <sc-if value="{{ isClipUrl }}" hint-placeholder-val="{{ true }}">
    <div style="position:absolute;inset:0;background:var(--canvas);z-index:34;display:flex;flex-direction:column;animation:sv-push 350ms cubic-bezier(0.32,0.72,0,1)">
      <div style="display:flex;align-items:center;gap:10px;padding:14px 16px 0">
        <button onClick="{{ back }}" style="display:flex"><x-import component-from-global-scope="StartViewDesignSystem_7416ce.Icon" name="arrow-left" variant="line" size="22px" color="var(--ink)" hint-size="22px,22px"></x-import></button>
        <span class="sv-kr" style="flex:1;font:500 17px/23px var(--font-display-medium),var(--font-fallback-kr);color:var(--ink)">네이버 채널 연결</span>
      </div>
      <div class="sv-scroll" style="flex:1;padding:20px 16px 0">
        <p class="sv-kr" style="font:400 14px/22px var(--font-body),var(--font-fallback-kr);color:var(--text-secondary)">네이버는 자동 연결이 안 돼서 채널 주소로 받아요. 앱에서 복사한 주소를 그대로 붙여넣으면 돼요.</p>
        <div style="margin-top:20px;display:flex;align-items:center;gap:8px;height:52px;padding:0 14px;border-radius:var(--radius-md);background:var(--surface-1)">
          <input value="{{ clipUrl }}" onChange="{{ onClipUrl }}" onBlur="{{ blurClipUrl }}" onFocus="{{ focusClipUrl }}" inputmode="url" placeholder="naver.me 또는 blog.naver.com 주소" style="flex:1;min-width:0;border:0;outline:none;background:transparent;font:400 15px/1.2 var(--font-body),var(--font-fallback-kr);color:var(--ink)">
        </div>
        <sc-if value="{{ clipNote }}" hint-placeholder-val="{{ true }}">
        <p class="sv-kr" style="margin-top:8px;font:400 12px/17px var(--font-body),var(--font-fallback-kr);color:var(--error)">{{ clipNote }}</p>
        </sc-if>
        <button onClick="{{ toggleClipGuide }}" style="margin-top:18px;display:flex;align-items:center;gap:6px;width:100%;padding:13px 14px;border-radius:var(--radius-lg);background:var(--surface-1)">
          <span class="sv-kr" style="flex:1;text-align:left;font:500 13px/1.2 var(--font-body-medium),var(--font-fallback-kr);color:var(--text-secondary)">주소 받는 법</span>
          <x-import component-from-global-scope="StartViewDesignSystem_7416ce.Icon" set="ionicon" name="chevron-down" size="15px" color="var(--text-tertiary)" hint-size="15px,15px"></x-import>
        </button>
        <sc-if value="{{ clipGuideOpen }}" hint-placeholder-val="{{ true }}">
        <div style="padding:14px 14px 4px">
          <p class="sv-kr" style="font:400 13px/21px var(--font-body),var(--font-fallback-kr);color:var(--text-secondary)">1. 네이버 앱에서 내 채널을 열어요<br>2. 오른쪽 위 공유 버튼을 눌러요<br>3. '링크 복사'를 고르면 naver.me 주소가 복사돼요<br>4. 여기에 붙여넣으면 끝이에요</p>
        </div>
        </sc-if>
      </div>
      <div style="padding:0 16px 22px">
        <button onClick="{{ saveClipUrl }}" style="{{ clipBtnStyle }}">채널 연결하기</button>
      </div>
    </div>
    </sc-if>
```

- [ ] **Step 7: Confirm it passes**

From the connect screen tap Naver and confirm the URL screen opens rather than toggling. Paste `https://naver.me/abc123` and confirm the CTA activates; paste `https://youtube.com/x` and confirm it stays inert with the error after blur. Save, and confirm a Naver channel appears in the channel list.

- [ ] **Step 8: Commit**

```bash
git add creator/main.dc.html
git commit -m "feat(creator): 네이버 채널 주소 등록 — 자동 연결이 안 되는 채널의 입구"
```

---

## Task 5: Channel disconnect confirmation and collecting state

Two small gaps around channel management. `onDisconnect` (~7491) removes a channel immediately with no confirmation — the only remaining destructive action in the prototype that does not use the shared sheet. And a channel added by Task 4 has no data yet, which nothing currently says.

**Files:**
- Modify: `creator/main.dc.html` — channel render block (~7480–7495), channel row markup (~1445)

**Interfaces:**
- Consumes: `askConfirm({title, body, reversible, confirmLabel, onConfirm})`; `collecting` flag set by Task 4
- Produces: rewritten `onDisconnect`; flags `collecting`, `collectLabel` on channel rows

- [ ] **Step 1: State the observable check**

Tapping `해제` opens the shared confirm sheet; dismissing changes nothing; confirming removes the channel. A channel marked `collecting` shows a "수집 중" note instead of follower figures.

- [ ] **Step 2: Confirm it fails**

Read the current `onDisconnect` (~7491) and confirm it calls `setState` directly with no `askConfirm`. Then:

```bash
grep -c "collecting" creator/main.dc.html
```

Expected: `1` (only Task 4's seed).

- [ ] **Step 3: Wrap the disconnect**

Keep the existing filter body exactly as it is — move it inside `onConfirm`:

```js
        onDisconnect: () => this.askConfirm({
          title:'이 채널을 연결 해제할까요?',
          body:'해제하면 이 채널로 받은 협업 제안을 더 볼 수 없어요. 언제든 다시 연결할 수 있어요.',
          reversible:true, confirmLabel:'연결 해제',
          onConfirm: () => this.setState((st) => ({ channels: st.channels.filter((x) => x.id !== c.id), channelScope:'all' })) }),
```

`reversible:true` is correct here — a channel can be reconnected. Do not copy Phase 2's participation-cancel flag, which is `false`.

- [ ] **Step 4: Add the collecting state**

In the same channel-row map, add:

```js
        collecting: !!c.collecting,
        collectLabel: c.collecting ? '수집 중 · 잠시 후 다시 확인해주세요' : c.followers,
```

Then in the channel row markup (~1445) bind the follower line to `{{ pc.collectLabel }}` and give the collecting case the muted treatment — `color:var(--text-tertiary)` rather than the normal follower styling. Read the existing row before editing so the non-collecting case renders exactly as it does today.

- [ ] **Step 5: Confirm it passes**

From MY, tap `해제` on a channel: sheet appears saying it can be reconnected; dismiss and confirm the channel is still listed; repeat and confirm, and check it is gone. Then connect a Naver channel via Task 4 and confirm the new row reads `수집 중` instead of a follower count, while the existing channels still show theirs.

- [ ] **Step 6: Commit**

```bash
git add creator/main.dc.html
git commit -m "fix(creator): 채널 연결 해제에 확인 시트 · 수집 중 상태 추가"
```

---

## Phase 3 gate

Walk these by clicking, not by setting state:

- **Password reset** — login → forgot → request (prefilled) → sent → 링크 열기 → new password → success → login (prefilled).
- **Find email** — login → forgot-email → verify → masked address → login with the full address.
- **Input errors** — on every new field, no judgment while typing, error on blur, cleared on refocus. The new-password length hint is the one deliberate exception.
- **Naver connect** — connect → Naver → URL screen → guide expands → valid URL saves → channel appears.
- **Channel management** — 해제 opens the sheet; dismissing is a no-op; confirming removes; a fresh Naver channel reads 수집 중.

Then `read_console_messages {"onlyErrors":true}` on each surface, reading immediately after a fresh cache-busted load. Only `/api/comments` 404s are acceptable.

---

## Self-review notes

**Spec coverage.** Spec §5 Phase 3 lists four items. Two are already implemented and are documented as dropped in "Scope correction" with the evidence (theme sheet, per-channel disconnect). Password reset maps to Tasks 1–2 as four screens exactly as its checklist specifies; find-email maps to Task 3. Tasks 4 and 5 are real gaps the spec did not list, found while verifying it.

**Deferred to a later plan.** Spec §5 Phase 5. Phase 4 is cancelled (group buy and affiliate are excluded product-wide). Spec §8's open questions all touch Phase 5 and block nothing here.

**Naming consistency.** `markTouched`/`clearTouched` (Phase 0) are consumed by Tasks 1, 2, and 4. `askConfirm` with `onConfirm` (Phase 0 + `4765ad4`) is consumed by Task 5. The `sv-done` success pattern is instantiated by Task 2, making it the fourth screen sharing that structure. `ACCOUNTS` is read by Tasks 1 and 3. `collecting` is produced by Task 4 and consumed by Task 5 — the only cross-task dependency in this phase.

**Known soft spots.**
- Task 3 returns `Object.keys(ACCOUNTS)[0]` — the prototype has no phone-to-account mapping, so identity verification always resolves to the first demo account. That is the honest prototype behavior; a reviewer should not read it as a lookup.
- Task 4 tells the implementer to read the existing `c.onToggle` construction and branch inside it rather than replacing the row, because the row's label and connected-state styling are computed alongside it.
- Task 5 tells the implementer to read the existing channel row before rebinding the follower line, so the non-collecting case is unchanged.
- The masking regex in Task 3 keeps the first two characters; for a one-character local part it degrades to showing the whole thing. Acceptable for two fixed demo accounts, worth noting if real data ever arrives.
