@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.0

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 (193) hide show
  1. package/apps/storefront/astro.config.mjs +17 -1
  2. package/apps/storefront/integrations/materialize-guard.mjs +56 -0
  3. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  4. package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
  5. package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
  6. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  7. package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
  8. package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
  10. package/apps/storefront/package.json +0 -1
  11. package/apps/storefront/public/js/dashboard-team.js +38 -2
  12. package/apps/storefront/src/components/Seo.astro +65 -1
  13. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
  14. package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
  15. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
  16. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
  17. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
  18. package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
  19. package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
  20. package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
  21. package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
  22. package/apps/storefront/src/layouts/Layout.astro +48 -3
  23. package/apps/storefront/src/lib/activity/alerts.ts +426 -0
  24. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  25. package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
  26. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  27. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  28. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  29. package/apps/storefront/src/lib/activity/query.ts +403 -0
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
  31. package/apps/storefront/src/lib/activity/store.ts +120 -0
  32. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  33. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  34. package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
  35. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
  36. package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
  37. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
  38. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
  39. package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
  40. package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
  41. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
  42. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
  43. package/apps/storefront/src/lib/apps/adminService.ts +4 -4
  44. package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
  45. package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
  46. package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
  47. package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
  48. package/apps/storefront/src/lib/apps/credentials.ts +2 -2
  49. package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
  50. package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
  51. package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
  52. package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
  53. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
  54. package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
  55. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
  56. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
  57. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
  58. package/apps/storefront/src/lib/apps/registryService.ts +6 -6
  59. package/apps/storefront/src/lib/apps/scopes.ts +2 -2
  60. package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
  61. package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
  62. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
  63. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
  64. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
  65. package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
  66. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
  67. package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
  68. package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
  69. package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
  70. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
  71. package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
  72. package/apps/storefront/src/lib/auth/route.ts +44 -4
  73. package/apps/storefront/src/lib/auth/session.ts +7 -8
  74. package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
  75. package/apps/storefront/src/lib/basePath.ts +9 -2
  76. package/apps/storefront/src/lib/blog/access.ts +29 -0
  77. package/apps/storefront/src/lib/blog/collection.ts +313 -0
  78. package/apps/storefront/src/lib/blog/markdown.ts +174 -0
  79. package/apps/storefront/src/lib/blog/provider.ts +5 -0
  80. package/apps/storefront/src/lib/blog/reactions.ts +87 -0
  81. package/apps/storefront/src/lib/blog/rss.ts +92 -0
  82. package/apps/storefront/src/lib/blog/teaser.ts +83 -0
  83. package/apps/storefront/src/lib/blog/types.ts +34 -0
  84. package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
  85. package/apps/storefront/src/lib/d1/catalog.ts +2 -2
  86. package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
  87. package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
  88. package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
  89. package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
  90. package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
  91. package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
  92. package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
  93. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
  94. package/apps/storefront/src/lib/edgeCache.ts +17 -0
  95. package/apps/storefront/src/lib/env.ts +51 -0
  96. package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
  97. package/apps/storefront/src/lib/i18n.ts +25 -0
  98. package/apps/storefront/src/lib/jsonld.ts +118 -0
  99. package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
  100. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
  101. package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
  102. package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
  103. package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
  104. package/apps/storefront/src/lib/newsletter/send.ts +265 -0
  105. package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
  106. package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
  107. package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
  108. package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
  109. package/apps/storefront/src/lib/publish/domainState.ts +22 -13
  110. package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
  111. package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
  112. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
  113. package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
  114. package/apps/storefront/src/lib/search/index.ts +2 -2
  115. package/apps/storefront/src/lib/seo/alternates.ts +42 -0
  116. package/apps/storefront/src/lib/seo/meta.ts +65 -0
  117. package/apps/storefront/src/lib/social/golive.ts +212 -0
  118. package/apps/storefront/src/lib/social/notify.ts +32 -0
  119. package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
  120. package/apps/storefront/src/lib/the-build/provider.ts +27 -0
  121. package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
  122. package/apps/storefront/src/lib/the-build/toc.ts +95 -0
  123. package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
  124. package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
  125. package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
  126. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
  127. package/apps/storefront/src/middleware/index.ts +130 -2
  128. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  129. package/apps/storefront/src/pages/admin.astro +52 -1206
  130. package/apps/storefront/src/pages/api/activity.ts +137 -0
  131. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  132. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  133. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  134. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  135. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  136. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  137. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  138. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  139. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  141. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  142. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
  143. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  144. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  145. package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
  146. package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
  147. package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
  148. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
  149. package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
  150. package/apps/storefront/src/pages/auth/login.astro +5 -4
  151. package/apps/storefront/src/pages/blog/[slug].astro +96 -16
  152. package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
  153. package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
  154. package/apps/storefront/src/pages/blog/index.astro +2 -0
  155. package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
  156. package/apps/storefront/src/pages/cockpit.astro +74 -12
  157. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
  158. package/apps/storefront/src/pages/search.astro +1 -1
  159. package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
  160. package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
  161. package/apps/storefront/src/pages/the-build/index.astro +146 -0
  162. package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
  163. package/apps/storefront/src/styles/admin.css +1080 -0
  164. package/apps/storefront/tsconfig.json +5 -0
  165. package/package.json +1 -1
  166. package/packages/public-runtime/package.json +1 -0
  167. package/packages/public-runtime/src/activity/README.md +146 -0
  168. package/packages/public-runtime/src/activity/catalog.ts +553 -0
  169. package/packages/public-runtime/src/activity/event.ts +168 -0
  170. package/packages/public-runtime/src/activity/index.ts +16 -0
  171. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  172. package/packages/public-runtime/src/candidate-index.ts +13 -0
  173. package/packages/public-runtime/src/catalog-d1.ts +1 -1
  174. package/packages/public-runtime/src/customization-preview.ts +25 -2
  175. package/packages/public-runtime/src/customization-runtime.ts +125 -3
  176. package/packages/public-runtime/src/customization-versioning.ts +10 -0
  177. package/packages/public-runtime/src/index.ts +7 -0
  178. package/packages/public-runtime/src/static-bundle.ts +357 -0
  179. package/packages/public-runtime/src/tenant.ts +36 -8
  180. package/scripts/build/copy-tenant-assets.mjs +14 -4
  181. package/scripts/dev/ai-edit.mjs +1 -1
  182. package/scripts/dev/checkout-watch.mjs +2 -2
  183. package/scripts/dev/file-browser.mjs +2 -4
  184. package/scripts/dev/file-writer.mjs +1 -1
  185. package/scripts/dev/git-status.mjs +2 -2
  186. package/scripts/dev/locate-handler.mjs +3 -3
  187. package/scripts/dev/port-check.mjs +3 -3
  188. package/scripts/dev/publish.mjs +5 -9
  189. package/scripts/dev/shot.mjs +1 -1
  190. package/scripts/dev/unified-diff.mjs +8 -3
  191. package/scripts/tot-dev.mjs +6 -9
  192. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  193. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -1,7 +1,15 @@
