create-ailk 0.11.3 → 0.11.5

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.
@@ -821,7 +821,7 @@
821
821
  "sourcePath": "packages/ui/src/blocks/AssetLightbox.tsx",
822
822
  "importPath": "@working-theory/ui/blocks",
823
823
  "exported": true,
824
- "description": "The asset detail lightbox: the image with zoom on the stage, a Details rail with in-place pencil-edit Title and Description, then Filename/Type/Size/Dimensions/Created, then Tags (from the seeded set the caller passes), then Copy URL/Replace/Delete. Renders inside a ResponsiveSheet — the SAME field sequence in both its dialog and sheet forms (: one field array, walked once, shared by both forms).",
824
+ "description": "The asset detail lightbox: the image with zoom on the stage, a Details rail with in-place pencil-edit Title and Description, then Filename/Type/Size/Dimensions/Created, then Tags (from the seeded set the caller passes), then Copy link/Replace/Delete. Renders inside a ResponsiveSheet — the SAME field sequence in both layouts (: one field array, walked once, shared by both).",
825
825
  "descriptionSource": "doc-comment",
826
826
  "descriptionGrade": "strong",
827
827
  "sectionType": null
@@ -134,7 +134,7 @@ Import: `import { X } from "@working-theory/ui/blocks";` (or from the `@working-
134
134
  | `ArticlePager` | The footer prev/next pager. Given the previous and next destinations — docs pages in tree order, or blog posts in date order — renders two link tiles (a directional label + the destination title). Either side may be absent (first / last item) — the present side still anchors to its edge. Renders nothing when both sides are absent. | `import { ArticlePager } from "@working-theory/ui/blocks"` |
135
135
  | `AskAiCommandGroup` | The ⌘K palette's composable "Ask AI" group; one CommandItem per provider seeding "About <entity>: <query>" (or the entity promptText when the query is empty); composed by the consumer's own CommandDialog. | `import { AskAiCommandGroup } from "@working-theory/ui/blocks"` |
136
136
  | `AskAiProviderRow` | The ONE shared provider-link row every ask-AI placement composes — the AskAi section (band + ticker variant) and the Hero `askAi` opt-in all render THIS block, never a per-placement fork. Renders a bounded set of five measured providers, each a deep link seeded with an AUTHORED `promptText` — the prompt is always consumer-supplied data, never derived from page context (headline, entity name, etc.). | `import { AskAiProviderRow } from "@working-theory/ui/blocks"` |
137
- | `AssetLightbox` | The asset detail lightbox: the image with zoom on the stage, a Details rail with in-place pencil-edit Title and Description, then Filename/Type/Size/Dimensions/Created, then Tags (from the seeded set the caller passes), then Copy URL/Replace/Delete. Renders inside a ResponsiveSheet — the SAME field sequence in both its dialog and sheet forms (: one field array, walked once, shared by both forms). | `import { AssetLightbox } from "@working-theory/ui/blocks"` |
137
+ | `AssetLightbox` | The asset detail lightbox: the image with zoom on the stage, a Details rail with in-place pencil-edit Title and Description, then Filename/Type/Size/Dimensions/Created, then Tags (from the seeded set the caller passes), then Copy link/Replace/Delete. Renders inside a ResponsiveSheet — the SAME field sequence in both layouts (: one field array, walked once, shared by both). | `import { AssetLightbox } from "@working-theory/ui/blocks"` |
138
138
  | `AuthCard` | Multi-flow auth card: sign-in, sign-up, invitation, forgot, reset and sent, from one skeleton. One self-contained card renders every auth surface a site needs: signing in, signing up, a closed sign-up that requires an invitation, requesting a password reset, choosing a new password, and the check-your-email state that follows a magic link or a reset request. | `import { AuthCard } from "@working-theory/ui/blocks"` |
139
139
  | `AwardBadgePlaceholder` | The labeled placeholder an Awards badge slot renders when no image has been supplied — mirrors MediaPlaceholder's role but in an award-chevron/ laurel badge silhouette rather than a generic photo frame, since an unfilled award slot should read as "an award badge goes here," not "a photo goes here." The shape (a laurel-flanked medallion over a chevron ribbon) is a hand-drawn SVG outline — never a copied G2/Capterra/Apollo asset. | internal — compose from `packages/ui/src/blocks/AwardBadgePlaceholder.tsx` |
140
140
  | `BackgroundPattern` | Decorative SVG dot-grid / line pattern for ContactForm section. Decorative block (generalized beyond its original single consumer). Renders an absolutely-positioned decoration behind a section's content. Originally the pattern anchored behind ContactForm's form column; SpotlightPanel composes it as a full-bleed section backdrop (dimmed + blurred, behind a centered card) — same block, a second placement. | `import { BackgroundPattern } from "@working-theory/ui/blocks"` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
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.33.0",
31
+ "@working-theory/templates": "0.33.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file content-write-production-boot.test.ts
3
+ * @description A production build with `content-write` enabled boots without
4
+ * pino-pretty (#7502).
5
+ *
6
+ * `services/revalidation.ts` is loaded only by the content-write routes, and it
7
+ * used to build its logger with `createLogger({ service })` alone. The
8
+ * observability package read a missing `environment` as development and asked
9
+ * pino for a `pino-pretty` transport, which a production install does not carry:
10
+ * pino threw `unable to determine transport target for "pino-pretty"` at module
11
+ * load, `build()` rejected, and every route — `/health` included — answered 500.
12
+ *
13
+ * "Without pino-pretty installed" is simulated at the pino boundary: the mock
14
+ * below throws pino's own error whenever a `pino-pretty` transport is requested,
15
+ * and passes every other call through to the real pino. pino is mocked by its
16
+ * resolved path because `apps/api` does not depend on it directly — only
17
+ * `@working-theory/observability` does.
18
+ *
19
+ * Mirrors `module-exclusion.test.ts`'s external-boundary mocks (CORS,
20
+ * rate-limit, email).
21
+ */
22
+
23
+ import type { FastifyInstance } from "fastify";
24
+
25
+ jest.mock(
26
+ require.resolve("pino", {
27
+ paths: [`${__dirname}/../../../../packages/observability`],
28
+ }),
29
+ () => {
30
+ const realPino = jest.requireActual(
31
+ require.resolve("pino", {
32
+ paths: [`${__dirname}/../../../../packages/observability`],
33
+ }),
34
+ );
35
+ const withoutPinoPretty = (
36
+ options?: { transport?: { target?: string } },
37
+ ...rest: unknown[]
38
+ ) => {
39
+ if (options?.transport?.target === "pino-pretty") {
40
+ throw new Error(
41
+ 'unable to determine transport target for "pino-pretty"',
42
+ );
43
+ }
44
+ return realPino(options, ...rest);
45
+ };
46
+ // pino exports itself as `default` and as `pino` too; both are replaced.
47
+ return Object.assign(withoutPinoPretty, realPino, {
48
+ default: withoutPinoPretty,
49
+ pino: withoutPinoPretty,
50
+ });
51
+ },
52
+ );
53
+
54
+ jest.mock("@fastify/cors", () => {
55
+ return jest.fn().mockResolvedValue(undefined);
56
+ });
57
+
58
+ jest.mock("@fastify/rate-limit", () => {
59
+ return jest.fn().mockResolvedValue(undefined);
60
+ });
61
+
62
+ jest.mock("@working-theory/email", () => ({
63
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
64
+ }));
65
+
66
+ describe("production boot with content-write enabled (#7502)", () => {
67
+ const prior = {
68
+ NODE_ENV: process.env.NODE_ENV,
69
+ AILK_ENABLED_MODULES: process.env.AILK_ENABLED_MODULES,
70
+ };
71
+ let app: FastifyInstance;
72
+
73
+ beforeAll(async () => {
74
+ process.env.NODE_ENV = "production";
75
+ // content-write registers only alongside a tenancy module (server.ts).
76
+ process.env.AILK_ENABLED_MODULES = "multi-tenant,content-write";
77
+ // The config and the observability singleton read NODE_ENV at import, so
78
+ // the server is loaded only after the environment is set.
79
+ const { build } = await import("../server");
80
+ app = await build();
81
+ });
82
+
83
+ afterAll(async () => {
84
+ await app?.close();
85
+ for (const [key, value] of Object.entries(prior)) {
86
+ if (value === undefined) delete process.env[key];
87
+ else process.env[key] = value;
88
+ }
89
+ });
90
+
91
+ it("answers /health with 200", async () => {
92
+ const res = await app.inject({ method: "GET", url: "/health" });
93
+ expect(res.statusCode).toBe(200);
94
+ });
95
+
96
+ it("registers the content-write routes, so revalidation.ts was loaded", async () => {
97
+ // Registered and session-less: 401, where an unregistered route is a 404.
98
+ const res = await app.inject({
99
+ method: "POST",
100
+ url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/content",
101
+ });
102
+ expect(res.statusCode).toBe(401);
103
+ });
104
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * @file root-modules.test.ts
3
+ * @description `GET /` reports the optional modules this process registered
4
+ * routes for (#7501), so the Admin can hide what a build does not serve.
5
+ *
6
+ * Builds the REAL server under several `AILK_ENABLED_MODULES` values and reads
7
+ * the root index over `inject`. `modules` must agree with the route table
8
+ * Fastify actually registered — the content and meetings reads the Admin makes
9
+ * are present exactly when their module is listed.
10
+ *
11
+ * Mirrors `src/openapi/__tests__/surface-drift.test.ts`'s external-boundary
12
+ * mocks (CORS, rate-limit, email) — see that file's header for why each is
13
+ * mocked.
14
+ */
15
+
16
+ jest.mock("@fastify/cors", () => {
17
+ return jest.fn().mockResolvedValue(undefined);
18
+ });
19
+
20
+ jest.mock("@fastify/rate-limit", () => {
21
+ return jest.fn().mockResolvedValue(undefined);
22
+ });
23
+
24
+ jest.mock("@working-theory/email", () => ({
25
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
26
+ }));
27
+
28
+ import { execFileSync } from "node:child_process";
29
+ import path from "node:path";
30
+ import { build } from "../server";
31
+ import { readRegisteredRoutes } from "../openapi/spec";
32
+ import { routeKey } from "../openapi/surface-drift";
33
+
34
+ const CONTENT_READ = routeKey("GET", "/v1/workspaces/:workspaceId/content");
35
+ const MEETINGS_READ = routeKey("GET", "/v1/workspaces/:workspaceId/meetings");
36
+
37
+ /** Every route the Admin reads under `multi-tenant` alone (spec §1 fact 4). */
38
+ const ADMIN_MULTI_TENANT_READS = [
39
+ routeKey("GET", "/v1/workspaces"),
40
+ routeKey("GET", "/v1/billing"),
41
+ routeKey("GET", "/v1/project-listings"),
42
+ routeKey("GET", "/v1/workspaces/:workspaceId/messages"),
43
+ routeKey("GET", "/v1/workspaces/:workspaceId/magnets"),
44
+ routeKey("GET", "/v1/workspaces/:workspaceId/assets"),
45
+ routeKey("GET", "/v1/workspaces/:workspaceId/sessions"),
46
+ routeKey("GET", "/v1/sessions/:sessionId/artifacts"),
47
+ ];
48
+
49
+ const CONFIGS: Array<[string, string | undefined, string[]]> = [
50
+ ["unset", undefined, []],
51
+ ["multi-tenant", "multi-tenant", ["multi-tenant"]],
52
+ [
53
+ "scheduling, content-write,multi-tenant",
54
+ "scheduling, content-write,multi-tenant",
55
+ ["content-write", "multi-tenant", "scheduling"],
56
+ ],
57
+ ["content-write alone", "content-write", []],
58
+ [
59
+ "multi-tenant,bogus,signal-collectors",
60
+ "multi-tenant,bogus,signal-collectors",
61
+ ["multi-tenant", "signal-collectors"],
62
+ ],
63
+ // `docs` registers no apps/api route, so it is never reported as served.
64
+ ["docs,multi-tenant", "docs,multi-tenant", ["multi-tenant"]],
65
+ ];
66
+
67
+ function setEnv(value: string | undefined): string | undefined {
68
+ const prior = process.env.AILK_ENABLED_MODULES;
69
+ if (value === undefined) {
70
+ delete process.env.AILK_ENABLED_MODULES;
71
+ } else {
72
+ process.env.AILK_ENABLED_MODULES = value;
73
+ }
74
+ return prior;
75
+ }
76
+
77
+ describe("GET / modules (#7501)", () => {
78
+ let prior: string | undefined;
79
+
80
+ beforeEach(() => {
81
+ prior = process.env.AILK_ENABLED_MODULES;
82
+ });
83
+
84
+ afterEach(() => {
85
+ setEnv(prior);
86
+ });
87
+
88
+ describe.each(CONFIGS)("AILK_ENABLED_MODULES = %s", (_label, env, expected) => {
89
+ let modules: unknown;
90
+ let routes: Set<string>;
91
+
92
+ beforeAll(async () => {
93
+ const before = setEnv(env);
94
+ try {
95
+ const app = await build();
96
+ await app.ready();
97
+ const res = await app.inject({ method: "GET", url: "/" });
98
+ routes = new Set(
99
+ readRegisteredRoutes(app).map((r) => routeKey(r.method, r.url)),
100
+ );
101
+ await app.close();
102
+ expect(res.statusCode).toBe(200);
103
+ modules = JSON.parse(res.body).modules;
104
+ } finally {
105
+ setEnv(before);
106
+ }
107
+ }, 60000);
108
+
109
+ it("AC-1: lists exactly the served optional modules, sorted", () => {
110
+ expect(modules).toEqual(expected);
111
+ });
112
+
113
+ it("AC-2: content-write is listed exactly when the content read registered", () => {
114
+ expect((modules as string[]).includes("content-write")).toBe(
115
+ routes.has(CONTENT_READ),
116
+ );
117
+ });
118
+
119
+ it("AC-2: scheduling is listed exactly when the meetings read registered", () => {
120
+ expect((modules as string[]).includes("scheduling")).toBe(
121
+ routes.has(MEETINGS_READ),
122
+ );
123
+ });
124
+ });
125
+
126
+ it("AC-1: a built server keeps its module list after the env changes", async () => {
127
+ const before = setEnv("multi-tenant,content-write");
128
+ let app: Awaited<ReturnType<typeof build>>;
129
+ try {
130
+ app = await build();
131
+ await app.ready();
132
+ } finally {
133
+ setEnv(before);
134
+ }
135
+ process.env.AILK_ENABLED_MODULES = "";
136
+ try {
137
+ const res = await app.inject({ method: "GET", url: "/" });
138
+ expect(JSON.parse(res.body).modules).toEqual([
139
+ "content-write",
140
+ "multi-tenant",
141
+ ]);
142
+ } finally {
143
+ await app.close();
144
+ }
145
+ }, 60000);
146
+
147
+ it("AC-2: multi-tenant alone registers every route the Admin reads, bar content and meetings", async () => {
148
+ const before = setEnv("multi-tenant");
149
+ try {
150
+ const app = await build();
151
+ await app.ready();
152
+ const routes = new Set(
153
+ readRegisteredRoutes(app).map((r) => routeKey(r.method, r.url)),
154
+ );
155
+ await app.close();
156
+ for (const read of ADMIN_MULTI_TENANT_READS) {
157
+ expect(routes).toContain(read);
158
+ }
159
+ expect(routes).not.toContain(CONTENT_READ);
160
+ expect(routes).not.toContain(MEETINGS_READ);
161
+ } finally {
162
+ setEnv(before);
163
+ }
164
+ }, 60000);
165
+
166
+ it("AC-2: pnpm lint:slice-registration exits 0", () => {
167
+ const repoRoot = path.resolve(__dirname, "../../../..");
168
+ expect(() =>
169
+ execFileSync("pnpm", ["lint:slice-registration"], {
170
+ cwd: repoRoot,
171
+ stdio: "pipe",
172
+ }),
173
+ ).not.toThrow();
174
+ }, 120000);
175
+ });
@@ -43,3 +43,41 @@ function enabledModules(): Set<string> {
43
43
  export function isModuleEnabled(moduleName: string): boolean {
44
44
  return enabledModules().has(moduleName);
45
45
  }
46
+
47
+ /**
48
+ * The recognized optional-module keys, sorted — the vocabulary the root index
49
+ * may report (#7501). Filtering to this list is what keeps the anonymous
50
+ * `GET /` from echoing whatever text an operator typed into
51
+ * `AILK_ENABLED_MODULES`. `docs` is deliberately absent: no apps/api route
52
+ * registers under it, so reporting it would break "modules this process
53
+ * registered routes for".
54
+ */
55
+ export const OPTIONAL_MODULES = [
56
+ "commerce",
57
+ "content-write",
58
+ "multi-tenant",
59
+ "scheduling",
60
+ "signal-collectors",
61
+ "workspace",
62
+ ] as const;
63
+
64
+ /**
65
+ * The optional modules whose routes this process registers, sorted. Called
66
+ * once at build time by `server.ts`, so the answer matches what registered
67
+ * even if the env changes afterwards.
68
+ *
69
+ * `content-write` counts only alongside a tenancy module (`workspace` or
70
+ * `multi-tenant`) — the exact predicate `server.ts` registers the editor's
71
+ * content routes under. That gate stays a literal there (the
72
+ * slice-registration lint reads `isModuleEnabled("X")` guards by text), so the
73
+ * predicate is repeated here and `root-modules.test.ts` asserts they agree.
74
+ */
75
+ export function servedModules(): string[] {
76
+ return OPTIONAL_MODULES.filter((name) => {
77
+ if (!isModuleEnabled(name)) return false;
78
+ if (name === "content-write") {
79
+ return isModuleEnabled("workspace") || isModuleEnabled("multi-tenant");
80
+ }
81
+ return true;
82
+ });
83
+ }
@@ -21,7 +21,7 @@ import type {
21
21
  } from "fastify";
22
22
 
23
23
  import { config } from "./config/index.js";
24
- import { isModuleEnabled } from "./config/modules.js";
24
+ import { isModuleEnabled, servedModules } from "./config/modules.js";
25
25
  import { registerErrorSerializer } from "./lib/error-serializer.js";
26
26
  import { validateLeadCaptureDir } from "./lib/lead-capture-store.js";
27
27
  import { registerLicenseKeyStore } from "./lib/license-key-store.js";
@@ -722,12 +722,19 @@ export async function build() {
722
722
  // Root endpoint — introduces this deployment as the SITE it serves, not as
723
723
  // the kit it was built with (#5307). `_site.mdx` is read once per process;
724
724
  // a site without one degrades to a neutral name, never to the kit's.
725
+ //
726
+ // `modules` (#7501) is the optional modules whose routes this build
727
+ // registers, computed once here — before any route registers — so the index
728
+ // reports what registered rather than re-reading the env per request. The
729
+ // Admin reads it to hide the surfaces whose module is off.
730
+ const modules = servedModules();
725
731
  server.get("/", async () => {
726
732
  const identity = resolveApiIdentity();
727
733
  return {
728
734
  name: apiDisplayName(identity),
729
735
  version: API_VERSION,
730
736
  description: apiDescription(identity),
737
+ modules,
731
738
  // Derived from the routes this process actually registered (#5314),
732
739
  // not a hand-maintained literal. The literal listed 11 endpoints against
733
740
  // 68 registered routes, omitted `/v1/project-listings/public` and
@@ -311,3 +311,22 @@ describe("notifyRevalidate", () => {
311
311
  });
312
312
  });
313
313
  });
314
+
315
+ describe("logger environment (#7502)", () => {
316
+ it.each(["production", "development"])(
317
+ "builds its logger for NODE_ENV=%s, as the api's main logger does",
318
+ (nodeEnv) => {
319
+ process.env.NODE_ENV = nodeEnv;
320
+
321
+ loadService();
322
+
323
+ const { createLogger } = require("@working-theory/observability") as {
324
+ createLogger: jest.Mock;
325
+ };
326
+ expect(createLogger).toHaveBeenCalledWith({
327
+ service: "revalidation",
328
+ environment: nodeEnv,
329
+ });
330
+ },
331
+ );
332
+ });
@@ -49,7 +49,14 @@ import { z } from "zod";
49
49
  import { config } from "../config/index.js";
50
50
  import { outboundFetch } from "../lib/outbound-http.js";
51
51
 
52
- const log = createLogger({ service: "revalidation" });
52
+ // The environment is the api's own NODE_ENV, as the observability `logger`
53
+ // singleton reads it. Omitted, createLogger assumes development and asks for a
54
+ // pino-pretty transport a production install does not carry — a throw at load
55
+ // that took the whole api down once content-write was enabled (#7502).
56
+ const log = createLogger({
57
+ service: "revalidation",
58
+ environment: config.NODE_ENV,
59
+ });
53
60
 
54
61
  const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
55
62
  const EVENT_HEADER = "x-ailk-revalidate-event";
@@ -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",
@@ -767,39 +768,63 @@
767
768
  "notFoundHeadline": "Page not found",
768
769
  "notFoundDescription": "There is no page at this address. It may have been renamed or removed.",
769
770
  "editor": {
770
- "tabForm": "Details",
771
- "tabBody": "Body",
772
771
  "tabPreview": "Preview",
773
- "tabCode": "Code",
772
+ "toolChanges": "Show unsaved changes",
773
+ "backLabel": "Pages",
774
774
  "statusDraft": "Draft",
775
775
  "statusNotYetLive": "Not yet live",
776
776
  "statusPublished": "Published",
777
- "frontmatterHeading": "Page details",
777
+ "publishedSwitch": "Published",
778
+ "publishBlockedHint": "Fix the items under For you before publishing.",
779
+ "unsaved": "Unsaved",
780
+ "unsavedChanges": "Unsaved changes",
781
+ "discardButton": "Discard",
782
+ "loading": "Loading…",
783
+ "loadError": "Could not load this page. Try again.",
784
+ "retryButton": "Try again",
778
785
  "frontmatterTitle": "Title",
779
786
  "frontmatterSlug": "Slug",
780
787
  "frontmatterDescription": "Description",
781
- "issuesHeading": "Fix these before publishing",
782
- "saveButton": "Save draft",
788
+ "titlePlaceholder": "Page title",
789
+ "fieldPageType": "Page type",
790
+ "toolbarLabel": "Formatting",
791
+ "formatMenu": "Page actions",
792
+ "closeSheet": "Close",
793
+ "toolH1": "Heading 1",
794
+ "toolH2": "Heading 2",
795
+ "toolH3": "Heading 3",
796
+ "toolBold": "Bold",
797
+ "toolItalic": "Italic",
798
+ "toolBulletList": "Bulleted list",
799
+ "toolNumberedList": "Numbered list",
800
+ "toolLink": "Link",
801
+ "toolImage": "Insert image",
802
+ "issueRequired": "{field} is required.",
803
+ "issueInvalid": "{field} isn't a valid choice.",
804
+ "forYou": {
805
+ "typeFix": "Blocks publish",
806
+ "typeSuggestion": "Suggestion",
807
+ "fixWhy": "The page can't go live until this is fixed.",
808
+ "fixOutcome": "Once it is fixed, the Published switch turns on.",
809
+ "descriptionTitle": "Add a search description",
810
+ "descriptionWhy": "Search engines and AI answers show this line under the page's title. Without it they write their own.",
811
+ "descriptionOutcome": "A sentence you chose appears wherever this page is listed.",
812
+ "altTitle": "{count, plural, one {Describe # image} other {Describe # images}}",
813
+ "altWhy": "Screen readers and search engines can't see an image without alt text.",
814
+ "altOutcome": "Every reader gets the image's meaning, and the page is easier to find."
815
+ },
816
+ "saveButton": "Save",
783
817
  "saving": "Saving…",
784
818
  "saved": "Saved",
785
819
  "saveError": "Could not save. Try again.",
786
- "publishButton": "Publish",
787
- "publishing": "Publishing…",
788
- "published": "Published",
789
820
  "publishError": "Could not publish. Try again.",
790
- "unpublishButton": "Unpublish",
791
- "unpublishing": "Unpublishing…",
792
- "unpublished": "Unpublished",
793
821
  "unpublishError": "Could not unpublish. Try again.",
794
822
  "conflictHeading": "Someone else saved this page",
795
823
  "conflictMessage": "This page changed since you opened it. Reload to take their version, or overwrite it with yours.",
796
824
  "conflictReload": "Reload their version",
797
825
  "conflictOverwrite": "Overwrite with mine",
798
- "imageInsertLabel": "Insert an image",
799
- "imageInsertHint": "The image is uploaded and its markdown inserted at the cursor.",
800
826
  "imageAlt": "Image",
801
827
  "imageInsertError": "Could not upload that image. Try again.",
802
- "codeHeading": "Composed MDX",
803
828
  "diffLegendAdded": "Added",
804
829
  "diffLegendRemoved": "Removed",
805
830
  "diffLegendUnchanged": "Unchanged"
@@ -1,5 +1,19 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.33.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`bf2c0a7`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/bf2c0a722c08974f6a199e9f8bf3d837a100be58)]:
8
+ - @working-theory/validation@0.33.0
9
+
10
+ ## 0.32.0
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies []:
15
+ - @working-theory/validation@0.32.0
16
+
3
17
  ## 0.31.0
4
18
 
5
19
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@working-theory/database",
3
- "version": "0.31.0",
3
+ "version": "0.33.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.33.0",
3
+ "@working-theory/aeo": "0.33.0",
4
+ "@working-theory/agent-context": "0.33.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.33.0",
9
+ "@working-theory/api-client": "0.33.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.33.0",
12
+ "@working-theory/common": "0.33.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.33.0",
15
+ "@working-theory/content-projection": "0.33.0",
16
+ "@working-theory/email": "0.33.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.33.0",
19
+ "@working-theory/fumadocs-schema": "0.33.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.33.0",
22
+ "@working-theory/license": "0.33.0",
23
+ "@working-theory/metadata": "0.33.0",
24
+ "@working-theory/observability": "0.33.0",
25
+ "@working-theory/page-renderer": "0.33.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.33.0",
28
+ "@working-theory/schema-pack-registry": "0.33.0",
29
+ "@working-theory/schema-validator": "0.33.0",
30
+ "@working-theory/sections-registry": "0.33.0",
31
+ "@working-theory/site-config": "0.33.0",
32
+ "@working-theory/stripe-client": "0.33.0",
33
+ "@working-theory/templates": "0.33.0",
34
+ "@working-theory/theme": "0.33.0",
35
+ "@working-theory/ui": "0.33.0",
36
+ "@working-theory/validation": "0.33.0",
37
+ "@working-theory/web-i18n": "0.33.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.10",
45
+ "@working-theory/ui-docs-blog": "0.23.10",
46
+ "@working-theory/ui-scheduling": "0.23.10",
47
+ "@working-theory/ui-signal-collectors": "0.24.8",
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.33.0"
52
52
  }