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.
- package/component-catalog.json +1 -1
- package/component-catalog.md +1 -1
- package/package.json +3 -3
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +104 -0
- package/templates/apps/api/src/__tests__/root-modules.test.ts +175 -0
- package/templates/apps/api/src/config/modules.ts +38 -0
- package/templates/apps/api/src/server.ts +8 -1
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +19 -0
- package/templates/apps/api/src/services/revalidation.ts +8 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +81 -6
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -23
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +31 -5
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +2 -2
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +5 -4
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +64 -40
- package/templates/apps/web/messages/en.json +40 -15
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
package/component-catalog.json
CHANGED
|
@@ -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
|
|
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
|
package/component-catalog.md
CHANGED
|
@@ -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
|
|
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
|
+
"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
|
-
"@working-theory/templates": "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
|
-
|
|
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:
|
|
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(
|
|
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]).
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
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
|
|
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={[
|
|
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
|
|
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
|
|
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 ·
|
|
11
|
-
* renders no marketing frame and no consent Banner on
|
|
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
|
|
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
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
{/*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
|
|
112
|
-
<
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
"
|
|
772
|
+
"toolChanges": "Show unsaved changes",
|
|
773
|
+
"backLabel": "Pages",
|
|
774
774
|
"statusDraft": "Draft",
|
|
775
775
|
"statusNotYetLive": "Not yet live",
|
|
776
776
|
"statusPublished": "Published",
|
|
777
|
-
"
|
|
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
|
-
"
|
|
782
|
-
"
|
|
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
|
package/versions.json
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@working-theory/admin-ui": "0.
|
|
3
|
-
"@working-theory/aeo": "0.
|
|
4
|
-
"@working-theory/agent-context": "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.
|
|
9
|
-
"@working-theory/api-client": "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.
|
|
12
|
-
"@working-theory/common": "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.
|
|
15
|
-
"@working-theory/content-projection": "0.
|
|
16
|
-
"@working-theory/email": "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.
|
|
19
|
-
"@working-theory/fumadocs-schema": "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.
|
|
22
|
-
"@working-theory/license": "0.
|
|
23
|
-
"@working-theory/metadata": "0.
|
|
24
|
-
"@working-theory/observability": "0.
|
|
25
|
-
"@working-theory/page-renderer": "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.
|
|
28
|
-
"@working-theory/schema-pack-registry": "0.
|
|
29
|
-
"@working-theory/schema-validator": "0.
|
|
30
|
-
"@working-theory/sections-registry": "0.
|
|
31
|
-
"@working-theory/site-config": "0.
|
|
32
|
-
"@working-theory/stripe-client": "0.
|
|
33
|
-
"@working-theory/templates": "0.
|
|
34
|
-
"@working-theory/theme": "0.
|
|
35
|
-
"@working-theory/ui": "0.
|
|
36
|
-
"@working-theory/validation": "0.
|
|
37
|
-
"@working-theory/web-i18n": "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.
|
|
45
|
-
"@working-theory/ui-docs-blog": "0.23.
|
|
46
|
-
"@working-theory/ui-scheduling": "0.23.
|
|
47
|
-
"@working-theory/ui-signal-collectors": "0.24.
|
|
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.
|
|
51
|
+
"@working-theory/database": "0.33.0"
|
|
52
52
|
}
|