1
1
  ---
2
2
  import Layout from "@/layouts/Layout.astro";
3
- import { getBlogPost, resolveBlogRedirect } from "@/lib/blog/provider.js";
4
- import { breadcrumbLd } from "@/lib/jsonld.js";
3
+ import BlogPaywall from "@/components/blog/BlogPaywall.astro";
4
+ import ReactionBar from "@/components/blog/ReactionBar.astro";
5
+ import { getBlogPost, listBlogPosts, resolveBlogRedirect } from "@/lib/blog/provider.js";
6
+ import { authorSlug, getTranslations } from "@/lib/blog/collection.js";
7
+ import { viewerHasBlogAccess } from "@/lib/blog/access.js";
8
+ import { articleBodyHtml } from "@/lib/blog/teaser.js";
9
+ import { readViewerSession, safeNext } from "@/lib/auth/route.js";
10
+ import { buildAlternates } from "@/lib/seo/alternates.js";
11
+ import { languageLabel } from "@/lib/i18n.js";
12
+ import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
5
13
 
6
14
  const { tenant, canonicalBase, basePath } = Astro.locals;
7
15
  const slug = Astro.params.slug ?? "";
@@ -12,26 +20,55 @@ if (!post) {
12
20
  return new Response(null, { status: 404 });
13
21
  }
14
22
 
23
+ // Entitlement (⟂ visibility): a gated post is still published/listed, but an
24
+ // unentitled reader sees a teaser + paywall rather than the full body. A teaser
25
+ // is a legitimate partial-content 200 — indexable, NOT noindexed (B4.0 spike §4).
26
+ const viewer = await readViewerSession(Astro);
27
+ const gated = (post.access ?? "public") !== "public";
28
+ const unlock = viewerHasBlogAccess(viewer, post);
29
+ const bodyHtml = articleBodyHtml(post.contentHtml, { gated, unlock });
30
+ const next = safeNext(`${basePath}/blog/${post.slug}/`);
31
+ const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
32
+
15
33
  const canonical = new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString();
16
- const articleLd = {
17
- "@context": "https://schema.org",
18
- "@type": "BlogPosting",
19
- headline: post.title,
20
- description: post.description,
34
+ // The author archive (`/blog/author/<slug>`) lists posts from the curated blog
35
+ // index, and 404s for an author with no LISTED post (an unlisted-only or
36
+ // index-omitted byline). Resolve that up front so the byline links ONLY when the
37
+ // archive will actually render — a byline must never point at a missing page
38
+ // (the invariant `blog/author/[author].astro` documents). Same slug + same source
39
+ // the archive itself filters on, so the two can't disagree.
40
+ const authorArchiveSlug = authorSlug(post.author);
41
+ const authorHasArchive = (await listBlogPosts(tenant.tenant_id)).some(
42
+ (p) => authorSlug(p.author) === authorArchiveSlug,
43
+ );
44
+ const authorUrl = authorHasArchive
45
+ ? new URL(`/blog/author/${authorArchiveSlug}/`, canonicalBase + "/").toString()
46
+ : undefined;
47
+ const articleLd = blogPostingLd(post, {
21
48
  url: canonical,
22
- mainEntityOfPage: canonical,
23
- datePublished: post.publishedAt,
24
- dateModified: post.modifiedAt,
25
- author: { "@type": "Person", name: post.author },
26
- publisher: { "@type": "Organization", name: tenant.displayName, url: canonicalBase },
27
- ...(post.featuredImage ? { image: new URL(post.featuredImage.src, canonicalBase + "/").toString() } : {}),
28
- };
49
+ canonicalBase,
50
+ publisherName: tenant.displayName,
51
+ authorUrl,
52
+ });
29
53
  const crumbs = breadcrumbLd([
30
54
  { label: tenant.displayName, href: "/" },
31
55
  { label: "Blog", href: "/blog/" },
32
56
  { label: post.title, href: `/blog/${post.slug}/` },
33
57
  ], canonicalBase);
