@pylonsync/create-pylon 0.11.1 → 0.11.3

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
@@ -3,10 +3,11 @@
3
3
  import React, { useState } from "react";
4
4
  import { callFn } from "@pylonsync/react";
5
5
  import { siteConfig } from "@/lib/site.config";
6
+ import { LINK } from "@/components/marketing";
6
7
 
7
8
  // The "submit a tool" form. submitListing is a public mutation, so it works for
8
9
  // anonymous visitors with no session. PRIVACY: the submission (incl. the
9
- // submitter's email) is written to the deny-all Submission table — it is NOT
10
+ // submitter's email) is written to the deny-all Submission table. It is NOT
10
11
  // public and never syncs to a browser. It shows up in the directory only after
11
12
  // the owner approves it from the dashboard.
12
13
  export function SubmitForm() {
@@ -33,7 +34,7 @@ export function SubmitForm() {
33
34
  e.preventDefault();
34
35
  if (status === "sending") return;
35
36
  if (!form.submitterName.trim() || !form.submitterEmail.trim() || !form.name.trim() || !form.url.trim()) {
36
- setError("Your name + email and the tool's name + URL are required.");
37
+ setError("Your name, your email, the tool name, and the URL are required.");
37
38
  return;
38
39
  }
39
40
  setStatus("sending");
@@ -58,8 +59,8 @@ export function SubmitForm() {
58
59
  : /valid URL/i.test(msg)
59
60
  ? "Enter a valid URL (https://…)."
60
61
  : /INVALID_ARGS/i.test(msg)
61
- ? "Please fill in the required fields."
62
- : "Something went wrong — try again in a moment.",
62
+ ? "Fill in the required fields."
63
+ : "The submission failed. Try again in a moment.",
63
64
  );
64
65
  setStatus("idle");
65
66
  }
@@ -67,10 +68,9 @@ export function SubmitForm() {
67
68
 
68
69
  if (status === "done") {
69
70
  return (
70
- <div className="rounded-2xl border border-brand/30 bg-brand-soft/50 px-6 py-10 text-center">
71
- <div className="mx-auto flex size-10 items-center justify-center rounded-full bg-brand text-white">✓</div>
72
- <p className="mt-3 text-[15px] font-semibold text-zinc-900">{submit.confirmationMessage}</p>
73
- <a href="/" className="mt-5 inline-flex h-10 items-center rounded-full border border-zinc-300 px-5 text-sm font-medium text-zinc-700 hover:bg-zinc-50">
71
+ <div className="border-t border-zinc-200 pt-4">
72
+ <p className="text-[14px] font-medium text-zinc-900">{submit.confirmationMessage}</p>
73
+ <a href="/" className={`${LINK} mt-2 inline-block text-[14px]`}>
74
74
  Back to the directory
75
75
  </a>
76
76
  </div>
@@ -78,8 +78,8 @@ export function SubmitForm() {
78
78
  }
79
79
 
80
80
  return (
81
- <form onSubmit={onSubmit} className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm sm:p-7">
82
- <div className="grid gap-3 sm:grid-cols-2">
81
+ <form onSubmit={onSubmit} className="border-t border-zinc-200 pt-5">
82
+ <div className="grid gap-4 sm:grid-cols-2">
83
83
  <Field label="Your name" required>
84
84
  <input value={form.submitterName} onChange={set("submitterName")} autoComplete="name" className={inputCls} />
85
85
  </Field>
@@ -88,8 +88,8 @@ export function SubmitForm() {
88
88
  </Field>
89
89
  </div>
90
90
 
91
- <div className="mt-3 border-t border-zinc-100 pt-4">
92
- <div className="grid gap-3 sm:grid-cols-2">
91
+ <div className="mt-5 border-t border-zinc-200 pt-5">
92
+ <div className="grid gap-4 sm:grid-cols-2">
93
93
  <Field label="Tool name" required>
94
94
  <input value={form.name} onChange={set("name")} className={inputCls} />
95
95
  </Field>
@@ -101,33 +101,33 @@ export function SubmitForm() {
101
101
  </select>
102
102
  </Field>
103
103
  </div>
104
- <div className="mt-3">
104
+ <div className="mt-4">
105
105
  <Field label="URL" required>
106
- <input value={form.url} onChange={set("url")} placeholder="https://…" inputMode="url" className={inputCls} />
106
+ <input value={form.url} onChange={set("url")} placeholder="https://" inputMode="url" className={`${inputCls} font-mono text-[13px]`} />
107
107
  </Field>
108
108
  </div>
109
- <div className="mt-3">
110
- <Field label="One-line tagline">
111
- <input value={form.tagline} onChange={set("tagline")} placeholder="What it does, in a sentence." className={inputCls} />
109
+ <div className="mt-4">
110
+ <Field label="One-line description">
111
+ <input value={form.tagline} onChange={set("tagline")} placeholder="What it does, in one sentence." className={inputCls} />
112
112
  </Field>
113
113
  </div>
114
- <div className="mt-3">
114
+ <div className="mt-4">
115
115
  <Field label="Tags">
116
- <input value={form.tags} onChange={set("tags")} placeholder="comma, separated, tags" className={inputCls} />
116
+ <input value={form.tags} onChange={set("tags")} placeholder="comma, separated" className={`${inputCls} font-mono text-[13px]`} />
117
117
  </Field>
118
118
  </div>
119
- <div className="mt-3">
120
- <Field label="Anything else?">
121
- <textarea value={form.description} onChange={set("description")} rows={3} className={inputCls + " resize-none py-2.5"} />
119
+ <div className="mt-4">
120
+ <Field label="Notes for the reviewer">
121
+ <textarea value={form.description} onChange={set("description")} rows={3} className={`${inputCls} h-auto resize-none py-2`} />
122
122
  </Field>
123
123
  </div>
124
124
  </div>
125
125
 
126
- {error ? <p className="mt-3 text-[13px] text-red-600">{error}</p> : null}
126
+ {error ? <p className="mt-4 text-[13px] text-red-700">{error}</p> : null}
127
127
  <button
128
128
  type="submit"
129
129
  disabled={status === "sending"}
130
- className="mt-5 inline-flex h-11 w-full items-center justify-center rounded-full bg-brand text-[15px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60 sm:w-auto sm:px-7"
130
+ className="mt-6 h-10 border border-brand bg-brand px-5 text-[14px] font-medium text-white hover:opacity-90 disabled:opacity-60"
131
131
  >
132
132
  {status === "sending" ? "Submitting…" : "Submit for review"}
133
133
  </button>
@@ -138,7 +138,7 @@ export function SubmitForm() {
138
138
  function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
139
139
  return (
140
140
  <label className="block">
141
- <span className="mb-1.5 block text-[12.5px] font-medium text-zinc-600">
141
+ <span className="mb-1 block text-[12px] font-medium uppercase tracking-wide text-zinc-500">
142
142
  {label}
143
143
  {required ? <span className="text-brand"> *</span> : null}
144
144
  </span>
@@ -148,4 +148,4 @@ function Field({ label, required, children }: { label: string; required?: boolea
148
148
  }
149
149
 
150
150
  const inputCls =
151
- "h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-[14px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20";
151
+ "h-10 w-full border border-zinc-300 bg-white px-3 text-[14px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-brand";
@@ -15,10 +15,10 @@
15
15
  three brand vars are defaults — app/layout.tsx overrides them from
16
16
  lib/site.config.ts on <html>, so re-theming the whole page is one edit there. */
17
17
  :root {
18
- --radius: 0.625rem;
19
- --brand: #4f46e5;
20
- --brand-soft: #eef2ff;
21
- --paper: #fafafa;
18
+ --radius: 0.125rem;
19
+ --brand: #1d4ed8;
20
+ --brand-soft: #eff6ff;
21
+ --paper: #ffffff;
22
22
  --background: oklch(1 0 0);
23
23
  --foreground: oklch(0.141 0.005 285.823);
24
24
  --card: oklch(1 0 0);
@@ -125,6 +125,8 @@
125
125
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
126
126
  --color-sidebar-border: var(--sidebar-border);
127
127
  --color-sidebar-ring: var(--sidebar-ring);
128
+ /* `font-mono` utility. IBM Plex Mono is declared in app.ts as --font-code. */
129
+ --font-mono: var(--font-code, "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace);
128
130
  }
129
131
 
130
132
  @layer base {
@@ -153,3 +155,12 @@
153
155
  cursor: pointer;
154
156
  }
155
157
  }
158
+
159
+ /* Headings. The page uses Inter semibold at modest sizes; no separate display
160
+ face. Kept as a class so a template fork can swap the heading font in one
161
+ place (declare it in app.ts as --font-display). */
162
+ .font-display {
163
+ font-family: var(--font-display, var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif));
164
+ font-weight: 600;
165
+ letter-spacing: -0.01em;
166
+ }
@@ -158,6 +158,16 @@ const manifest = buildManifest({
158
158
  display: "swap",
159
159
  preload: true,
160
160
  }),
161
+ // The mono face for tags, counts, and the search hint. globals.css maps it
162
+ // onto Tailwind's `font-mono` utility.
163
+ font({
164
+ family: "IBM Plex Mono",
165
+ variable: "--font-code",
166
+ weights: ["400", "500"],
167
+ subsets: ["latin"],
168
+ display: "swap",
169
+ preload: true,
170
+ }),
161
171
  ],
162
172
  routes: await discoverAppRoutes(),
163
173
  });
@@ -1,91 +1,33 @@
1
1
  import React from "react";
2
2
 
3
- // Reusable presentational pieces for the landing page. All server-rendered —
4
- // no client JS. Restyle here and the whole page follows. The brand accent
5
- // (`text-brand`, `bg-brand-soft`) comes from CSS vars set on <html> in
6
- // app/layout.tsx, which read lib/site.config.ts — so re-theming is one edit.
3
+ // Reusable presentational pieces for the marketing pages. All server-rendered,
4
+ // no client JS. The brand accent (`text-brand`) comes from CSS vars set on
5
+ // <html> in app/layout.tsx, which read lib/site.config.ts.
7
6
 
8
7
  // Shared container: a contained, centered column.
9
- export const WRAP = "mx-auto w-full max-w-5xl px-6";
8
+ export const WRAP = "mx-auto w-full max-w-5xl px-5 sm:px-8";
10
9
 
11
- export function Eyebrow({ children }: { children: React.ReactNode }) {
12
- return (
13
- <p className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-brand">
14
- {children}
15
- </p>
16
- );
17
- }
18
-
19
- // "New / Coming soon"-style pill for the hero.
20
- export function Badge({ children }: { children: React.ReactNode }) {
21
- return (
22
- <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1.5 pr-3 text-[13px] text-zinc-600 shadow-sm">
23
- <span className="inline-block size-1.5 rounded-full bg-brand" />
24
- {children}
25
- </span>
26
- );
27
- }
10
+ // Plain text link in the accent color. Used for nav, rail, and inline links.
11
+ export const LINK = "text-brand underline-offset-2 hover:underline";
28
12
 
29
13
  export function Divider() {
30
14
  return (
31
15
  <div className={WRAP}>
32
- <div className="border-t border-zinc-200/70" />
16
+ <div className="border-t border-zinc-200" />
33
17
  </div>
34
18
  );
35
19
  }
36
20
 
37
- export function SectionHead({
38
- eyebrow,
39
- title,
40
- body,
41
- }: {
42
- eyebrow: string;
43
- title: string;
44
- body?: string;
45
- }) {
21
+ export function SectionHead({ title, body }: { title: string; body?: string }) {
46
22
  return (
47
23
  <div>
48
- <Eyebrow>{eyebrow}</Eyebrow>
49
- <h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
50
- {title}
51
- </h2>
52
- {body ? (
53
- <p className="mt-4 max-w-xl text-[15px] leading-relaxed text-zinc-500">
54
- {body}
55
- </p>
56
- ) : null}
24
+ <h2 className="font-display text-[20px] leading-tight text-zinc-900">{title}</h2>
25
+ {body ? <p className="mt-2 max-w-xl text-[14px] leading-relaxed text-zinc-600">{body}</p> : null}
57
26
  </div>
58
27
  );
59
28
  }
60
29
 
61
- // A grid of value props — icon + title + body.
62
- export function FeatureGrid({
63
- items,
64
- }: {
65
- items: { title: string; body: string; icon?: string }[];
66
- }) {
67
- return (
68
- <div className="grid gap-6 sm:grid-cols-3">
69
- {items.map((f) => (
70
- <div key={f.title}>
71
- {f.icon ? (
72
- <span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
73
- {f.icon}
74
- </span>
75
- ) : null}
76
- <h3 className="mt-4 text-[15px] font-semibold text-zinc-900">
77
- {f.title}
78
- </h3>
79
- <p className="mt-2 text-[14px] leading-relaxed text-zinc-500">
80
- {f.body}
81
- </p>
82
- </div>
83
- ))}
84
- </div>
85
- );
86
- }
87
-
88
- // Initials for testimonial avatars, so the cards look finished without a photo.
30
+ // Initials for avatars, so a row looks finished without a photo.
89
31
  export function initials(name: string) {
90
32
  return name
91
33
  .split(/\s+/)
@@ -95,23 +37,24 @@ export function initials(name: string) {
95
37
  .toUpperCase();
96
38
  }
97
39
 
98
- // A deliberately-obvious image placeholder. Real sites drop a photo here; this
99
- // makes the spot unmistakable — dashed border, a photo glyph, and a one-line
100
- // "swap this" instruction telling you exactly what to replace and where. Looks
101
- // tidy enough to demo, but no one will mistake it for a finished design.
40
+ // An image slot. With `src` it renders the image; without, a dashed box with a
41
+ // title naming the image that belongs there and a hint on where to replace it.
102
42
  //
103
43
  // shape — "landscape" | "portrait" | "square" | "circle"
104
- // title — what photo belongs here ("Your headshot")
44
+ // title — what image belongs here
105
45
  // hint — how to replace it ("Replace in app/page.tsx")
106
46
  export function ImagePlaceholder({
107
47
  shape = "landscape",
108
48
  title,
109
49
  hint,
50
+ src,
110
51
  className = "",
111
52
  }: {
112
53
  shape?: "landscape" | "portrait" | "square" | "circle";
113
54
  title: string;
114
55
  hint?: string;
56
+ /** A real image under public/images. */
57
+ src?: string;
115
58
  className?: string;
116
59
  }) {
117
60
  const aspect =
@@ -120,27 +63,20 @@ export function ImagePlaceholder({
120
63
  : shape === "square" || shape === "circle"
121
64
  ? "aspect-square"
122
65
  : "aspect-[4/3]";
123
- const radius = shape === "circle" ? "rounded-full" : "rounded-2xl";
66
+ const radius = shape === "circle" ? "rounded-full" : "";
67
+ if (src) {
68
+ return (
69
+ <div className={`relative overflow-hidden bg-zinc-100 ${aspect} ${radius} ${className}`}>
70
+ <img src={src} alt={title} className="absolute inset-0 size-full object-cover" loading="lazy" />
71
+ </div>
72
+ );
73
+ }
124
74
  return (
125
75
  <div
126
- className={`relative grid place-items-center overflow-hidden border-2 border-dashed border-zinc-300 bg-zinc-50 ${aspect} ${radius} ${className}`}
76
+ className={`relative grid place-items-center overflow-hidden border border-dashed border-zinc-300 bg-zinc-50 ${aspect} ${radius} ${className}`}
127
77
  >
128
78
  <div className="px-4 text-center">
129
- <svg
130
- className="mx-auto size-7 text-zinc-300"
131
- viewBox="0 0 24 24"
132
- fill="none"
133
- stroke="currentColor"
134
- strokeWidth="1.5"
135
- strokeLinecap="round"
136
- strokeLinejoin="round"
137
- aria-hidden
138
- >
139
- <rect x="3" y="3" width="18" height="18" rx="2" />
140
- <circle cx="9" cy="9" r="1.6" />
141
- <path d="m21 15-4.5-4.5L7 20" />
142
- </svg>
143
- <p className="mt-2 text-[12.5px] font-medium text-zinc-500">{title}</p>
79
+ <p className="font-mono text-[12px] text-zinc-500">{title}</p>
144
80
  {hint ? <p className="mt-1 text-[11px] leading-snug text-zinc-400">{hint}</p> : null}
145
81
  </div>
146
82
  </div>
@@ -36,21 +36,18 @@ export type SeedListing = {
36
36
  };
37
37
 
38
38
  export type DirectoryConfig = BaseConfig & {
39
- hero: {
40
- tagline: string;
41
- headline: string;
42
- subcopy: string;
39
+ // The page opens with the brand name, this one line, and the search box.
40
+ intro: {
41
+ description: string;
43
42
  ctaLabel: string;
44
43
  searchPlaceholder: string;
45
44
  };
46
- browse: { eyebrow: string; headline: string };
47
45
  // The facet values offered in the submit form's category select. The browse
48
46
  // facet sidebar is built live from the data, so this only needs to cover what
49
47
  // submitters can pick.
50
48
  categories: string[];
51
49
  seedListings: SeedListing[];
52
50
  submit: {
53
- eyebrow: string;
54
51
  headline: string;
55
52
  subcopy: string;
56
53
  confirmationMessage: string;
@@ -77,26 +74,19 @@ export const siteConfig: DirectoryConfig = {
77
74
  ],
78
75
  },
79
76
 
80
- colors: { brand: "#7c3aed", brandSoft: "#ede9fe", paper: "#faf9fc" },
77
+ colors: { brand: "#1d4ed8", brandSoft: "#eff6ff", paper: "#ffffff" },
81
78
 
82
79
  seo: {
83
- title: "Stacked — the developer tools directory.",
80
+ title: "Stacked, a developer tools directory",
84
81
  description:
85
- "A searchable, community-voted directory of developer tools. Full-text search, filter by category, upvote your favorites, and submit the ones we're missing.",
82
+ "A searchable, community-voted directory of developer tools. Full-text search, category filters, upvotes, and a submit form.",
86
83
  },
87
84
 
88
- hero: {
89
- tagline: "The dev tools directory",
90
- headline: "Developer tools, checked by hand.",
91
- subcopy:
92
- "Search a hand-checked directory, filter by category, and upvote useful tools. Results and vote counts update live, and browsing requires no account.",
85
+ intro: {
86
+ description:
87
+ "A hand-checked directory of developer tools. Search, filter by category, and upvote. No account needed.",
93
88
  ctaLabel: "Submit a tool",
94
- searchPlaceholder: "Search tools — try “database”, “deploy”, “auth”…",
95
- },
96
-
97
- browse: {
98
- eyebrow: "Browse",
99
- headline: "Find your next tool.",
89
+ searchPlaceholder: "search: database, deploy, auth",
100
90
  },
101
91
 
102
92
  categories: ["Database", "Hosting", "Auth", "AI", "Analytics", "Design", "Productivity", "DevOps"],
@@ -119,10 +109,9 @@ export const siteConfig: DirectoryConfig = {
119
109
  ],
120
110
 
121
111
  submit: {
122
- eyebrow: "Submit a tool",
123
- headline: "Know one we're missing?",
112
+ headline: "Submit a tool",
124
113
  subcopy:
125
- "Send it over. Submissions land in the curator's queue — once it's approved it shows up in the directory for everyone.",
126
- confirmationMessage: "Thanks — your submission's in the queue. We review new tools a few times a week.",
114
+ "Submissions go to the review queue. Approved tools appear in the directory.",
115
+ confirmationMessage: "Your submission is in the queue. We review new tools a few times a week.",
127
116
  },
128
117
  };
@@ -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