@pylonsync/create-pylon 0.11.1 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/_root/AGENTS.md +1 -1
  4. package/templates/agency/AGENTS.md +1 -1
  5. package/templates/agency/app/(marketing)/contact-form.tsx +22 -32
  6. package/templates/agency/app/(marketing)/layout.tsx +36 -64
  7. package/templates/agency/app/(marketing)/not-found.tsx +6 -8
  8. package/templates/agency/app/(marketing)/page.tsx +93 -144
  9. package/templates/agency/app/(marketing)/work/[slug]/page.tsx +69 -71
  10. package/templates/agency/app/(marketing)/work/page.tsx +20 -17
  11. package/templates/agency/app/globals.css +15 -6
  12. package/templates/agency/app.ts +10 -0
  13. package/templates/agency/components/marketing.tsx +40 -104
  14. package/templates/agency/lib/site.config.ts +34 -52
  15. package/templates/agency/public/images/hero.jpg +0 -0
  16. package/templates/agency/public/images/team/claire-donovan.jpg +0 -0
  17. package/templates/agency/public/images/team/dana-okafor.jpg +0 -0
  18. package/templates/agency/public/images/team/marcus-lee.jpg +0 -0
  19. package/templates/agency/public/images/work/atlas-health.jpg +0 -0
  20. package/templates/agency/public/images/work/cohort.jpg +0 -0
  21. package/templates/agency/public/images/work/ledger.jpg +0 -0
  22. package/templates/agency/public/images/work/vela.jpg +0 -0
  23. package/templates/ai-chat/AGENTS.md +1 -1
  24. package/templates/ai-studio/AGENTS.md +1 -1
  25. package/templates/barebones/AGENTS.md +1 -1
  26. package/templates/chat/AGENTS.md +1 -1
  27. package/templates/consumer/AGENTS.md +1 -1
  28. package/templates/consumer/README.md +39 -23
  29. package/templates/consumer/app/(app)/explore/page.tsx +13 -0
  30. package/templates/consumer/app/(app)/layout.tsx +17 -0
  31. package/templates/consumer/app/(app)/p/[id]/page.tsx +13 -0
  32. package/templates/consumer/app/(app)/page.tsx +15 -0
  33. package/templates/consumer/app/(app)/u/[username]/page.tsx +14 -0
  34. package/templates/consumer/app/error.tsx +1 -1
  35. package/templates/consumer/app/globals.css +25 -0
  36. package/templates/consumer/app/layout.tsx +3 -10
  37. package/templates/consumer/app/llms.ts +10 -10
  38. package/templates/consumer/app/not-found.tsx +9 -22
  39. package/templates/consumer/app/robots.ts +3 -4
  40. package/templates/consumer/app/sitemap.ts +7 -21
  41. package/templates/consumer/app.ts +152 -33
  42. package/templates/consumer/components/social/app-chrome.tsx +109 -0
  43. package/templates/consumer/components/social/avatar.tsx +50 -0
  44. package/templates/consumer/components/social/comment-form.tsx +51 -0
  45. package/templates/consumer/components/social/create-post.tsx +155 -0
  46. package/templates/consumer/components/social/edit-profile.tsx +103 -0
  47. package/templates/consumer/components/social/explore-screen.tsx +23 -0
  48. package/templates/consumer/components/social/home-screen.tsx +161 -0
  49. package/templates/consumer/components/social/post-card.tsx +77 -0
  50. package/templates/consumer/components/social/post-grid.tsx +46 -0
  51. package/templates/consumer/components/social/post-parts.tsx +146 -0
  52. package/templates/consumer/components/social/post-screen.tsx +173 -0
  53. package/templates/consumer/components/social/profile-screen.tsx +146 -0
  54. package/templates/consumer/components/social/rich-text.tsx +22 -0
  55. package/templates/consumer/components/social/session.tsx +43 -0
  56. package/templates/consumer/components/social/use-social.ts +86 -0
  57. package/templates/consumer/functions/addComment.ts +27 -0
  58. package/templates/consumer/functions/createPost.ts +39 -0
  59. package/templates/consumer/functions/ensureProfile.ts +39 -0
  60. package/templates/consumer/functions/seedFeed.ts +38 -0
  61. package/templates/consumer/functions/updateProfile.ts +61 -0
  62. package/templates/consumer/lib/seed.ts +192 -0
  63. package/templates/consumer/lib/site.ts +6 -0
  64. package/templates/consumer/lib/social.ts +245 -0
  65. package/templates/consumer/tests/social.test.ts +157 -0
  66. package/templates/creator/AGENTS.md +1 -1
  67. package/templates/creator/app/(marketing)/layout.tsx +35 -69
  68. package/templates/creator/app/(marketing)/newsletter-signup.tsx +15 -26
  69. package/templates/creator/app/(marketing)/not-found.tsx +8 -6
  70. package/templates/creator/app/(marketing)/page.tsx +90 -106
  71. package/templates/creator/app/globals.css +14 -0
  72. package/templates/creator/app/layout.tsx +1 -0
  73. package/templates/creator/app.ts +10 -0
  74. package/templates/creator/components/marketing.tsx +28 -89
  75. package/templates/creator/lib/site.config.ts +51 -29
  76. package/templates/creator/public/images/headshot.jpg +0 -0
  77. package/templates/crm/AGENTS.md +1 -1
  78. package/templates/crm/lib/seed.ts +153 -21
  79. package/templates/directory/AGENTS.md +1 -1
  80. package/templates/directory/app/(marketing)/directory-browse.tsx +99 -137
  81. package/templates/directory/app/(marketing)/layout.tsx +37 -88
  82. package/templates/directory/app/(marketing)/not-found.tsx +11 -10
  83. package/templates/directory/app/(marketing)/page.tsx +12 -33
  84. package/templates/directory/app/(marketing)/submit/page.tsx +9 -10
  85. package/templates/directory/app/(marketing)/submit-form.tsx +26 -26
  86. package/templates/directory/app/globals.css +15 -4
  87. package/templates/directory/app.ts +10 -0
  88. package/templates/directory/components/marketing.tsx +27 -91
  89. package/templates/directory/lib/site.config.ts +13 -24
  90. package/templates/helpdesk/AGENTS.md +1 -1
  91. package/templates/helpdesk/lib/seed.ts +188 -104
  92. package/templates/inventory/AGENTS.md +1 -1
  93. package/templates/inventory/lib/seed.ts +175 -44
  94. package/templates/invoices/AGENTS.md +1 -1
  95. package/templates/invoices/lib/seed.ts +74 -89
  96. package/templates/local-service/AGENTS.md +1 -1
  97. package/templates/local-service/app/(marketing)/booking-widget.tsx +31 -31
  98. package/templates/local-service/app/(marketing)/layout.tsx +25 -37
  99. package/templates/local-service/app/(marketing)/not-found.tsx +4 -4
  100. package/templates/local-service/app/(marketing)/page.tsx +145 -139
  101. package/templates/local-service/app/globals.css +11 -0
  102. package/templates/local-service/app/layout.tsx +2 -0
  103. package/templates/local-service/app.ts +9 -0
  104. package/templates/local-service/components/marketing.tsx +14 -39
  105. package/templates/local-service/lib/site.config.ts +16 -23
  106. package/templates/local-service/public/images/hero.jpg +0 -0
  107. package/templates/marketplace/AGENTS.md +1 -1
  108. package/templates/marketplace/client/SellForm.tsx +2 -0
  109. package/templates/projects/AGENTS.md +1 -1
  110. package/templates/projects/lib/seed.ts +103 -32
  111. package/templates/restaurant/AGENTS.md +1 -1
  112. package/templates/restaurant/app/(marketing)/layout.tsx +26 -48
  113. package/templates/restaurant/app/(marketing)/page.tsx +146 -123
  114. package/templates/restaurant/app/globals.css +11 -0
  115. package/templates/restaurant/app/layout.tsx +1 -0
  116. package/templates/restaurant/app.ts +10 -0
  117. package/templates/restaurant/components/marketing.tsx +10 -0
  118. package/templates/restaurant/lib/site.config.ts +16 -14
  119. package/templates/restaurant/public/images/bar.jpg +0 -0
  120. package/templates/restaurant/public/images/branzino.jpg +0 -0
  121. package/templates/restaurant/public/images/hearth.jpg +0 -0
  122. package/templates/restaurant/public/images/hero.jpg +0 -0
  123. package/templates/saas/AGENTS.md +1 -1
  124. package/templates/shop/AGENTS.md +1 -1
  125. package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
  126. package/templates/shop/public/images/products/linen.jpg +0 -0
  127. package/templates/todo/AGENTS.md +1 -1
  128. package/templates/waitlist/AGENTS.md +1 -1
  129. package/templates/waitlist/app/(marketing)/layout.tsx +39 -85
  130. package/templates/waitlist/app/(marketing)/not-found.tsx +8 -9
  131. package/templates/waitlist/app/(marketing)/page.tsx +51 -86
  132. package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +43 -83
  133. package/templates/waitlist/app/globals.css +23 -0
  134. package/templates/waitlist/app/layout.tsx +1 -0
  135. package/templates/waitlist/app.ts +18 -0
  136. package/templates/waitlist/components/marketing.tsx +67 -81
  137. package/templates/waitlist/lib/site.config.ts +52 -59
  138. package/templates/consumer/app/feed-client.tsx +0 -159
  139. package/templates/consumer/app/page.tsx +0 -27
  140. package/templates/consumer/functions/_keep.ts +0 -13
  141. package/templates/consumer/functions/seedPosts.ts +0 -56
  142. package/templates/consumer/tests/example.test.ts +0 -12