34
58
  const blogHref = `${basePath}/blog/`;
59
+ const authorHref = authorHasArchive
60
+ ? `${basePath}/blog/author/${authorArchiveSlug}/`
61
+ : null;
62
+
63
+ // I2 — hreflang alternates + per-variant <html lang>. Singleton ⇒ [] alternates
64
+ // and no `lang` override, so a non-translated post is byte-identical to today.
65
+ const translations = post.translationGroupId
66
+ ? await getTranslations(tenant.tenant_id, "blog", post.slug)
67
+ : [];
68
+ const alternates = buildAlternates(translations, (s) =>
69
+ new URL(`/blog/${s}/`, canonicalBase + "/").toString(),
70
+ );
71
+ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
35
72
  ---
36
73
 
37
74
  <Layout
@@ -41,6 +78,19 @@ const blogHref = `${basePath}/blog/`;
41
78
  ogType="article"
42
79
  ogImage={post.featuredImage?.src}
43
80
  jsonLd={[crumbs, articleLd]}
81
+ article={{
82
+ publishedTime: post.publishedAt,
83
+ modifiedTime: post.modifiedAt,
84
+ author: authorUrl,
85
+ section: post.categories[0],
86
+ tags: post.tags,
87
+ }}
88
+ imageAlt={post.featuredImage?.alt}
89
+ imageWidth={post.featuredImage?.width}
90
+ imageHeight={post.featuredImage?.height}
91
+ canonicalOverride={post.canonicalUrl}
92
+ alternates={alternates}
93
+ lang={lang}
44
94
  >
45
95
  <article class="article-shell">
46
96
  <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={blogHref}>Blog</a><span aria-hidden="true">/</span><span>{post.categories[0] ?? "Article"}</span></nav>
@@ -49,10 +99,27 @@ const blogHref = `${basePath}/blog/`;
49
99
  <h1>{post.title}</h1>
50
100
  <p class="dek">{post.excerpt}</p>
51
101
  <div class="byline">
52
- <span>By {post.author}</span>
102
+ <span>By {authorHref
103
+ ? <a href={authorHref} class="byline-author" rel="author">{post.author}</a>
104
+ : <span class="byline-author">{post.author}</span>}</span>
53
105
  <span aria-hidden="true">·</span>
54
106
  <time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
55
107
  </div>
108
+ {translations.length > 1 && (
109
+ <nav class="lang-switcher" aria-label="Available languages">
110
+ {translations.map((variant) => {
111
+ const code = variant.inLanguage ?? "en";
112
+ const current = variant.slug === post.slug;
113
+ return (
114
+ <a
115
+ hreflang={code}
116
+ href={`${basePath}/blog/${variant.slug}/`}
117
+ aria-current={current ? "true" : undefined}
118
+ >{languageLabel(code)}</a>
119
+ );
120
+ })}
121
+ </nav>
122
+ )}
56
123
  </header>
