create-ailk 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,8 +27,8 @@
27
27
  "tar": "^7.5.21"
28
28
  },
29
29
  "devDependencies": {
30
- "@working-theory/schema": "0.31.0",
31
- "@working-theory/templates": "0.31.0"
30
+ "@working-theory/schema": "0.32.0",
31
+ "@working-theory/templates": "0.32.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -9,11 +9,15 @@
9
9
  * by side); Log out signs out and routes home.
10
10
  * 3. ? opens Contact support (→ /account/support) and, only when the site
11
11
  * configures a help URL, Help center.
12
- * 4. Below md the tab bar reads avatar · Inbox · Help.
12
+ * 4. Below md the tab bar reads avatar · Inbox · For you · Help, with no
13
+ * count on any tab (#7469, DESIGN.md §2b as amended 2026-10-01); For
14
+ * you is `?tab=for-you` of the Inbox route, and the frame hands the
15
+ * tab to the page (PhoneTabProvider).
13
16
  * 5. The For-you items are the aside; none → no aside.
14
17
  * 6. No site chrome (Nav, Footer, ThemeToggle) inside the frame.
15
18
  */
16
19
  import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
20
+ import type { ReactElement } from "react";
17
21
 
18
22
  jest.mock("next-intl/server", () => {
19
23
  const msgs: unknown = require("../../../../../messages/en.json");
@@ -69,14 +73,23 @@ jest.mock("better-auth/react", () => ({
69
73
  const mockPush = jest.fn();
70
74
  const mockRefresh = jest.fn();
71
75
  let mockPathname = "/account";
76
+ let mockSearch = "";
72
77
  jest.mock("next/navigation", () => ({
73
78
  __esModule: true,
74
79
  useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
75
80
  usePathname: () => mockPathname,
81
+ useSearchParams: () => new URLSearchParams(mockSearch),
76
82
  }));
77
83
 
84
+ import { usePhoneTab } from "@working-theory/templates/account";
85
+
78
86
  import AccountLayout from "../layout";
79
87
 
88
+ /** A page that shows the phone tab the frame hands it. */
89
+ function PhoneTabProbe(): ReactElement {
90
+ return <p data-testid="phone-tab-probe">{usePhoneTab()}</p>;
91
+ }
92
+
80
93
  const SESSION = {
81
94
  user: {
82
95
  id: "user_1",
@@ -107,7 +120,12 @@ function openByPointer(trigger: HTMLElement): void {
107
120
 
108
121
  async function renderLayout(locale = "en"): Promise<void> {
109
122
  const element = await AccountLayout({
110
- children: <p data-testid="account-child">page</p>,
123
+ children: (
124
+ <>
125
+ <p data-testid="account-child">page</p>
126
+ <PhoneTabProbe />
127
+ </>
128
+ ),
111
129
  params: Promise.resolve({ locale }),
112
130
  });
113
131
  render(element);
@@ -118,6 +136,7 @@ const ORIGINAL_HELP_URL = process.env.AILK_HELP_CENTER_URL;
118
136
  beforeEach(() => {
119
137
  window.localStorage.clear();
120
138
  mockPathname = "/account";
139
+ mockSearch = "";
121
140
  delete process.env.AILK_HELP_CENTER_URL;
122
141
  mockGetSession.mockReset().mockResolvedValue(SESSION);
123
142
  mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [] });
@@ -241,22 +260,78 @@ describe("the help menu (D13)", () => {
241
260
  });
242
261
  });
243
262
 
244
- describe("the phone frame (D13)", () => {
245
- it("the tab bar reads avatar · Inbox · Help, and Help opens the same menu", async () => {
263
+ describe("the phone frame (D13; #7469 — the For you tab)", () => {
264
+ it("the tab bar reads avatar · Inbox · For you · Help, and Help opens the same menu", async () => {
246
265
  await renderLayout();
247
266
  const bar = screen.getByTestId("bottom-tab-bar");
248
267
  const cells = Array.from(bar.children);
249
- expect(cells).toHaveLength(3);
268
+ expect(cells).toHaveLength(4);
250
269
  expect(cells[0]).toBe(screen.getByTestId("bottom-tab-bar-leading"));
251
270
  expect(cells[1]).toHaveTextContent("Inbox");
271
+ expect(cells[1]).toHaveAttribute("href", "/account");
252
272
  expect(cells[1]).toHaveAttribute("aria-current", "page");
253
- expect(cells[2]).toBe(screen.getByTestId("bottom-tab-bar-trailing"));
273
+ expect(cells[2]).toHaveTextContent("For you");
274
+ expect(cells[2]).toHaveAttribute("href", "/account?tab=for-you");
275
+ expect(cells[2]).not.toHaveAttribute("aria-current");
276
+ expect(cells[3]).toBe(screen.getByTestId("bottom-tab-bar-trailing"));
254
277
  openByPointer(screen.getByTestId("frame-bottom-bar-help"));
255
278
  expect(
256
279
  within(screen.getByTestId("signed-in-shell-help-menu")).getByText("Contact support"),
257
280
  ).toBeInTheDocument();
258
281
  });
259
282
 
283
+ it("no tab carries a count, even with For-you items waiting", async () => {
284
+ mockGetSession.mockResolvedValue({
285
+ user: { ...SESSION.user, name: null, emailVerified: false },
286
+ });
287
+ await renderLayout();
288
+ const bar = screen.getByTestId("bottom-tab-bar");
289
+ const tabs = within(bar).getAllByRole("link");
290
+ expect(tabs.map((tab) => tab.textContent)).toEqual(["Inbox", "For you"]);
291
+ });
292
+
293
+ it("a non-default locale prefixes both tabs", async () => {
294
+ mockPathname = "/fr/account";
295
+ await renderLayout("fr");
296
+ const tabs = within(screen.getByTestId("bottom-tab-bar")).getAllByRole("link");
297
+ expect(tabs.map((tab) => tab.getAttribute("href"))).toEqual([
298
+ "/fr/account",
299
+ "/fr/account?tab=for-you",
300
+ ]);
301
+ });
302
+
303
+ it("on ?tab=for-you, For you is selected, Inbox is not, and the page is handed the tab", async () => {
304
+ mockSearch = "tab=for-you";
305
+ await renderLayout();
306
+ const [inbox, forYou] = within(screen.getByTestId("bottom-tab-bar")).getAllByRole("link");
307
+ expect(forYou).toHaveAttribute("aria-current", "page");
308
+ expect(inbox).not.toHaveAttribute("aria-current");
309
+ expect(screen.getByTestId("phone-tab-probe")).toHaveTextContent("for-you");
310
+ });
311
+
312
+ it("on the Inbox the page is handed the inbox tab", async () => {
313
+ await renderLayout();
314
+ expect(screen.getByTestId("phone-tab-probe")).toHaveTextContent("inbox");
315
+ });
316
+
317
+ it("an unknown ?tab= is the Inbox", async () => {
318
+ mockSearch = "tab=library";
319
+ await renderLayout();
320
+ const [inbox] = within(screen.getByTestId("bottom-tab-bar")).getAllByRole("link");
321
+ expect(inbox).toHaveAttribute("aria-current", "page");
322
+ expect(screen.getByTestId("phone-tab-probe")).toHaveTextContent("inbox");
323
+ });
324
+
325
+ it("an Inbox item page keeps the Inbox tab selected, whatever ?tab= says", async () => {
326
+ mockPathname = "/account/inbox/purchase/fc_1";
327
+ mockSearch = "tab=for-you";
328
+ await renderLayout();
329
+ const [inbox, forYou] = within(screen.getByTestId("bottom-tab-bar")).getAllByRole("link");
330
+ expect(inbox).toHaveAttribute("aria-current", "page");
331
+ expect(forYou).not.toHaveAttribute("aria-current");
332
+ expect(screen.getByTestId("phone-tab-probe")).toHaveTextContent("inbox");
333
+ });
334
+
260
335
  it("the phone top bar carries the site logo, linking home", async () => {
261
336
  await renderLayout();
262
337
  const logos = screen.getAllByTestId("account-site-logo");
@@ -8,10 +8,15 @@
8
8
  * 2. `result` and `purchase` rows sit under Recents and All, newest first,
9
9
  * each linking to its item page.
10
10
  * 3. `email-unverified` and `payment-due` are For-you items (the phone
11
- * stack), never Inbox rows.
11
+ * For you tab), never Inbox rows.
12
12
  * 4. A member with no name gets an "Add your name" For-you item (needs-you,
13
13
  * an Open action) linking to User settings › General.
14
14
  * 5. A failed read renders an error row with Retry — never an empty Inbox.
15
+ * 6. #7469 (DESIGN.md §2b as amended 2026-10-01) — the route is the
16
+ * phone's two main tabs. The Inbox tab is the filter tabs and the list
17
+ * only: no For-you stack, no bottom sheet, no large title. The For you
18
+ * tab lists every For-you item in one inset card, and ✕ dismisses one.
19
+ * At `md`+ only the Inbox panel shows (the aside holds For you).
15
20
  */
16
21
  import { fireEvent, render, screen, within } from "@testing-library/react";
17
22
 
@@ -73,6 +78,8 @@ jest.mock("next/navigation", () => ({
73
78
  usePathname: () => "/account",
74
79
  }));
75
80
 
81
+ import { PhoneTabProvider } from "@working-theory/templates/account";
82
+
76
83
  import { accountExperimentKeys } from "../../../../../lib/account-config";
77
84
  import Page from "../page";
78
85
 
@@ -114,12 +121,24 @@ const NO_SUBSCRIPTION = {
114
121
  body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false },
115
122
  };
116
123
 
117
- async function renderPage(locale = "en"): Promise<void> {
124
+ /** Renders the page on a phone tab — the frame's PhoneTabProvider. */
125
+ async function renderPage(locale = "en", tab = "inbox"): Promise<void> {
118
126
  const element = await Page({ params: Promise.resolve({ locale }) });
119
- render(element);
127
+ render(<PhoneTabProvider tab={tab}>{element}</PhoneTabProvider>);
120
128
  }
121
129
 
130
+ const UNVERIFIED_AND_PAST_DUE = (): void => {
131
+ mockGetSession.mockResolvedValue({
132
+ user: { ...SESSION.user, emailVerified: false },
133
+ });
134
+ mockSubscriptionMe.mockResolvedValue({
135
+ ok: true,
136
+ body: { actionRequired: true, status: "past_due", accessRevokesAt: null, portalAvailable: true },
137
+ });
138
+ };
139
+
122
140
  beforeEach(() => {
141
+ window.localStorage.clear();
123
142
  accountExperimentKeys.length = 0;
124
143
  mockGetSession.mockReset().mockResolvedValue(SESSION);
125
144
  mockSignupMe.mockReset().mockResolvedValue({ ok: true, body: SIGNUP });
@@ -195,18 +214,11 @@ describe("the Inbox — D6a tabs (AC-7)", () => {
195
214
  });
196
215
 
197
216
  describe("For-you items, not Inbox rows (AC-7, D9 as amended)", () => {
198
- it("email-unverified and payment-due are For-you items in the phone stack, never Inbox rows", async () => {
199
- mockGetSession.mockResolvedValue({
200
- user: { ...SESSION.user, emailVerified: false },
201
- });
202
- mockSubscriptionMe.mockResolvedValue({
203
- ok: true,
204
- body: { actionRequired: true, status: "past_due", accessRevokesAt: null, portalAvailable: true },
205
- });
206
- await renderPage();
207
- const forYou = screen.getByTestId("inbox-for-you");
208
- expect(forYou).toHaveClass("md:hidden");
209
- expect(within(forYou).getByTestId("for-you-stack-count")).toHaveTextContent("2");
217
+ it("email-unverified and payment-due are For you tab items, never Inbox rows", async () => {
218
+ UNVERIFIED_AND_PAST_DUE();
219
+ await renderPage("en", "for-you");
220
+ const forYou = screen.getByTestId("inbox-panel-for-you");
221
+ expect(within(forYou).getAllByTestId("for-you-item")).toHaveLength(2);
210
222
  const list = screen.getByTestId("inbox-list");
211
223
  expect(within(list).queryByText("Email not verified")).not.toBeInTheDocument();
212
224
  expect(
@@ -214,15 +226,10 @@ describe("For-you items, not Inbox rows (AC-7, D9 as amended)", () => {
214
226
  ).not.toBeInTheDocument();
215
227
  });
216
228
 
217
- it("nothing for you → no For you section", async () => {
218
- await renderPage();
219
- expect(screen.queryByTestId("inbox-for-you")).not.toBeInTheDocument();
220
- });
221
-
222
229
  it("a member with no name sees 'Add your name' (needs-you, Open → User settings › General)", async () => {
223
230
  mockGetSession.mockResolvedValue({ user: { ...SESSION.user, name: "" } });
224
- await renderPage();
225
- const forYou = screen.getByTestId("inbox-for-you");
231
+ await renderPage("en", "for-you");
232
+ const forYou = screen.getByTestId("inbox-panel-for-you");
226
233
  const item = within(forYou).getByTestId("for-you-item");
227
234
  expect(item).toHaveAttribute("data-item-id", "add-name");
228
235
  expect(item).toHaveTextContent("Add your name");
@@ -235,6 +242,101 @@ describe("For-you items, not Inbox rows (AC-7, D9 as amended)", () => {
235
242
  });
236
243
  });
237
244
 
245
+ describe("the phone Inbox tab (#7469, DESIGN.md §2b · §5.15)", () => {
246
+ it("holds the filter tabs and the list only — no For-you stack and no bottom sheet", async () => {
247
+ UNVERIFIED_AND_PAST_DUE();
248
+ await renderPage();
249
+ const inbox = screen.getByTestId("inbox-panel-inbox");
250
+ expect(within(inbox).getByTestId("inbox-list")).toBeInTheDocument();
251
+ expect(screen.queryByTestId("for-you-stack")).not.toBeInTheDocument();
252
+ expect(screen.queryByTestId("for-you-stack-count")).not.toBeInTheDocument();
253
+ expect(screen.queryByTestId("for-you-sheet")).not.toBeInTheDocument();
254
+ expect(screen.queryByTestId("inbox-for-you")).not.toBeInTheDocument();
255
+ });
256
+
257
+ it("shows on the phone and at md+, while the For you panel stays hidden", async () => {
258
+ UNVERIFIED_AND_PAST_DUE();
259
+ await renderPage();
260
+ expect(screen.getByTestId("inbox-panel-inbox")).not.toHaveClass("hidden");
261
+ expect(screen.getByTestId("inbox-panel-inbox")).not.toHaveClass("md:hidden");
262
+ expect(screen.getByTestId("inbox-panel-for-you")).toHaveClass("hidden");
263
+ });
264
+
265
+ it("the phone has no large title or lead — both stay at md+", async () => {
266
+ await renderPage();
267
+ expect(screen.getByRole("heading", { level: 1 })).toHaveClass("max-md:sr-only");
268
+ expect(screen.getByRole("heading", { level: 1 })).not.toHaveClass("hidden");
269
+ expect(screen.getByText("What changed, and what's coming.")).toHaveClass("hidden", "md:block");
270
+ });
271
+
272
+ it("the list sits in a bordered card, inset by the canvas on every side", async () => {
273
+ accountExperimentKeys.push("ailk-founder-diagnostic");
274
+ await renderPage();
275
+ // The shell's main slot insets the canvas (p-4 below md); the page adds
276
+ // no full-bleed escape, and the list is RowGroup's bordered box.
277
+ const page = screen.getByTestId("account-inbox");
278
+ expect(page.className).not.toMatch(/-mx-|w-screen/);
279
+ expect(screen.getByTestId("inbox-panel-inbox").className ?? "").not.toMatch(/-mx-|w-screen/);
280
+ });
281
+ });
282
+
283
+ describe("the phone For you tab (#7469, DESIGN.md §2b · §5.15)", () => {
284
+ it("lists every item with its type tag, title, Open or View, and ✕", async () => {
285
+ UNVERIFIED_AND_PAST_DUE();
286
+ mockGetSession.mockResolvedValue({
287
+ user: { ...SESSION.user, name: "", emailVerified: false },
288
+ });
289
+ await renderPage("en", "for-you");
290
+ const panel = screen.getByTestId("inbox-panel-for-you");
291
+ // Phone-only: shown below md, hidden at md+ where the aside holds it.
292
+ expect(panel).toHaveClass("md:hidden");
293
+ expect(panel).not.toHaveClass("hidden");
294
+ // The Inbox panel is hidden on the phone but still shows at md+.
295
+ expect(screen.getByTestId("inbox-panel-inbox")).toHaveClass("hidden", "md:block");
296
+ const card = within(panel).getByTestId("for-you-list-card");
297
+ const items = within(card).getAllByTestId("for-you-item");
298
+ expect(items).toHaveLength(3);
299
+ for (const item of items) {
300
+ expect(within(item).getByTestId("for-you-tag")).toBeInTheDocument();
301
+ expect(
302
+ within(item).queryByTestId("for-you-open") ?? within(item).queryByTestId("for-you-view"),
303
+ ).not.toBeNull();
304
+ expect(within(item).getByTestId("for-you-dismiss")).toHaveAccessibleName(/^Dismiss/);
305
+ }
306
+ expect(within(panel).getByText("Add your name")).toBeInTheDocument();
307
+ expect(within(panel).getByText("Email not verified")).toBeInTheDocument();
308
+ expect(within(panel).getByText("Your payment didn't go through")).toBeInTheDocument();
309
+ });
310
+
311
+ it("✕ dismisses an item — it leaves the list", async () => {
312
+ UNVERIFIED_AND_PAST_DUE();
313
+ await renderPage("en", "for-you");
314
+ const panel = screen.getByTestId("inbox-panel-for-you");
315
+ const [first] = within(panel).getAllByTestId("for-you-item");
316
+ const id = first!.getAttribute("data-item-id");
317
+ fireEvent.click(within(first!).getByTestId("for-you-dismiss"));
318
+ const left = within(panel).getAllByTestId("for-you-item");
319
+ expect(left).toHaveLength(1);
320
+ expect(left.map((item) => item.getAttribute("data-item-id"))).not.toContain(id);
321
+ });
322
+
323
+ it("the For you tab keeps the page's h1 (visually hidden on the phone)", async () => {
324
+ await renderPage("en", "for-you");
325
+ const h1 = screen.getByRole("heading", { level: 1 });
326
+ expect(h1).toHaveTextContent("Inbox");
327
+ expect(screen.getByTestId("inbox-panel-inbox")).not.toContainElement(h1);
328
+ });
329
+
330
+ it("with nothing waiting the tab shows its empty line, not a blank page", async () => {
331
+ await renderPage("en", "for-you");
332
+ const panel = screen.getByTestId("inbox-panel-for-you");
333
+ expect(within(panel).queryByTestId("for-you-item")).not.toBeInTheDocument();
334
+ expect(within(panel).getByTestId("for-you-empty")).toHaveTextContent(
335
+ "Nothing here right now.",
336
+ );
337
+ });
338
+ });
339
+
238
340
  describe("Hub unavailable (D12)", () => {
239
341
  it("a failed read renders an error row with Retry, never an empty Inbox", async () => {
240
342
  mockCheckoutsMe.mockResolvedValue({ ok: false, status: 500, body: {} });
@@ -250,6 +352,26 @@ describe("Hub unavailable (D12)", () => {
250
352
  it("a failed For-you read also shows the error row", async () => {
251
353
  mockSubscriptionMe.mockResolvedValue({ ok: false, status: 502, body: {} });
252
354
  await renderPage();
253
- expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
355
+ expect(
356
+ within(screen.getByTestId("inbox-panel-inbox")).getByTestId("inbox-error"),
357
+ ).toBeInTheDocument();
358
+ });
359
+
360
+ it("on the phone For you tab, a failed For-you read shows the error row above the list (#7469)", async () => {
361
+ mockSubscriptionMe.mockResolvedValue({ ok: false, status: 502, body: {} });
362
+ await renderPage("en", "for-you");
363
+ const panel = screen.getByTestId("inbox-panel-for-you");
364
+ expect(within(panel).getByTestId("inbox-error")).toHaveTextContent(
365
+ "We couldn't load everything in your Inbox.",
366
+ );
367
+ // Never a silently empty list (D12): the empty line gives way to the error row.
368
+ expect(within(panel).queryByTestId("for-you-empty")).not.toBeInTheDocument();
369
+ });
370
+
371
+ it("a failed Inbox-kind read leaves the For you tab without an error row", async () => {
372
+ mockCheckoutsMe.mockResolvedValue({ ok: false, status: 500, body: {} });
373
+ await renderPage("en", "for-you");
374
+ const panel = screen.getByTestId("inbox-panel-for-you");
375
+ expect(within(panel).queryByTestId("inbox-error")).not.toBeInTheDocument();
254
376
  });
255
377
  });
@@ -17,7 +17,7 @@
17
17
  * #7418), with the same `failed` flag.
18
18
  *
19
19
  * `cache()` dedupes the reads within one request: the layout's aside and the
20
- * Inbox's phone stack render the same items from one set of API calls.
20
+ * Inbox's phone For you tab render the same items from one set of API calls.
21
21
  */
22
22
 
23
23
  import type {
@@ -9,10 +9,18 @@
9
9
  * `createAuthClient()` at module scope is the `sign-in-gate.tsx` idiom. Log
10
10
  * out ends the session, then navigates home and refreshes so every RSC under
11
11
  * the `(authed)` gate re-reads the (now absent) session.
12
+ *
13
+ * #7469 (DESIGN.md §2b as amended 2026-10-01, matching the Admin's §5.15) —
14
+ * below `md` the bar reads avatar · Inbox · For you · Help, with no count
15
+ * on any tab. For you is a page of the Inbox route (`?tab=for-you`): the
16
+ * frame reads the URL and hands the tab to the page (`PhoneTabProvider`),
17
+ * which shows one panel per tab. At `md`+ nothing changes — For you stays
18
+ * the aside.
12
19
  */
13
20
 
14
21
  import {
15
22
  ForYouPanel,
23
+ PhoneTabProvider,
16
24
  useForYouDismissed,
17
25
  type ForYouItem,
18
26
  type ForYouLabels,
@@ -25,18 +33,23 @@ import {
25
33
  import { localeHref } from "@working-theory/ui";
26
34
  import { Link } from "@working-theory/ui/primitives";
27
35
  import { createAuthClient } from "better-auth/react";
28
- import { Inbox, Settings } from "lucide-react";
29
- import { usePathname, useRouter } from "next/navigation";
36
+ import { Inbox, Settings, Sparkles } from "lucide-react";
37
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
30
38
  import type { ReactElement, ReactNode } from "react";
31
39
 
32
40
  import { ACCOUNT_PERSIST_KEY } from "./for-you-labels";
33
41
 
34
42
  const authClient = createAuthClient();
35
43
 
44
+ /** The phone For you tab's `?tab=` value (#7469). */
45
+ const FOR_YOU_TAB = "for-you";
46
+
36
47
  export interface AccountFrameLabels extends SignedInShellLabels {
37
48
  /** The logo link's accessible name. */
38
49
  home: string;
39
50
  inbox: string;
51
+ /** The phone's For you tab label (#7469). */
52
+ forYou: string;
40
53
  settings: string;
41
54
  }
42
55
 
@@ -62,12 +75,16 @@ export function AccountFrame({
62
75
  }: AccountFrameProps): ReactElement {
63
76
  const router = useRouter();
64
77
  const pathname = usePathname();
78
+ const searchParams = useSearchParams();
65
79
  const [dismissed] = useForYouDismissed(ACCOUNT_PERSIST_KEY);
66
80
 
67
81
  const homeHref = localeHref(locale, "/");
68
82
  const inboxHref = localeHref(locale, "/account");
69
83
  // The Inbox row stays selected on an item's own page (/account/inbox/…).
70
84
  const onInbox = pathname === inboxHref || pathname.startsWith(`${inboxHref}/inbox/`);
85
+ // #7469 — the phone tab: For you only on the Inbox route itself.
86
+ const phoneTab =
87
+ pathname === inboxHref && searchParams?.get("tab") === FOR_YOU_TAB ? FOR_YOU_TAB : "inbox";
71
88
 
72
89
  const onSignOut = (): void => {
73
90
  void (async () => {
@@ -82,7 +99,7 @@ export function AccountFrame({
82
99
  })();
83
100
  };
84
101
 
85
- const { home, inbox, settings, ...shellLabels } = labels;
102
+ const { home, inbox, forYou: forYouLabel, settings, ...shellLabels } = labels;
86
103
 
87
104
  // D13 — the logo only: no box, no name, no popover; it links home. The
88
105
  // same link is the phone top bar's leading cell.
@@ -105,7 +122,16 @@ export function AccountFrame({
105
122
  persistKey={ACCOUNT_PERSIST_KEY}
106
123
  locale={locale}
107
124
  navItems={[{ labelKey: "inbox", label: inbox, path: "/account", icon: Inbox }]}
108
- tabItems={[{ key: "inbox", label: inbox, href: inboxHref, icon: Inbox, isActive: onInbox }]}
125
+ tabItems={[
126
+ { key: "inbox", label: inbox, href: inboxHref, icon: Inbox, isActive: onInbox && phoneTab === "inbox" },
127
+ {
128
+ key: FOR_YOU_TAB,
129
+ label: forYouLabel,
130
+ href: `${inboxHref}?tab=${FOR_YOU_TAB}`,
131
+ icon: Sparkles,
132
+ isActive: phoneTab === FOR_YOU_TAB,
133
+ },
134
+ ]}
109
135
  {...(onInbox ? { activePath: inboxHref } : {})}
110
136
  user={user}
111
137
  userMenuItems={[
@@ -137,7 +163,7 @@ export function AccountFrame({
137
163
  : {})}
138
164
  labels={shellLabels}
139
165
  >
140
- {children}
166
+ <PhoneTabProvider tab={phoneTab}>{children}</PhoneTabProvider>
141
167
  </SignedInShell>
142
168
  );
143
169
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * for-you-labels.ts — the For-you panel's strings from the `account`
3
3
  * namespace (#7430, spec §5.2 i18n), and the frame's persist key. Shared by
4
- * the layout's aside and the Inbox's phone stack.
4
+ * the layout's aside and the Inbox's phone For you tab.
5
5
  */
6
6
 
7
7
  import type { AccountTranslate, ForYouLabels } from "@working-theory/templates/account";
@@ -10,7 +10,7 @@ import type { AccountTranslate, ForYouLabels } from "@working-theory/templates/a
10
10
  * The account frame's `persistKey`: the shell's remembered collapse
11
11
  * (`ailk:shell:account:*`) and the For-you dismissals
12
12
  * (`ailk:account:foryou:dismissed`), shared by the aside and the Inbox's
13
- * phone stack so a dismissal holds on both.
13
+ * phone For you tab so a dismissal holds on both.
14
14
  */
15
15
  export const ACCOUNT_PERSIST_KEY = "account";
16
16
 
@@ -7,13 +7,13 @@
7
7
  * home; Inbox as the rail's only row; a footer of the avatar and ? alone;
8
8
  * the card avatar menu (name, then Settings and Log out); the ? help menu
9
9
  * (Contact support, and Help center when `AILK_HELP_CENTER_URL` is set);
10
- * and below `md` a tab bar reading avatar · Inbox · Help. The locale layout
11
- * renders no marketing frame and no consent Banner on these paths (D4,
12
- * `isAccountPath`).
10
+ * and below `md` a tab bar reading avatar · Inbox · For you · Help (#7469).
11
+ * The locale layout renders no marketing frame and no consent Banner on
12
+ * these paths (D4, `isAccountPath`).
13
13
  *
14
14
  * The aside is the For-you panel, fed by `accountForYouSources` plus the
15
15
  * area's own "Add your name" item (`loadForYou`, deduped per request with
16
- * the Inbox's phone stack); with no items there is no aside.
16
+ * the Inbox's phone For you tab); with no items there is no aside.
17
17
  *
18
18
  * The `(authed)` layout above owns the signed-out gate, so a missing session
19
19
  * here is a race (it expired between the two layouts): the page renders
@@ -67,6 +67,7 @@ export default async function AccountLayout({
67
67
  labels={{
68
68
  home: t("frame.home"),
69
69
  inbox: t("frame.inbox"),
70
+ forYou: t("frame.forYou"),
70
71
  settings: t("frame.settings"),
71
72
  signOut: t("frame.signOut"),
72
73
  help: t("frame.help"),
@@ -17,8 +17,18 @@
17
17
  *
18
18
  * What needs the person is never an Inbox row: `email-unverified`,
19
19
  * `payment-due` and "Add your name" are For-you items (D9 as amended). From
20
- * `md` up they are the layout's aside; below `md` this page shows them inline
21
- * as the For you stack, between the header and the tabs — absent when empty.
20
+ * `md` up they are the layout's aside.
21
+ *
22
+ * #7469 (DESIGN.md §2b as amended 2026-10-01, matching the Admin's §5.15) —
23
+ * below `md` the route is the phone's two main tabs, one `PhoneTabPanel`
24
+ * each (the frame hands the tab over from `?tab=`):
25
+ * - Inbox — the filter tabs and the list only, in RowGroup's inset card.
26
+ * No For-you stack and no bottom sheet, and no lead on the phone.
27
+ * - For you — every For-you item as rows of one inset card, each with its
28
+ * type tag, title, Open or View, and ✕; the empty line when none waits,
29
+ * and D12's error row above the list when a For-you source failed.
30
+ * The h1 sits above both panels, visually hidden below `md` (the phone has
31
+ * no large title). At `md`+ only the Inbox panel shows, lead included.
22
32
  *
23
33
  * D12 — any read that fails (a kind or a For-you source) renders an error
24
34
  * row with Retry above the list; the list then holds what DID load, never a
@@ -26,8 +36,9 @@
26
36
  */
27
37
 
28
38
  import {
29
- ForYouStack,
39
+ ForYouPanel,
30
40
  InboxList,
41
+ PhoneTabPanel,
31
42
  type InboxListRow,
32
43
  } from "@working-theory/templates/account";
33
44
  import { Heading, Text } from "@working-theory/ui/primitives";
@@ -96,52 +107,65 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
96
107
  const failed = inbox.failed || forYou.failed;
97
108
 
98
109
  return (
99
- <div className="max-w-3xl py-8" data-testid="account-inbox">
100
- <Heading level={1} visual={2}>
110
+ // The phone's top bar already clears the canvas — no top inset there.
111
+ <div className="max-w-3xl py-8 max-md:pt-0" data-testid="account-inbox">
112
+ {/* The page's one h1, on both phone tabs; visually hidden on the phone,
113
+ which has no large title (§5.15). */}
114
+ <Heading level={1} visual={2} className="max-md:sr-only">
101
115
  {t("inbox.title")}
102
116
  </Heading>
103
- <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
104
- {t("inbox.lead")}
105
- </Text>
117
+ <PhoneTabPanel tabs={["inbox"]} desktop data-testid="inbox-panel-inbox">
118
+ <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight hidden md:block">
119
+ {t("inbox.lead")}
120
+ </Text>
121
+
122
+ {failed ? (
123
+ <ErrorRow
124
+ message={t("inbox.error")}
125
+ retryLabel={t("inbox.retry")}
126
+ className="mt-block-loose"
127
+ />
128
+ ) : null}
129
+
130
+ <InboxList
131
+ tabs={[
132
+ { id: "recents", label: t("inbox.tabs.recents"), count: recents.length, rows: recents },
133
+ { id: "suggested", label: t("inbox.tabs.suggested"), count: 0, rows: [] },
134
+ {
135
+ id: "scheduled",
136
+ label: t("inbox.tabs.scheduled"),
137
+ count: scheduled.length,
138
+ rows: scheduled,
139
+ },
140
+ { id: "all", label: t("inbox.tabs.all"), count: all.length, rows: all },
141
+ ]}
142
+ defaultTab="recents"
143
+ {...(failed ? {} : { emptyLine: t("inbox.empty") })}
144
+ />
145
+ </PhoneTabPanel>
106
146
 
107
- {/* D6a — below `md` the For you stack sits between the header and the
108
- list; absent when empty. From `md` up the same items are the
109
- layout's aside (SignedInShell's breakpoint is "md"). */}
110
- {forYou.items.length > 0 ? (
111
- <div className="mt-block-loose md:hidden" data-testid="inbox-for-you">
112
- <ForYouStack
147
+ {/* #7469 — the phone For you tab: every item, rows of one inset card.
148
+ At `md`+ the same items are the layout's aside. D12 here too: a
149
+ failed source shows the error row, and never the empty line alone. */}
150
+ <PhoneTabPanel tabs={["for-you"]} data-testid="inbox-panel-for-you">
151
+ {forYou.failed ? (
152
+ <ErrorRow
153
+ message={t("inbox.error")}
154
+ retryLabel={t("inbox.retry")}
155
+ className="mb-block-loose"
156
+ />
157
+ ) : null}
158
+ {forYou.failed && forYou.items.length === 0 ? null : (
159
+ <ForYouPanel
160
+ variant="list"
113
161
  items={forYou.items}
114
162
  persistKey={ACCOUNT_PERSIST_KEY}
115
163
  labels={forYouLabels(translate)}
116
164
  locale={locale}
117
165
  now={forYou.now}
118
166
  />
119
- </div>
120
- ) : null}
121
-
122
- {failed ? (
123
- <ErrorRow
124
- message={t("inbox.error")}
125
- retryLabel={t("inbox.retry")}
126
- className="mt-block-loose"
127
- />
128
- ) : null}
129
-
130
- <InboxList
131
- tabs={[
132
- { id: "recents", label: t("inbox.tabs.recents"), count: recents.length, rows: recents },
133
- { id: "suggested", label: t("inbox.tabs.suggested"), count: 0, rows: [] },
134
- {
135
- id: "scheduled",
136
- label: t("inbox.tabs.scheduled"),
137
- count: scheduled.length,
138
- rows: scheduled,
139
- },
140
- { id: "all", label: t("inbox.tabs.all"), count: all.length, rows: all },
141
- ]}
142
- defaultTab="recents"
143
- {...(failed ? {} : { emptyLine: t("inbox.empty") })}
144
- />
167
+ )}
168
+ </PhoneTabPanel>
145
169
  </div>
146
170
  );
147
171
  }
@@ -550,6 +550,7 @@
550
550
  "frame": {
551
551
  "home": "Home",
552
552
  "inbox": "Inbox",
553
+ "forYou": "For you",
553
554
  "settings": "Settings",
554
555
  "signOut": "Log out",
555
556
  "help": "Help",
@@ -1,5 +1,12 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.32.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies []:
8
+ - @working-theory/validation@0.32.0
9
+
3
10
  ## 0.31.0
4
11
 
5
12
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@working-theory/database",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/versions.json CHANGED
@@ -1,52 +1,52 @@
1
1
  {
2
- "@working-theory/admin-ui": "0.31.0",
3
- "@working-theory/aeo": "0.31.0",
4
- "@working-theory/agent-context": "0.31.0",
2
+ "@working-theory/admin-ui": "0.32.0",
3
+ "@working-theory/aeo": "0.32.0",
4
+ "@working-theory/agent-context": "0.32.0",
5
5
  "@working-theory/agent-contract": "0.1.0",
6
6
  "@working-theory/agent-memory": "0.0.0",
7
7
  "@working-theory/ai": "0.1.0",
8
- "@working-theory/analytics": "0.31.0",
9
- "@working-theory/api-client": "0.31.0",
8
+ "@working-theory/analytics": "0.32.0",
9
+ "@working-theory/api-client": "0.32.0",
10
10
  "@working-theory/cda-filesystem": "0.0.0",
11
- "@working-theory/cli": "0.31.0",
12
- "@working-theory/common": "0.31.0",
11
+ "@working-theory/cli": "0.32.0",
12
+ "@working-theory/common": "0.32.0",
13
13
  "@working-theory/consent": "0.1.0",
14
- "@working-theory/content-adapters": "0.31.0",
15
- "@working-theory/content-projection": "0.31.0",
16
- "@working-theory/email": "0.31.0",
14
+ "@working-theory/content-adapters": "0.32.0",
15
+ "@working-theory/content-projection": "0.32.0",
16
+ "@working-theory/email": "0.32.0",
17
17
  "@working-theory/env": "0.0.0",
18
- "@working-theory/eslint-config": "0.31.0",
19
- "@working-theory/fumadocs-schema": "0.31.0",
18
+ "@working-theory/eslint-config": "0.32.0",
19
+ "@working-theory/fumadocs-schema": "0.32.0",
20
20
  "@working-theory/ids": "0.8.0",
21
- "@working-theory/jsonld": "0.31.0",
22
- "@working-theory/license": "0.31.0",
23
- "@working-theory/metadata": "0.31.0",
24
- "@working-theory/observability": "0.31.0",
25
- "@working-theory/page-renderer": "0.31.0",
21
+ "@working-theory/jsonld": "0.32.0",
22
+ "@working-theory/license": "0.32.0",
23
+ "@working-theory/metadata": "0.32.0",
24
+ "@working-theory/observability": "0.32.0",
25
+ "@working-theory/page-renderer": "0.32.0",
26
26
  "@working-theory/plugin-mcp": "0.1.1",
27
- "@working-theory/schema": "0.31.0",
28
- "@working-theory/schema-pack-registry": "0.31.0",
29
- "@working-theory/schema-validator": "0.31.0",
30
- "@working-theory/sections-registry": "0.31.0",
31
- "@working-theory/site-config": "0.31.0",
32
- "@working-theory/stripe-client": "0.31.0",
33
- "@working-theory/templates": "0.31.0",
34
- "@working-theory/theme": "0.31.0",
35
- "@working-theory/ui": "0.31.0",
36
- "@working-theory/validation": "0.31.0",
37
- "@working-theory/web-i18n": "0.31.0",
27
+ "@working-theory/schema": "0.32.0",
28
+ "@working-theory/schema-pack-registry": "0.32.0",
29
+ "@working-theory/schema-validator": "0.32.0",
30
+ "@working-theory/sections-registry": "0.32.0",
31
+ "@working-theory/site-config": "0.32.0",
32
+ "@working-theory/stripe-client": "0.32.0",
33
+ "@working-theory/templates": "0.32.0",
34
+ "@working-theory/theme": "0.32.0",
35
+ "@working-theory/ui": "0.32.0",
36
+ "@working-theory/validation": "0.32.0",
37
+ "@working-theory/web-i18n": "0.32.0",
38
38
  "@working-theory/billing-client": "0.0.0",
39
39
  "@working-theory/branding": "0.0.0",
40
40
  "@working-theory/license-client": "0.0.0",
41
41
  "@working-theory/license-manifest": "0.0.0",
42
42
  "@working-theory/partner-session": "0.0.0",
43
43
  "@working-theory/preview-protocol": "0.0.0",
44
- "@working-theory/ui-commerce": "0.23.8",
45
- "@working-theory/ui-docs-blog": "0.23.8",
46
- "@working-theory/ui-scheduling": "0.23.8",
47
- "@working-theory/ui-signal-collectors": "0.24.6",
44
+ "@working-theory/ui-commerce": "0.23.9",
45
+ "@working-theory/ui-docs-blog": "0.23.9",
46
+ "@working-theory/ui-scheduling": "0.23.9",
47
+ "@working-theory/ui-signal-collectors": "0.24.7",
48
48
  "@working-theory/web": "0.0.0",
49
49
  "@working-theory/api": "1.0.0",
50
50
  "@working-theory/mcp": "0.0.0",
51
- "@working-theory/database": "0.31.0"
51
+ "@working-theory/database": "0.32.0"
52
52
  }