@@ -0,0 +1,245 @@
1
+ // The social model: usernames, counts, time labels, and which posts a
2
+ // person sees. Pure — no React, no `db` — so the rules are testable without a
3
+ // server, and the components stay thin.
4
+
5
+ export interface Profile {
6
+ id: string;
7
+ userId: string;
8
+ username: string;
9
+ displayName: string;
10
+ bio?: string | null;
11
+ avatarUrl?: string | null;
12
+ createdAt?: string | null;
13
+ }
14
+
15
+ export interface Post {
16
+ id: string;
17
+ authorId: string;
18
+ imageUrl: string;
19
+ caption?: string | null;
20
+ /** "square" (1:1) or "portrait" (4:5). The feed crops to this shape. */
21
+ shape?: string | null;
22
+ createdAt: string;
23
+ }
24
+
25
+ export interface Like {
26
+ id: string;
27
+ userId: string;
28
+ postId: string;
29
+ }
30
+
31
+ export interface Comment {
32
+ id: string;
33
+ postId: string;
34
+ authorId: string;
35
+ text: string;
36
+ createdAt: string;
37
+ }
38
+
39
+ export interface Follow {
40
+ id: string;
41
+ followerId: string;
42
+ followingId: string;
43
+ }
44
+
45
+ export interface Save {
46
+ id: string;
47
+ userId: string;
48
+ postId: string;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Usernames
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export const USERNAME_MIN = 3;
56
+ export const USERNAME_MAX = 30;
57
+ export const CAPTION_MAX = 2200;
58
+ export const COMMENT_MAX = 500;
59
+ export const BIO_MAX = 150;
60
+ export const DISPLAY_NAME_MAX = 40;
61
+
62
+ /** Lowercase, trimmed, a leading @ dropped. Does not validate. */
63
+ export function normalizeUsername(raw: string): string {
64
+ return raw.trim().replace(/^@+/, "").toLowerCase();
65
+ }
66
+
67
+ /**
68
+ * Why a username is refused, or null when it is fine: 3 to 30 characters of
69
+ * letters, digits, periods, and underscores, not starting or ending with a
70
+ * period, and no two periods in a row.
71
+ */
72
+ export function usernameProblem(username: string): string | null {
73
+ if (username.length < USERNAME_MIN) return `Use at least ${USERNAME_MIN} characters.`;
74
+ if (username.length > USERNAME_MAX) return `Use at most ${USERNAME_MAX} characters.`;
75
+ if (!/^[a-z0-9._]+$/.test(username)) return "Use letters, numbers, periods, and underscores only.";
76
+ if (username.startsWith(".") || username.endsWith(".")) return "A username cannot start or end with a period.";
77
+ if (username.includes("..")) return "A username cannot have two periods in a row.";
78
+ return null;
79
+ }
80
+
81
+ /** A stable starting username for a new account, from its user id. */
82
+ export function starterUsername(userId: string): string {
83
+ let hash = 2166136261;
84
+ for (let i = 0; i < userId.length; i++) {
85
+ hash ^= userId.charCodeAt(i);
86
+ hash = Math.imul(hash, 16777619);
87
+ }
88
+ return `visitor_${(hash >>> 0).toString(36).slice(0, 6)}`;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Labels
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /** 999 → "999", 1204 → "1,204", 12_500 → "12.5K", 3_400_000 → "3.4M". */
96
+ export function formatCount(n: number): string {
97
+ if (n < 10_000) return n.toLocaleString("en-US");
98
+ if (n < 1_000_000) return `${trimZero((n / 1000).toFixed(1))}K`;
99
+ return `${trimZero((n / 1_000_000).toFixed(1))}M`;
100
+ }
101
+
102
+ function trimZero(s: string): string {
103
+ return s.endsWith(".0") ? s.slice(0, -2) : s;
104
+ }
105
+
106
+ /** "1 like", "2 likes". */
107
+ export function plural(n: number, one: string, many = `${one}s`): string {
108
+ return `${formatCount(n)} ${n === 1 ? one : many}`;
109
+ }
110
+
111
+ const MINUTE = 60_000;
112
+ const HOUR = 60 * MINUTE;
113
+ const DAY = 24 * HOUR;
114
+ const WEEK = 7 * DAY;
115
+
116
+ /** Short age: "now", "5m", "3h", "4d", "2w", then a date. */
117
+ export function timeAgo(iso: string, now: number = Date.now()): string {
118
+ const then = Date.parse(iso);
119
+ if (Number.isNaN(then)) return "";
120
+ const age = Math.max(0, now - then);
121
+ if (age < MINUTE) return "now";
122
+ if (age < HOUR) return `${Math.floor(age / MINUTE)}m`;
123
+ if (age < DAY) return `${Math.floor(age / HOUR)}h`;
124
+ if (age < WEEK) return `${Math.floor(age / DAY)}d`;
125
+ if (age < 5 * WEEK) return `${Math.floor(age / WEEK)}w`;
126
+ const date = new Date(then);
127
+ const sameYear = date.getUTCFullYear() === new Date(now).getUTCFullYear();
128
+ return date.toLocaleDateString("en-US", {
129
+ month: "short",
130
+ day: "numeric",
131
+ ...(sameYear ? {} : { year: "numeric" }),
132
+ timeZone: "UTC",
133
+ });
134
+ }
135
+
136
+ /**
137
+ * A caption or comment split into text and @mentions, so a mention can link to
138
+ * the profile. Only names that pass `usernameProblem` become links.
139
+ */
140
+ export type TextPart = { kind: "text"; text: string } | { kind: "mention"; username: string };
141
+
142
+ export function splitMentions(text: string): TextPart[] {
143
+ const parts: TextPart[] = [];
144
+ const pattern = /@([a-z0-9._]{3,30})/gi;
145
+ let last = 0;
146
+ for (const match of text.matchAll(pattern)) {
147
+ const username = match[1].toLowerCase().replace(/\.+$/, "");
148
+ if (usernameProblem(username)) continue;
149
+ const start = match.index ?? 0;
150
+ if (start > last) parts.push({ kind: "text", text: text.slice(last, start) });
151
+ parts.push({ kind: "mention", username });
152
+ last = start + 1 + username.length;
153
+ }
154
+ if (last < text.length) parts.push({ kind: "text", text: text.slice(last) });
155
+ return parts;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Who sees what
160
+ // ---------------------------------------------------------------------------
161
+
162
+ export function newestFirst<T extends { createdAt: string }>(rows: T[]): T[] {
163
+ return [...rows].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
164
+ }
165
+
166
+ /** The user ids `userId` follows. */
167
+ export function followingOf(follows: Follow[], userId: string | null | undefined): Set<string> {
168
+ const out = new Set<string>();
169
+ if (!userId) return out;
170
+ for (const f of follows) if (f.followerId === userId) out.add(f.followingId);
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * The home feed: posts by the people you follow and your own, newest first.
176
+ * Someone who follows nobody yet sees every post, so a new account does not
177
+ * open on an empty page.
178
+ */
179
+ export function homeFeed(posts: Post[], following: Set<string>, me: string | null | undefined): { posts: Post[]; everyone: boolean } {
180
+ if (following.size === 0) return { posts: newestFirst(posts), everyone: true };
181
+ return {
182
+ posts: newestFirst(posts.filter((p) => following.has(p.authorId) || p.authorId === me)),
183
+ everyone: false,
184
+ };
185
+ }
186
+
187
+ /** Accounts to suggest: not you, not already followed, most followed first. */
188
+ export function suggestions(
189
+ profiles: Profile[],
190
+ follows: Follow[],
191
+ following: Set<string>,
192
+ me: string | null | undefined,
193
+ limit = 5,
194
+ ): Profile[] {
195
+ const followers = new Map<string, number>();
196
+ for (const f of follows) followers.set(f.followingId, (followers.get(f.followingId) ?? 0) + 1);
197
+ return profiles
198
+ .filter((p) => p.userId !== me && !following.has(p.userId))
199
+ .sort((a, b) => (followers.get(b.userId) ?? 0) - (followers.get(a.userId) ?? 0) || a.username.localeCompare(b.username))
200
+ .slice(0, limit);
201
+ }
202
+
203
+ /** People who posted in the last `hours`, most recent first, one entry each. */
204
+ export function recentPosters(posts: Post[], now: number = Date.now(), hours = 48): string[] {
205
+ const cutoff = now - hours * HOUR;
206
+ const seen = new Set<string>();
207
+ const out: string[] = [];
208
+ for (const post of newestFirst(posts)) {
209
+ if (Date.parse(post.createdAt) < cutoff) break;
210
+ if (seen.has(post.authorId)) continue;
211
+ seen.add(post.authorId);
212
+ out.push(post.authorId);
213
+ }
214
+ return out;
215
+ }
216
+
217
+ export function countBy<T>(rows: T[], key: (row: T) => string): Map<string, number> {
218
+ const out = new Map<string, number>();
219
+ for (const row of rows) {
220
+ const k = key(row);
221
+ out.set(k, (out.get(k) ?? 0) + 1);
222
+ }
223
+ return out;
224
+ }
225
+
226
+ export function groupBy<T>(rows: T[], key: (row: T) => string): Map<string, T[]> {
227
+ const out = new Map<string, T[]>();
228
+ for (const row of rows) {
229
+ const k = key(row);
230
+ const list = out.get(k);
231
+ if (list) list.push(row);
232
+ else out.set(k, [row]);
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /** A photo URL a post may use: an uploaded file, a file in public/, or https. */
238
+ export function isPostImageUrl(url: string): boolean {
239
+ if (url.startsWith("/api/files/") || url.startsWith("/images/")) return !url.includes("..");
240
+ try {
241
+ return new URL(url).protocol === "https:";
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ formatCount,
4
+ homeFeed,
5
+ isPostImageUrl,
6
+ normalizeUsername,
7
+ recentPosters,
8
+ splitMentions,
9
+ starterUsername,
10
+ suggestions,
11
+ timeAgo,
12
+ usernameProblem,
13
+ type Follow,
14
+ type Post,
15
+ type Profile,
16
+ } from "../lib/social";
17
+ import { SEED_COMMENTS, SEED_FOLLOWS, SEED_POSTS, SEED_PROFILES, likersOf, shapeSeed } from "../lib/seed";
18
+
19
+ const NOW = Date.parse("2026-09-17T12:00:00Z");
20
+ const hoursAgo = (h: number) => new Date(NOW - h * 3_600_000).toISOString();
21
+ const post = (id: string, authorId: string, h: number): Post => ({ id, authorId, imageUrl: `/images/posts/${id}.jpg`, createdAt: hoursAgo(h) });
22
+ const profile = (userId: string, username: string): Profile => ({ id: `p_${userId}`, userId, username, displayName: username });
23
+ const follow = (followerId: string, followingId: string): Follow => ({ id: `${followerId}>${followingId}`, followerId, followingId });
24
+
25
+ describe("usernames", () => {
26
+ test("accepts the usual shapes and explains refusals", () => {
27
+ for (const ok of ["mara.bakes", "june_surfs", "abc", "a1.b2_c3"]) expect(usernameProblem(ok)).toBeNull();
28
+ expect(usernameProblem("ab")).toContain("at least");
29
+ expect(usernameProblem("x".repeat(31))).toContain("at most");
30
+ expect(usernameProblem("has space")).toContain("letters");
31
+ expect(usernameProblem("Upper")).toContain("letters");
32
+ expect(usernameProblem(".dot")).toContain("start or end");
33
+ expect(usernameProblem("dot.")).toContain("start or end");
34
+ expect(usernameProblem("two..dots")).toContain("two periods");
35
+ });
36
+
37
+ test("normalizes input and starts new accounts on a valid, stable name", () => {
38
+ expect(normalizeUsername(" @Mara.Bakes ")).toBe("mara.bakes");
39
+ const name = starterUsername("guest_4f2a9c");
40
+ expect(name).toBe(starterUsername("guest_4f2a9c"));
41
+ expect(name).not.toBe(starterUsername("guest_4f2a9d"));
42
+ expect(usernameProblem(name)).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe("labels", () => {
47
+ test("counts", () => {
48
+ expect(formatCount(0)).toBe("0");
49
+ expect(formatCount(1204)).toBe("1,204");
50
+ expect(formatCount(12_500)).toBe("12.5K");
51
+ expect(formatCount(40_000)).toBe("40K");
52
+ expect(formatCount(3_400_000)).toBe("3.4M");
53
+ });
54
+
55
+ test("ages", () => {
56
+ expect(timeAgo(hoursAgo(0), NOW)).toBe("now");
57
+ expect(timeAgo(new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe("5m");
58
+ expect(timeAgo(hoursAgo(3), NOW)).toBe("3h");
59
+ expect(timeAgo(hoursAgo(50), NOW)).toBe("2d");
60
+ expect(timeAgo(hoursAgo(24 * 15), NOW)).toBe("2w");
61
+ expect(timeAgo("2026-03-04T10:00:00Z", NOW)).toBe("Mar 4");
62
+ expect(timeAgo("2024-03-04T10:00:00Z", NOW)).toBe("Mar 4, 2024");
63
+ expect(timeAgo("not a date", NOW)).toBe("");
64
+ });
65
+
66
+ test("mentions become links only when the name is valid", () => {
67
+ expect(splitMentions("thanks @nora.h and @biscuit.corgi.")).toEqual([
68
+ { kind: "text", text: "thanks " },
69
+ { kind: "mention", username: "nora.h" },
70
+ { kind: "text", text: " and " },
71
+ { kind: "mention", username: "biscuit.corgi" },
72
+ { kind: "text", text: "." },
73
+ ]);
74
+ expect(splitMentions("email me@x or @ab")).toEqual([{ kind: "text", text: "email me@x or @ab" }]);
75
+ });
76
+
77
+ test("post images: uploads, public files, or https only", () => {
78
+ expect(isPostImageUrl("/api/files/abc123")).toBe(true);
79
+ expect(isPostImageUrl("/images/posts/a.jpg")).toBe(true);
80
+ expect(isPostImageUrl("https://cdn.example.com/a.jpg")).toBe(true);
81
+ expect(isPostImageUrl("http://cdn.example.com/a.jpg")).toBe(false);
82
+ expect(isPostImageUrl("javascript:alert(1)")).toBe(false);
83
+ expect(isPostImageUrl("/images/../app.ts")).toBe(false);
84
+ expect(isPostImageUrl("")).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe("who sees what", () => {
89
+ const posts = [post("a", "u1", 1), post("b", "u2", 2), post("c", "me", 3), post("d", "u3", 100)];
90
+
91
+ test("someone who follows nobody sees everything", () => {
92
+ const feed = homeFeed(posts, new Set(), "me");
93
+ expect(feed.everyone).toBe(true);
94
+ expect(feed.posts.map((p) => p.id)).toEqual(["a", "b", "c", "d"]);
95
+ });
96
+
97
+ test("otherwise the people you follow and yourself, newest first", () => {
98
+ const feed = homeFeed(posts, new Set(["u2", "u3"]), "me");
99
+ expect(feed.everyone).toBe(false);
100
+ expect(feed.posts.map((p) => p.id)).toEqual(["b", "c", "d"]);
101
+ });
102
+
103
+ test("suggestions skip you and people you follow, most followed first", () => {
104
+ const profiles = [profile("me", "me.me"), profile("u1", "zed"), profile("u2", "amy"), profile("u3", "bob")];
105
+ const follows = [follow("u1", "u3"), follow("u2", "u3"), follow("u3", "u1"), follow("me", "u2")];
106
+ const out = suggestions(profiles, follows, new Set(["u2"]), "me");
107
+ expect(out.map((p) => p.userId)).toEqual(["u3", "u1"]);
108
+ });
109
+
110
+ test("recent posters are unique and inside the window", () => {
111
+ const list = [post("a", "u1", 1), post("b", "u1", 2), post("c", "u2", 5), post("d", "u3", 60)];
112
+ expect(recentPosters(list, NOW, 48)).toEqual(["u1", "u2"]);
113
+ });
114
+ });
115
+
116
+ describe("demo data", () => {
117
+ const usernames = new Set(SEED_PROFILES.map((p) => p.username));
118
+
119
+ test("every username is valid and every reference points at a real row", () => {
120
+ for (const u of usernames) expect(usernameProblem(u)).toBeNull();
121
+ const keys = new Set(SEED_POSTS.map((p) => p.key));
122
+ expect(keys.size).toBe(SEED_POSTS.length);
123
+ for (const p of SEED_POSTS) expect(usernames.has(p.author)).toBe(true);
124
+ for (const c of SEED_COMMENTS) {
125
+ expect(keys.has(c.post)).toBe(true);
126
+ expect(usernames.has(c.author)).toBe(true);
127
+ }
128
+ for (const [from, tos] of Object.entries(SEED_FOLLOWS)) {
129
+ expect(usernames.has(from)).toBe(true);
130
+ for (const to of tos) {
131
+ expect(usernames.has(to)).toBe(true);
132
+ expect(to).not.toBe(from);
133
+ }
134
+ }
135
+ });
136
+
137
+ test("likes: four to eleven people, never the author, no one twice", () => {
138
+ for (const p of SEED_POSTS) {
139
+ const likers = likersOf(p);
140
+ expect(likers.length).toBeGreaterThanOrEqual(4);
141
+ expect(likers.length).toBeLessThanOrEqual(11);
142
+ expect(likers).not.toContain(p.author);
143
+ expect(new Set(likers).size).toBe(likers.length);
144
+ }
145
+ });
146
+
147
+ test("shaped rows are dated in the past relative to now", () => {
148
+ const seed = shapeSeed(NOW);
149
+ expect(seed.profiles).toHaveLength(SEED_PROFILES.length);
150
+ for (const row of [...seed.posts.map((p) => p.row), ...seed.likes.map((l) => l.row), ...seed.comments.map((c) => c.row)]) {
151
+ expect(Date.parse(String(row.createdAt))).toBeLessThanOrEqual(NOW);
152
+ }
153
+ // A comment never predates its post.
154
+ const postTime = new Map(seed.posts.map((p) => [p.key, Date.parse(String(p.row.createdAt))]));
155
+ for (const c of seed.comments) expect(Date.parse(String(c.row.createdAt))).toBeGreaterThanOrEqual(postTime.get(c.post)!);
156
+ });
157
+ });
@@ -65,7 +65,7 @@ this pass before the user has to ask.
65
65
  - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
- - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
68
+ - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
69
69
 
70
70
  ## Testing
71
71
 
@@ -1,15 +1,16 @@
1
1
  import React from "react";
2
2
  import { Link, type PageAuth } from "@pylonsync/react";
3
3
  import { siteConfig } from "@/lib/site.config";
4
+ import { WRAP } from "@/components/marketing";
4
5
 
5
6
  // `(marketing)` is a ROUTE GROUP: the parens segment is stripped from every
6
7
  // URL (so `(marketing)/page.tsx` still serves `/`), and this layout wraps
7
- // only the pages inside the group — a slim nav up top and a footer below,
8
- // both driven by lib/site.config.ts. Routes outside the group (/login,
9
- // /dashboard) render bare in the root shell. `auth.user_id` is resolved
10
- // server-side from the session cookie before any HTML is sent, so the nav
11
- // shows "Dashboard" once the owner is signed in and "Sign in" otherwise —
12
- // no flash, no client fetch.
8
+ // only the pages inside the group: the name up top, a footer below, both
9
+ // driven by lib/site.config.ts. Routes outside the group (/login, /dashboard)
10
+ // render bare in the root shell. `auth.user_id` is resolved server-side from
11
+ // the session cookie before any HTML is sent, so the nav shows "Dashboard"
12
+ // once the owner is signed in and "Sign in" otherwise. No flash, no client
13
+ // fetch.
13
14
  interface LayoutProps {
14
15
  children: React.ReactNode;
15
16
  auth: PageAuth;
@@ -17,36 +18,25 @@ interface LayoutProps {
17
18
 
18
19
  export default function MarketingLayout({ children, auth }: LayoutProps) {
19
20
  // A guest session (minted by <EnsureGuest> for the live counter) has a
20
- // `guest_…` user id — that's an anonymous visitor, NOT the signed-in owner,
21
- // so it shouldn't flip the nav to "Dashboard".
21
+ // `guest_…` user id. That is an anonymous visitor, not the signed-in owner,
22
+ // so it must not flip the nav to "Dashboard".
22
23
  const signedIn = Boolean(auth?.user_id && !auth.user_id.startsWith("guest_"));
23
24
  const { brand } = siteConfig;
24
25
 
25
26
  return (
26
- <>
27
- <header className="sticky top-0 z-30 bg-white/80 backdrop-blur">
28
- <div className="mx-auto flex h-14 max-w-3xl items-center justify-between px-6">
29
- <Link href="/" className="flex items-center gap-2">
30
- <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
31
- {brand.letter}
32
- </span>
33
- <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
34
- {brand.name}
35
- </span>
27
+ <div className="font-display flex min-h-screen flex-1 flex-col bg-paper text-ink">
28
+ <header>
29
+ <div className={`${WRAP} flex h-16 items-center justify-between`}>
30
+ <Link href="/" className="text-[17px] font-medium text-ink">
31
+ {brand.name}
36
32
  </Link>
37
- <nav className="flex items-center gap-2">
33
+ <nav>
38
34
  {signedIn ? (
39
- <Link
40
- href="/dashboard"
41
- className="inline-flex items-center rounded-full bg-zinc-900 px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
42
- >
35
+ <Link href="/dashboard" className="text-[16px] text-ink-2 hover:text-ink">
43
36
  Dashboard
44
37
  </Link>
45
38
  ) : (
46
- <Link
47
- href="/login"
48
- className="rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900"
49
- >
39
+ <Link href="/login" className="text-[16px] text-ink-2 hover:text-ink">
50
40
  Sign in
51
41
  </Link>
52
42
  )}
@@ -57,61 +47,37 @@ export default function MarketingLayout({ children, auth }: LayoutProps) {
57
47
  <main className="flex-1">{children}</main>
58
48
 
59
49
  <SiteFooter />
60
- </>
50
+ </div>
61
51
  );
62
52
  }
63
53
 
64
54
  function SiteFooter() {
65
55
  const { brand } = siteConfig;
66
56
  return (
67
- <footer className="border-t border-zinc-200/70 bg-white">
68
- <div className="mx-auto max-w-3xl px-6 py-12">
69
- <div className="flex flex-col items-start justify-between gap-6 sm:flex-row">
70
- <div className="max-w-sm">
71
- <Link href="/" className="inline-flex items-center gap-2">
72
- <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
73
- {brand.letter}
74
- </span>
75
- <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
76
- {brand.name}
77
- </span>
78
- </Link>
79
- <p className="mt-3 text-[13px] leading-relaxed text-zinc-500">
80
- {brand.footerBlurb}
81
- </p>
82
- </div>
83
- <div className="flex items-center gap-4">
84
- {brand.socials.map((s) => (
85
- <a
86
- key={s.label}
87
- href={s.href}
88
- aria-label={s.label}
89
- className="text-zinc-400 transition-colors hover:text-zinc-900"
90
- >
91
- <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
92
- <path d={s.path} />
93
- </svg>
57
+ <footer>
58
+ <div className={`${WRAP} border-t border-rule py-10 text-[15px] text-ink-2`}>
59
+ <p className="max-w-md leading-relaxed">{brand.footerBlurb}</p>
60
+ <ul className="mt-5 flex flex-wrap gap-x-5 gap-y-2">
61
+ {brand.socials.map((s) => (
62
+ <li key={s.label}>
63
+ <a href={s.href} className="underline decoration-rule underline-offset-4 hover:text-ink">
64
+ {s.label}
94
65
  </a>
95
- ))}
96
- <a
97
- href={`mailto:${brand.email}`}
98
- aria-label="Email"
99
- className="text-zinc-400 transition-colors hover:text-zinc-900"
100
- >
101
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
102
- <rect x="3" y="5" width="18" height="14" rx="2" />
103
- <path d="m3 7 9 6 9-6" />
104
- </svg>
66
+ </li>
67
+ ))}
68
+ <li>
69
+ <a href={`mailto:${brand.email}`} className="underline decoration-rule underline-offset-4 hover:text-ink">
70
+ {brand.email}
105
71
  </a>
106
- </div>
107
- </div>
108
- <div className="mt-10 flex flex-col items-start justify-between gap-3 border-t border-zinc-200/70 pt-6 text-[12px] text-zinc-400 sm:flex-row sm:items-center">
72
+ </li>
73
+ </ul>
74
+ <div className="mt-8 flex flex-col gap-1 text-[14px] sm:flex-row sm:justify-between">
109
75
  <span>
110
76
  © {new Date().getFullYear()} {brand.copyrightName}
111
77
  </span>
112
78
  <span>
113
79
  Built with{" "}
114
- <a href="https://pylonsync.com" className="font-medium text-zinc-600 hover:text-zinc-900">
80
+ <a href="https://pylonsync.com" className="underline decoration-rule underline-offset-4 hover:text-ink">
115
81
  Pylon
116
82
  </a>
117
83
  </span>
@@ -22,7 +22,7 @@ export function NewsletterSignup({ newsletter }: Props) {
22
22
  return (
23
23
  <div>
24
24
  <SubscribeForm newsletter={newsletter} />
25
- <div className="mt-5">
25
+ <div className="mt-3">
26
26
  <LiveCounter seed={newsletter.seedCount ?? 0} label={newsletter.counterLabel} />
27
27
  </div>
28
28
  </div>
@@ -52,7 +52,7 @@ function SubscribeForm({ newsletter }: Props) {
52
52
  setError(
53
53
  /valid email|INVALID_ARGS/i.test(msg)
54
54
  ? "Enter a valid email address."
55
- : "Something went wrong — try again in a moment.",
55
+ : "Something went wrong. Try again in a moment.",
56
56
  );
57
57
  setStatus("idle");
58
58
  }
@@ -60,17 +60,15 @@ function SubscribeForm({ newsletter }: Props) {
60
60
 
61
61
  if (status === "done") {
62
62
  return (
63
- <div className="rounded-xl border border-brand/30 bg-brand-soft/50 px-5 py-4">
64
- <p className="text-[15px] font-medium text-zinc-900">
65
- {alreadyJoined ? "You're already subscribed." : newsletter.successMessage}
66
- </p>
67
- </div>
63
+ <p className="border-l-2 border-brand pl-4 text-[17.5px] leading-[1.65] text-ink">
64
+ {alreadyJoined ? "You are already subscribed." : newsletter.successMessage}
65
+ </p>
68
66
  );
69
67
  }
70
68
 
71
69
  return (
72
70
  <form onSubmit={onSubmit} className="max-w-md">
73
- <div className="flex flex-col gap-2 sm:flex-row">
71
+ <div className="flex gap-2">
74
72
  <input
75
73
  type="email"
76
74
  inputMode="email"
@@ -80,21 +78,19 @@ function SubscribeForm({ newsletter }: Props) {
80
78
  placeholder={newsletter.emailPlaceholder}
81
79
  aria-label="Email address"
82
80
  required
83
- className="h-11 flex-1 rounded-full border border-zinc-300 bg-white px-4 text-[15px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
81
+ className="h-11 min-w-0 flex-1 rounded-md border border-rule bg-white px-3.5 text-[16px] text-ink outline-none transition placeholder:text-ink-2/60 focus:border-brand focus:ring-2 focus:ring-brand/20"
84
82
  />
85
83
  <button
86
84
  type="submit"
87
85
  disabled={status === "sending"}
88
- className="inline-flex h-11 items-center justify-center rounded-full bg-brand px-6 text-[15px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60"
86
+ className="inline-flex h-11 shrink-0 items-center justify-center rounded-md bg-brand px-5 text-[16px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60"
89
87
  >
90
88
  {status === "sending" ? "Subscribing…" : newsletter.ctaLabel}
91
89
  </button>
92
90
  </div>
93
91
  {error ? (
94
- <p className="mt-2 text-[13px] text-red-600">{error}</p>
95
- ) : (
96
- <p className="mt-2 text-[13px] text-zinc-400">One email a week. Unsubscribe anytime.</p>
97
- )}
92
+ <p className="mt-2 text-[15px] text-red-700">{error}</p>
93
+ ) : null}
98
94
  </form>
99
95
  );
100
96
  }
@@ -118,21 +114,14 @@ function LiveCounterInner({ seed, label }: { seed: number; label: string }) {
118
114
  return <CounterView value={seed + real} label={label} live={!loading} />;
119
115
  }
120
116
 
117
+ // Plain text under the form. The fallback shows the seed until the guest
118
+ // session connects; `live` is set once the count is synced.
121
119
  function CounterView({ value, label, live }: { value: number; label: string; live?: boolean }) {
122
120
  const shown = useCountUp(value);
123
121
  return (
124
- <div className="inline-flex items-center gap-2.5 text-[14px] text-zinc-500">
125
- {live ? (
126
- <span className="relative flex size-2">
127
- <span className="absolute inline-flex size-2 animate-ping rounded-full bg-brand/70" />
128
- <span className="relative inline-flex size-2 rounded-full bg-brand" />
129
- </span>
130
- ) : (
131
- <span className="inline-flex size-2 rounded-full bg-zinc-300" />
132
- )}
133
- <span className="font-semibold tabular-nums text-zinc-900">{shown.toLocaleString()}</span>
134
- {label}
135
- </div>
122
+ <p className="text-[15px] text-ink-2" data-live={live ? "true" : undefined}>
123
+ <span className="tabular-nums text-ink">{shown.toLocaleString()}</span> {label}
124
+ </p>
136
125
  );
137
126
  }
138
127