57
124
  {post.featuredImage && (
58
125
  <img
@@ -70,8 +137,14 @@ const blogHref = `${basePath}/blog/`;
70
137
  <a href={blogHref}>← All articles</a>
71
138
  {post.tags.length > 0 && <div class="article-tags">{post.tags.map((tag) => <span>{tag}</span>)}</div>}
72
139
  </aside>
73
- <div class="blog-body" set:html={post.contentHtml}></div>
140
+ <div class="blog-main">
141
+ <div class="blog-body" set:html={bodyHtml}></div>
142
+ {gated && !unlock && (
143
+ <BlogPaywall access={post.access ?? "authenticated"} next={next} signedIn={!!viewer} loginHref={loginHref} />
144
+ )}
145
+ </div>
74
146
  </div>
147
+ <ReactionBar collection="blog" slug={post.slug} />
75
148
  </article>
76
149
  </Layout>
77
150
 
@@ -84,12 +157,19 @@ const blogHref = `${basePath}/blog/`;
84
157
  h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
85
158
  .dek { max-width: 68ch; margin: 1.5rem auto 0; color: var(--color-muted); font-size: clamp(1rem, 2vw, 1.2rem); }
86
159
  .byline { display: flex; justify-content: center; gap: .55rem; margin-top: 1.35rem; color: var(--color-muted); font-size: .85rem; }
160
+ .byline-author { color: var(--color-accent-ink); font-weight: 700; text-decoration: none; }
161
+ .byline-author:hover { text-decoration: underline; text-underline-offset: .18em; }
162
+ .lang-switcher { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; margin-top: 1.1rem; }
163
+ .lang-switcher a { padding: .28rem .6rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .78rem; font-weight: 600; text-decoration: none; }
164
+ .lang-switcher a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
165
+ .lang-switcher a[aria-current="true"] { color: var(--color-accent-ink); border-color: var(--color-accent); background: var(--color-surface); }
87
166
  .featured { display: block; width: 100%; max-height: 620px; object-fit: cover; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
88
167
  .article-grid { display: grid; grid-template-columns: 190px minmax(0, 720px); justify-content: center; gap: clamp(2rem, 6vw, 5rem); margin-top: clamp(3rem, 7vw, 5.5rem); }
89
168
  aside { align-self: start; position: sticky; top: 7rem; }
90
169
  aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
91
170
  .article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
92
171
  .article-tags span { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; }
172
+ .blog-main { min-width: 0; }
93
173
  .blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
94
174
  .blog-body :global(p), .blog-body :global(ul), .blog-body :global(ol), .blog-body :global(blockquote), .blog-body :global(figure), .blog-body :global(table), .blog-body :global(pre) { margin-block: 1.25rem; }
95
175
  .blog-body :global(h2), .blog-body :global(h3), .blog-body :global(h4) { scroll-margin-top: 7rem; margin: 2.8rem 0 1rem; line-height: 1.18; }
@@ -0,0 +1,140 @@
1
+ ---
2
+ import Layout from "@/layouts/Layout.astro";
3
+ import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
4
+ import { listBlogPosts } from "@/lib/blog/provider.js";
5
+ import { authorSlug, getAuthors } from "@/lib/blog/collection.js";
6
+ import { breadcrumbLd } from "@/lib/jsonld.js";
7
+
8
+ // Author archive for the `/blog` collection. Lists exactly the posts whose
9
+ // bylines link here (same source as /blog/index.astro + /blog/[slug].astro),
10
+ // so a byline never points at a page missing its post.
11
+ const { tenant, basePath, canonicalBase } = Astro.locals;
12
+ const slug = Astro.params.author ?? "";
13
+
14
+ const allPosts = await listBlogPosts(tenant.tenant_id);
15
+ const posts = allPosts.filter((post) => authorSlug(post.author) === slug);
16
+ if (posts.length === 0) return new Response(null, { status: 404 });
17
+
18
+ const authorName = posts[0]!.author;
19
+ const bio = (await getAuthors(tenant.tenant_id, "blog"))[slug];
20
+
21
+ const href = (path: string) => `${basePath}${path}` || "/";
22
+ const canonical = new URL(`/blog/author/${slug}/`, canonicalBase + "/").toString();
23
+ const avatarUrl = bio?.avatar ? new URL(bio.avatar, canonicalBase + "/").toString() : undefined;
24
+
25
+ const personLd = {
26
+ "@context": "https://schema.org",
27
+ "@type": "Person",
28
+ name: authorName,
29
+ url: canonical,
30
+ ...(bio?.title ? { jobTitle: bio.title } : {}),
31
+ ...(bio?.bio ? { description: bio.bio } : {}),
32
+ ...(avatarUrl ? { image: avatarUrl } : {}),
33
+ ...(bio?.url ? { mainEntityOfPage: bio.url } : {}),
34
+ ...(bio?.sameAs && bio.sameAs.length > 0 ? { sameAs: bio.sameAs } : {}),
35
+ };
36
+ const profileLd = {
37
+ "@context": "https://schema.org",
38
+ "@type": "ProfilePage",
39
+ mainEntity: personLd,
40
+ };
41
+ const itemListLd = {
42
+ "@context": "https://schema.org",
43
+ "@type": "ItemList",
44
+ numberOfItems: posts.length,
45
+ itemListElement: posts.map((post, index) => ({
46
+ "@type": "ListItem",
47
+ position: index + 1,
48
+ name: post.title,
49
+ url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
50
+ })),
51
+ };
52
+ const crumbs = breadcrumbLd([
53
+ { label: tenant.displayName, href: "/" },
54
+ { label: "Blog", href: "/blog/" },
55
+ { label: authorName, href: `/blog/author/${slug}/` },
56
+ ], canonicalBase);
57
+ ---
58
+
59
+ <Layout
60
+ title={`${authorName} — Blog`}
61
+ description={bio?.bio ?? `Articles by ${authorName}.`}
62
+ path={`/blog/author/${slug}/`}
63
+ fullBleed
64
+ jsonLd={[crumbs, profileLd, itemListLd]}
65
+ >
66
+ <header class="author-hero">
67
+ <div class="blog-shell">
68
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={href("/blog/")}>Blog</a><span aria-hidden="true">/</span><span>Author</span></nav>
69
+ <div class="author-id">
70
+ {avatarUrl && <img class="avatar" src={avatarUrl} alt={authorName} width="96" height="96" decoding="async" />}
71
+ <div>
72
+ <p class="eyebrow eyebrow--tick">Author</p>
73
+ <h1>{authorName}</h1>
74
+ {bio?.title && <p class="author-title">{bio.title}</p>}
75
+ {bio?.bio && <p class="author-bio">{bio.bio}</p>}
76
+ {bio?.sameAs && bio.sameAs.length > 0 && (
77
+ <p class="author-links">
78
+ {bio.sameAs.map((url) => <a href={url} rel="me nofollow" target="_blank">{new URL(url).hostname.replace(/^www\./, "")}</a>)}
79
+ </p>
80
+ )}
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </header>
85
+
86
+ <div class="blog-shell">
87
+ <section class="post-grid" aria-label={`Posts by ${authorName}`}>
88
+ {posts.map((post, index) => (
89
+ <article class="post-card">
90
+ <a href={href(`/blog/${post.slug}/`)} aria-label={`Read ${post.title}`}>
91
+ {post.featuredImage ? (
92
+ <img
93
+ src={post.featuredImage.src}
94
+ alt={post.featuredImage.alt}
95
+ width={post.featuredImage.width}
96
+ height={post.featuredImage.height}
97
+ loading={index < 3 ? "eager" : "lazy"}
98
+ decoding="async"
99
+ />
100
+ ) : <div class="image-placeholder" aria-hidden="true"></div>}
101
+ <div class="post-card-copy">
102
+ <p class="post-meta"><time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</time>{post.tags[0] && <> · {post.tags[0]}</>}</p>
103
+ <PostAccessBadge access={post.access} />
104
+ <h2>{post.title}</h2>
105
+ <p>{post.excerpt}</p>
106
+ <span>Read article <span aria-hidden="true">→</span></span>
107
+ </div>
108
+ </a>
109
+ </article>
110
+ ))}
111
+ </section>
112
+ </div>
113
+ </Layout>
114
+
115
+ <style>
116
+ .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
117
+ .author-hero { padding: clamp(3.5rem, 8vw, 6rem) 0 clamp(2rem, 5vw, 3.5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
118
+ .breadcrumbs { display: flex; gap: .55rem; align-items: center; font-size: .84rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); }
119
+ .breadcrumbs a { color: inherit; font-weight: 700; }
120
+ .author-id { display: flex; gap: 1.5rem; align-items: center; margin-top: 1.5rem; }
121
+ .avatar { flex: 0 0 auto; width: 96px; height: 96px; border-radius: var(--radius-full); object-fit: cover; box-shadow: var(--shadow-md); }
122
+ .author-hero h1 { margin-top: .5rem; color: inherit; font-size: clamp(2.4rem, 6vw, 4.2rem); }
123
+ .author-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
124
+ .author-title { margin-top: .35rem; font-weight: 700; color: color-mix(in srgb, var(--color-primary-contrast) 90%, transparent); }
125
+ .author-bio { max-width: 62ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
126
+ .author-links { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 1.1rem; }
127
+ .author-links a { color: inherit; font-weight: 700; font-size: .85rem; text-decoration: underline; text-underline-offset: .18em; }
128
+ .post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; padding-block: clamp(2.5rem, 6vw, 4.5rem); }
129
+ .post-card { height: 100%; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: transform .2s ease, box-shadow .2s ease; }
130
+ .post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
131
+ .post-card a { display: flex; height: 100%; flex-direction: column; }
132
+ .post-card img, .image-placeholder { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, white), color-mix(in srgb, var(--color-accent) 24%, white)); }
133
+ .post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
134
+ .post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
135
+ .post-card h2 { font-size: 1.18rem; line-height: 1.25; }
136
+ .post-card-copy > p:not(.post-meta) { display: -webkit-box; margin: .75rem 0 1rem; overflow: hidden; color: var(--color-muted); font-size: .92rem; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
137
+ .post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
138
+ @media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
139
+ @media (max-width: 640px) { .author-id { flex-direction: column; align-items: flex-start; } .post-grid { grid-template-columns: 1fr; } }
140
+ </style>
@@ -0,0 +1,67 @@
1
+ ---
2
+ import Layout from "@/layouts/Layout.astro";
3
+ import PostGrid from "@/components/blog/PostGrid.astro";
4
+ import { listBlogPosts } from "@/lib/blog/provider.js";
5
+ import { collectPostsByTerm } from "@/lib/blog/collection.js";
6
+ import { breadcrumbLd } from "@/lib/jsonld.js";
7
+
8
+ const { tenant, basePath, canonicalBase } = Astro.locals;
9
+ const param = Astro.params.category ?? "";
10
+
11
+ // Same source as the /blog index (the collection's curated list), filtered to
12
+ // one category — so a post can never appear here that isn't already live on /blog.
13
+ const all = await listBlogPosts(tenant.tenant_id);
14
+ const match = collectPostsByTerm(all, "categories", param);
15
+ if (!match) return new Response(null, { status: 404 });
16
+ const { label, posts } = match;
17
+
18
+ const path = `/blog/category/${param}/`;
19
+ const itemList = {
20
+ "@context": "https://schema.org",
21
+ "@type": "ItemList",
22
+ numberOfItems: posts.length,
23
+ itemListElement: posts.map((post, index) => ({
24
+ "@type": "ListItem",
25
+ position: index + 1,
26
+ name: post.title,
27
+ url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
28
+ })),
29
+ };
30
+ const crumbs = breadcrumbLd([
31
+ { label: tenant.displayName, href: "/" },
32
+ { label: "Blog", href: "/blog/" },
33
+ { label, href: path },
34
+ ], canonicalBase);
35
+ ---
36
+
37
+ <Layout
38
+ title={`${label} — Blog`}
39
+ description={`Articles in ${label} from ${tenant.displayName}.`}
40
+ path={path}
41
+ fullBleed
42
+ jsonLd={[crumbs, itemList]}
43
+ >
44
+ <header class="blog-hero">
45
+ <div class="blog-shell">
46
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={`${basePath}/blog/`}>Blog</a><span aria-hidden="true">/</span><span>Category</span></nav>
47
+ <p class="eyebrow eyebrow--tick">Category</p>
48
+ <h1>{label}</h1>
49
+ <p>{posts.length} {posts.length === 1 ? "article" : "articles"} in this category.</p>
50
+ </div>
51
+ </header>
52
+
53
+ <div class="blog-shell blog-layout">
54
+ <PostGrid posts={posts} basePath={basePath} base="/blog" />
55
+ </div>
56
+ </Layout>
57
+
58
+ <style>
59
+ .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
60
+ .blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
61
+ .breadcrumbs { display: flex; gap: .55rem; align-items: center; margin-bottom: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .84rem; }
62
+ .breadcrumbs a { color: inherit; font-weight: 700; }
63
+ .blog-hero h1 { max-width: 14ch; margin-top: .75rem; color: inherit; font-size: clamp(2.6rem, 6vw, 4.6rem); }
64
+ .blog-hero p:last-child { max-width: 58ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
65
+ .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
66
+ .blog-layout { padding-block: clamp(3rem, 7vw, 6rem); }
67
+ </style>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Layout from "@/layouts/Layout.astro";
3
+ import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
3
4
  import { listBlogPosts } from "@/lib/blog/provider.js";
4
5
 
5
6
  const { tenant, basePath, canonicalBase } = Astro.locals;
@@ -60,6 +61,7 @@ const itemList = {
60
61
  ) : <div class="image-placeholder" aria-hidden="true"></div>}
61
62
  <div class="post-card-copy">
62
63
  <p class="post-meta"><time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</time>{post.tags[0] && <> · {post.tags[0]}</>}</p>
64
+ <PostAccessBadge access={post.access} />
63
65
  <h2>{post.title}</h2>
64
66
  <p>{post.excerpt}</p>
65
67
  <span>Read article <span aria-hidden="true">→</span></span>
@@ -0,0 +1,65 @@
1
+ ---
2
+ import Layout from "@/layouts/Layout.astro";
3
+ import PostGrid from "@/components/blog/PostGrid.astro";
4
+ import { listBlogPosts } from "@/lib/blog/provider.js";
5
+ import { collectPostsByTerm } from "@/lib/blog/collection.js";
6
+ import { breadcrumbLd } from "@/lib/jsonld.js";
7
+
8
+ const { tenant, basePath, canonicalBase } = Astro.locals;
9
+ const param = Astro.params.tag ?? "";
10
+
11
+ const all = await listBlogPosts(tenant.tenant_id);
12
+ const match = collectPostsByTerm(all, "tags", param);
13
+ if (!match) return new Response(null, { status: 404 });
14
+ const { label, posts } = match;
15
+
16
+ const path = `/blog/tag/${param}/`;
17
+ const itemList = {
18
+ "@context": "https://schema.org",
19
+ "@type": "ItemList",
20
+ numberOfItems: posts.length,
21
+ itemListElement: posts.map((post, index) => ({
22
+ "@type": "ListItem",
23
+ position: index + 1,
24
+ name: post.title,
25
+ url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
26
+ })),
27
+ };
28
+ const crumbs = breadcrumbLd([
29
+ { label: tenant.displayName, href: "/" },
30
+ { label: "Blog", href: "/blog/" },
31
+ { label: `#${label}`, href: path },
32
+ ], canonicalBase);
33
+ ---
34
+
35
+ <Layout
36
+ title={`#${label} — Blog`}
37
+ description={`Articles tagged ${label} from ${tenant.displayName}.`}
38
+ path={path}
39
+ fullBleed
40
+ jsonLd={[crumbs, itemList]}
41
+ >
42
+ <header class="blog-hero">
43
+ <div class="blog-shell">
44
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={`${basePath}/blog/`}>Blog</a><span aria-hidden="true">/</span><span>Tag</span></nav>
45
+ <p class="eyebrow eyebrow--tick">Tag</p>
46
+ <h1>#{label}</h1>
47
+ <p>{posts.length} {posts.length === 1 ? "article" : "articles"} with this tag.</p>
48
+ </div>
49
+ </header>
50
+
51
+ <div class="blog-shell blog-layout">
52
+ <PostGrid posts={posts} basePath={basePath} base="/blog" />
53
+ </div>
54
+ </Layout>
55
+
56
+ <style>
57
+ .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
58
+ .blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
59
+ .breadcrumbs { display: flex; gap: .55rem; align-items: center; margin-bottom: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .84rem; }
60
+ .breadcrumbs a { color: inherit; font-weight: 700; }
61
+ .blog-hero h1 { max-width: 14ch; margin-top: .75rem; color: inherit; font-size: clamp(2.6rem, 6vw, 4.6rem); }
62
+ .blog-hero p:last-child { max-width: 58ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
63
+ .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
64
+ .blog-layout { padding-block: clamp(3rem, 7vw, 6rem); }
65
+ </style>
@@ -132,6 +132,40 @@ const storeNotSetUp =
132
132
  // hasStoreAccess) for anything that mints a command or starts the activity poll.
133
133
  const canSetupStore = hasStoreAccess && !noResolvableStore && !storeNotSetUp;
134
134
 
135
+ // vc-app-binding safety-net (08-18, §5): a BROWSER-FIRST developer reached this page
136
+ // via the storefront's own sign-in (a tot_session cookie) without ever crossing an
137
+ // MCP binding seam (rendezvous approve / native OAuth), so `appForCaller` doesn't
138
+ // resolve them and the `tot start` command we're about to hand them would 403 with
139
+ // "No version-control app is bound." Ensure the binding now — on-behalf of the
140
+ // verified session identity, via the app's private_key_jwt channel. Best-effort +
141
+ // fail-open (never blocks render), throttled to once per browser session so it stays
142
+ // off the hot render path; the authority-seam binding + backfill remain the real
143
+ // guarantee. Gated on canSetupStore (entitled + resolvable + provisioned), the exact
144
+ // condition under which we render a runnable command.
145
+ //
146
+ // The binding itself is control-plane (authors a forge/MCP binding under the app
147
+ // credential), so this renderer page reaches it only over the network — POST
148
+ // /api/dev/ensure-vc-binding — never by static import (renderer-carve moat). The
149
+ // endpoint re-derives the developer from the forwarded session cookie.
150
+ if (canSetupStore && session && !Astro.cookies.has("tot_vc_bound")) {
151
+ const vcBind = await fetch(new URL(`${basePath}/api/dev/ensure-vc-binding`, url.origin), {
152
+ method: "POST",
153
+ headers: { cookie: Astro.request.headers.get("cookie") ?? "" },
154
+ })
155
+ .then((r) => (r.ok ? r.json() : null))
156
+ .catch(() => null);
157
+ if (vcBind?.ok) {
158
+ // Throttle only on a real success — a fail-open miss retries on the next load.
159
+ Astro.cookies.set("tot_vc_bound", "1", {
160
+ path: "/",
161
+ httpOnly: true,
162
+ sameSite: "lax",
163
+ secure: true,
164
+ maxAge: 3600, // re-check hourly so a lost/revoked binding self-heals
165
+ });
166
+ }
167
+ }
168
+
135
169
  // Signed in, but either no developer capability on any store OR (staff bypass) no store
136
170
  // resolves for this viewer/host — an honest recovery state (not a 401): show what to
137
171
  // do, not the fastpath. NOTE: storeNotSetUp is deliberately NOT folded in here — it is
@@ -871,10 +905,18 @@ const hostedActivityScript = `
871
905
  }
872
906
 
873
907
  // Item 6 — once the CLI reports the developer's $EDITOR, show the EXACT command to
874
- // open the first file to edit (e.g. \`code content/home.html\`); otherwise a generic
875
- // \`$EDITOR content/home.html\` plus a nudge to set $EDITOR.
908
+ // open the first file to edit (e.g. \`code content/home.html\`). Without one, guess
909
+ // from the browser's OS (a reasonable proxy for the local dev machine in the common
910
+ // case of viewing the cockpit on the same laptop) and default to VS Code (\`code\`) —
911
+ // the most common case — with an alternate + AI-agent note underneath.
876
912
  var editOpenCmd = document.getElementById("editOpenCmd");
877
913
  var editOpenHint = document.getElementById("editOpenHint");
914
+ function platformOpenDirCmd(dirArg) {
915
+ var plat = String((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || navigator.userAgent || "");
916
+ if (/win/i.test(plat)) return "explorer " + dirArg;
917
+ if (/mac/i.test(plat)) return "open " + dirArg;
918
+ return "xdg-open " + dirArg;
919
+ }
878
920
  function updateEditHint(rt) {
879
921
  if (!editOpenCmd) return;
880
922
  var editor = rt && typeof rt.editor === "string" ? rt.editor : "";
@@ -882,13 +924,18 @@ const hostedActivityScript = `
882
924
  // Prefer the FULL path (works from any directory) when the CLI reports the
883
925
  // project dir; else a project-relative path with a "run it from there" note.
884
926
  var file = cwd ? (cwd.replace(/\\/+$/, "") + "/content/home.html") : "content/home.html";
885
- editOpenCmd.textContent = (editor || "$EDITOR") + " " + file;
927
+ editOpenCmd.textContent = (editor || "code") + " " + file;
886
928
  if (editOpenHint) {
887
- editOpenHint.textContent = editor
888
- ? (cwd
889
- ? "Run that anywhere to open the file — then change a line and save."
890
- : "Run that from your project directory — then change a line and save.")
891
- : "Tip: set $EDITOR (e.g. \`export EDITOR=\\"code\\"\`) and we'll show the exact open command here.";
929
+ if (editor) {
930
+ editOpenHint.textContent = cwd
931
+ ? "Run that anywhere to open the file — then change a line and save."
932
+ : "Run that from your project directory — then change a line and save.";
933
+ } else {
934
+ var altCmd = platformOpenDirCmd(cwd || ".");
935
+ editOpenHint.textContent = "Using VS Code? Run that. Prefer Cursor, another editor, or an AI coding " +
936
+ "agent like Claude? Same idea — open the folder (\`" + altCmd + "\`) or file above and edit there. " +
937
+ "Set $EDITOR and we'll show your exact command instead.";
938
+ }
892
939
  }
893
940
  }
894
941
 
@@ -1570,6 +1617,8 @@ const hostedActivityScript = `
1570
1617
 
1571
1618
  /* ---- Step 2 "lay it beside your editor" tip ---- */
1572
1619
  .tip-lay { margin: 1rem 0 0; font-size: .9rem; color: var(--muted); line-height: 1.5; }
1620
+ .tip-branch { margin: .5rem 0 0; font-size: .9rem; color: var(--muted); line-height: 1.5; }
1621
+ .tip-branch a { color: inherit; text-decoration: underline; }
1573
1622
 
1574
1623
  /* ---- Guided accordion: a step body animates IN when opened (collapse is instant
1575
1624
  + synchronous so the flow can't get stuck). Shared with isLocalDevLoop —
@@ -2235,7 +2284,8 @@ const hostedActivityScript = `
2235
2284
  <span class="cmd" id="editOpenCmd">$EDITOR content/home.html</span></div>
2236
2285
  </div>
2237
2286
  <p class="cmd-note" id="editOpenHint">Tip: set <code>$EDITOR</code> and we'll show the exact open command here.</p>
2238
- <p class="tip-lay">Lay this cockpit <em>beside</em> your editor and your running store — hot reload is on, so every save reloads the store and lands in <strong>Your activity</strong> here, live.</p>
2287
+ <p class="tip-lay">Lay this cockpit <em>beside</em> your editor (or an AI coding agent like Claude — same idea) and your running store — hot reload is on, so every save reloads the store and lands in <strong>Your activity</strong> here, live. Keep the terminal running <code>tot start</code>/<code>tot dev</code> open too, watching for build errors — need the command line for something else? Open a new terminal instead of closing that one.</p>
2288
+ <p class="tip-branch">No need to think about git branches for this — just keep editing and running <code>tot preview</code>; it updates the same change every time. <a href="https://github.com/tokenoftrust/storefront/blob/feature/8500-storefront-integration/docs/kb/develop-ship-and-connect-domain.md#branching-is-an-advanced-feature--you-almost-never-need-it" target="_blank" rel="noopener">Working on two things at once? Read this first →</a></p>
2239
2289
  </div>
2240
2290
  </section>
2241
2291
  </div>
@@ -2651,6 +2701,17 @@ const hostedActivityScript = `
2651
2701
  var rzvTimer = null, rzvCode = null, rzvDecided = false;
2652
2702
  function rzvShow(el) { if (el) el.hidden = false; }
2653
2703
  function rzvHide(el) { if (el) el.hidden = true; }
2704
+ // Once the terminal has ATTACHED, the command has plainly been pasted and
2705
+ // run — hide the copy affordances so the approve prompt is the page's
2706
+ // single focus (DZ 2026-08-18). An EXPIRED sign-in restores the generate
2707
+ // button (a fresh mint is needed); the stale command's copy icon stays gone.
2708
+ function rzvHideCopyCtas() {
2709
+ if (btn) btn.hidden = true;
2710
+ if (copyIcon) copyIcon.hidden = true;
2711
+ }
2712
+ function rzvRestoreGenerate() {
2713
+ if (btn) btn.hidden = false;
2714
+ }
2654
2715
  function rzvStopPoll() { if (rzvTimer) { clearTimeout(rzvTimer); rzvTimer = null; } }
2655
2716
  function rzvFinish(msg) {
2656
2717
  rzvStopPoll(); rzvDecided = true;
@@ -2669,13 +2730,14 @@ const hostedActivityScript = `
2669
2730
  } catch (e) { rzvTimer = setTimeout(rzvPoll, 2800); return; }
2670
2731
  if (!r || !r.ok) { rzvTimer = setTimeout(rzvPoll, 3200); return; }
2671
2732
  if (b.state === "attached" && b.fingerprint) {
2733
+ rzvHideCopyCtas();
2672
2734
  rzvHide(rzvWait); rzvShow(rzvConfirm);
2673
2735
  if (rzvFp) { rzvFp.textContent = b.fingerprint; rzvFp.setAttribute("data-fp", b.fingerprint); }
2674
2736
  return; // stop polling — wait for the human decision
2675
2737
  }
2676
- if (b.state === "approved") { rzvFinish("Signed in ✓ — your terminal is good to go."); return; }
2677
- if (b.state === "denied") { rzvFinish("Rejected — that terminal wasn’t approved. You’re safe; generate a fresh command to try again."); return; }
2678
- if (b.state === "expired") { rzvFinish("This sign-in expired. Generate a fresh setup command above."); return; }
2738
+ if (b.state === "approved") { rzvHideCopyCtas(); rzvFinish("Signed in ✓ — your terminal is good to go."); return; }
2739
+ if (b.state === "denied") { rzvHideCopyCtas(); rzvFinish("Rejected — that terminal wasn’t approved. You’re safe; generate a fresh command to try again."); return; }
2740
+ if (b.state === "expired") { rzvRestoreGenerate(); rzvFinish("This sign-in expired. Generate a fresh setup command above."); return; }
2679
2741
  rzvTimer = setTimeout(rzvPoll, 2500); // pending → keep watching
2680
2742
  }
2681
2743
  function rzvStart(code) {
@@ -127,19 +127,23 @@ const backHref = `/dashboard/${encodeURIComponent(appDomain)}`;
127
127
  <div class="card">
128
128
  <label for="email">Developer email</label>
129
129
  <input id="email" type="email" placeholder="dev@agency.com" autocomplete="off" />
130
- {!isRealMember && (
131
- <>
132
- {/* #8453: ToT staff may grant any role; a plain member is developer-only
133
- (no selector — tot20 ignores a member's role and pins to developer).
134
- Values are tot20's role vocabulary: developer | appAdmin | appOwner. */}
135
- <label for="role">Role</label>
136
- <select id="role">
137
- <option value="developer" selected>Developer</option>
130
+ {/* tot20's role vocabulary: developer | appAdmin | appOwner | contributor.
131
+ `contributor` is a strictly-weaker self-downgrade a plain member may
132
+ request directly (tot20 honors it for a non-staff operator handoff
133
+ 2026-08-22-tot20-contributor-role); appAdmin/appOwner still require ToT
134
+ staff (#8453) and tot20 ignores them from a plain member (pins to
135
+ developer), so only staff see those two options. */}
136
+ <label for="role">Role</label>
137
+ <select id="role">
138
+ <option value="developer" selected>Developer</option>
139
+ <option value="contributor">Contributor (blog &amp; content only)</option>
140
+ {!isRealMember && (
141
+ <>
138
142
  <option value="appAdmin">Admin</option>
139
143
  <option value="appOwner">Owner</option>
140
- </select>
141
- </>
142
- )}
144
+ </>
145
+ )}
146
+ </select>
143
147
  <label for="note">Note to the developer (optional)</label>
144
148
  <textarea id="note" rows="3" maxlength="1000"
145
149
  placeholder="Add a personal message — they'll see it in the invite email."></textarea>