@hraness/design-kit 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARTICLE_COPY.md +208 -0
- package/HERO_FIELDS.md +55 -0
- package/ICONS.md +56 -0
- package/LANTERN_MATERIAL.md +60 -0
- package/LICENSE +21 -0
- package/MARKETING_COPY.md +49 -0
- package/MARKETING_PRESET.md +108 -0
- package/PALETTES.md +90 -0
- package/PAPER_THEME.md +128 -0
- package/README.md +745 -0
- package/STATUS_PAGES.md +67 -0
- package/STYLE.md +245 -0
- package/WRITING.md +72 -0
- package/dist/browser/index.js +2345 -0
- package/dist/chunk-44djxk16.js +6872 -0
- package/dist/chunk-5gtx3pza.js +9 -0
- package/dist/chunk-77391vmq.js +454 -0
- package/dist/chunk-7b1586eb.js +1209 -0
- package/dist/chunk-8834fh4n.js +6 -0
- package/dist/chunk-9t8xyqte.js +870 -0
- package/dist/chunk-cejpzyfh.js +446 -0
- package/dist/chunk-eh71jz57.js +429 -0
- package/dist/chunk-evb02bc1.js +516 -0
- package/dist/chunk-h4k7yv6x.js +368 -0
- package/dist/chunk-he8eznb1.js +760 -0
- package/dist/chunk-mr1vdcjq.js +404 -0
- package/dist/chunk-wzvdn8ey.js +116 -0
- package/dist/fonts/nebula-sans/social-fonts.generated.js +53 -0
- package/dist/icons.js +48 -0
- package/dist/index.js +237 -0
- package/dist/launch.js +41 -0
- package/dist/mockups/client.js +422 -0
- package/dist/mockups/index.js +1850 -0
- package/dist/portfolio.js +1549 -0
- package/dist/provider-marks.js +19 -0
- package/dist/react/charts.js +11 -0
- package/dist/react/hero-backdrop.js +8 -0
- package/dist/react/index.js +7252 -0
- package/dist/react/platform-install.js +10 -0
- package/dist/react/server.js +137 -0
- package/dist/stylex-manifest.json +1 -0
- package/dist/stylex.css +5392 -0
- package/dist/syntax-highlighting.js +15 -0
- package/dist/testing.js +250 -0
- package/package.json +377 -0
- package/portfolio-inventory.json +31 -0
- package/scripts/check-lantern-material-snapshot.d.mts +26 -0
- package/scripts/check-lantern-material-snapshot.mjs +126 -0
- package/scripts/check-marketing-snapshot.d.mts +12 -0
- package/scripts/check-marketing-snapshot.mjs +58 -0
- package/scripts/lantern-material-snapshot.ts +101 -0
- package/scripts/marketing-textures.ts +48 -0
- package/scripts/paper-theme-snapshot.ts +97 -0
- package/scripts/product-marketing-snapshot.ts +149 -0
- package/src/appearance-menu.css +266 -0
- package/src/appearance.ts +32 -0
- package/src/article-html.ts +269 -0
- package/src/article.ts +686 -0
- package/src/browser/appearance-menu.ts +604 -0
- package/src/browser/artifact-share.ts +164 -0
- package/src/browser/design-palette.ts +197 -0
- package/src/browser/foil.ts +124 -0
- package/src/browser/hero-light.ts +11 -0
- package/src/browser/index.ts +29 -0
- package/src/browser/status-field.ts +432 -0
- package/src/browser/status-page.ts +100 -0
- package/src/browser/sticky-offset.ts +82 -0
- package/src/browser/theme-color-sync.ts +213 -0
- package/src/charts.css +7 -0
- package/src/compiler-components.css +34 -0
- package/src/compiler-foundation.css +18 -0
- package/src/compiler-palettes.css +7 -0
- package/src/compiler-tokens.css +342 -0
- package/src/components.css +3 -0
- package/src/design-gallery.css +431 -0
- package/src/effects.css +14 -0
- package/src/fonts/geist-mono/GeistMono[wght].woff2 +0 -0
- package/src/fonts/geist-mono/OFL.txt +93 -0
- package/src/fonts/geist-mono/PROVENANCE.md +12 -0
- package/src/fonts/instrument-serif/OFL.txt +93 -0
- package/src/fonts/instrument-serif/UPSTREAM.md +14 -0
- package/src/fonts/instrument-serif/instrument-serif-latin-400.woff2 +0 -0
- package/src/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/fonts/nebula-sans/NebulaSans-Black.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BlackItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Bold.otf +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Bold.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BoldItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Book.otf +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BookItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Light.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-LightItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Medium.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-MediumItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Semibold.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-SemiboldItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/fonts/nebula-sans/social-fonts.generated.ts +3873 -0
- package/src/fonts.css +106 -0
- package/src/icons/act60/act60.svg +5 -0
- package/src/icons/act60/application.svg +9 -0
- package/src/icons/act60/chapter-compare.svg +3 -0
- package/src/icons/act60/decree-residence.svg +6 -0
- package/src/icons/act60/estimate.svg +7 -0
- package/src/icons/act60/export-services.svg +4 -0
- package/src/icons/act60/investor.svg +13 -0
- package/src/icons/act60/presence.svg +7 -0
- package/src/icons/aicharts/aicharts.svg +6 -0
- package/src/icons/aicharts/cost-compare.svg +4 -0
- package/src/icons/aicharts/task-audio.svg +7 -0
- package/src/icons/aicharts/task-coding.svg +6 -0
- package/src/icons/aicharts/task-images.svg +3 -0
- package/src/icons/aicharts/task-reasoning.svg +9 -0
- package/src/icons/aicharts/task-research.svg +5 -0
- package/src/icons/aicharts/task-video.svg +4 -0
- package/src/icons/manifest.json +1371 -0
- package/src/icons/platonik/conversation.svg +3 -0
- package/src/icons/platonik/creature.svg +7 -0
- package/src/icons/platonik/economy.svg +4 -0
- package/src/icons/platonik/keep-light.svg +6 -0
- package/src/icons/platonik/platonik.svg +7 -0
- package/src/icons/platonik/spark-route.svg +15 -0
- package/src/icons/receipts/act60-retry.receipt.json +888 -0
- package/src/icons/receipts/act60.receipt.json +1366 -0
- package/src/icons/receipts/aicharts.receipt.json +1546 -0
- package/src/icons/receipts/platonik.receipt.json +1011 -0
- package/src/icons/receipts/roughday.receipt.json +770 -0
- package/src/icons/receipts/shared.receipt.json +1296 -0
- package/src/icons/receipts/slopcamera.receipt.json +1495 -0
- package/src/icons/receipts/soundfish-retry.receipt.json +460 -0
- package/src/icons/receipts/soundfish.receipt.json +1709 -0
- package/src/icons/receipts/sponge.receipt.json +1743 -0
- package/src/icons/receipts/stripe-history-retry.receipt.json +145 -0
- package/src/icons/receipts/stripe-history.receipt.json +1202 -0
- package/src/icons/receipts/wordcell.receipt.json +1924 -0
- package/src/icons/roughday/finance.svg +7 -0
- package/src/icons/roughday/roughday.svg +6 -0
- package/src/icons/roughday/technology.svg +4 -0
- package/src/icons/roughday/world.svg +4 -0
- package/src/icons/sets/act60.json +63 -0
- package/src/icons/sets/aicharts.json +63 -0
- package/src/icons/sets/platonik.json +39 -0
- package/src/icons/sets/roughday.json +25 -0
- package/src/icons/sets/shared.json +12 -0
- package/src/icons/sets/slopcamera.json +47 -0
- package/src/icons/sets/soundfish.json +43 -0
- package/src/icons/sets/sponge.json +51 -0
- package/src/icons/sets/stripe-history.json +39 -0
- package/src/icons/sets/wordcell.json +51 -0
- package/src/icons/shared/agent-skill.svg +4 -0
- package/src/icons/shared/cli.svg +5 -0
- package/src/icons/shared/privacy.svg +3 -0
- package/src/icons/shared/research.svg +7 -0
- package/src/icons/shared/sdk.svg +3 -0
- package/src/icons/slopcamera/diagram.svg +3 -0
- package/src/icons/slopcamera/direct-scene.svg +5 -0
- package/src/icons/slopcamera/edit-video.svg +17 -0
- package/src/icons/slopcamera/mcp.svg +3 -0
- package/src/icons/slopcamera/native-world.svg +3 -0
- package/src/icons/slopcamera/slopcamera.svg +11 -0
- package/src/icons/soundfish/agent-ui.svg +7 -0
- package/src/icons/soundfish/collaborate.svg +5 -0
- package/src/icons/soundfish/compose.svg +3 -0
- package/src/icons/soundfish/hear.svg +6 -0
- package/src/icons/soundfish/human-ui.svg +6 -0
- package/src/icons/soundfish/share.svg +3 -0
- package/src/icons/soundfish/soundfish.svg +6 -0
- package/src/icons/sponge/api.svg +3 -0
- package/src/icons/sponge/documents.svg +3 -0
- package/src/icons/sponge/library.svg +3 -0
- package/src/icons/sponge/one-question.svg +5 -0
- package/src/icons/sponge/people-direct.svg +5 -0
- package/src/icons/sponge/read.svg +3 -0
- package/src/icons/sponge/save.svg +4 -0
- package/src/icons/sponge/sponge.svg +7 -0
- package/src/icons/stripe-history/company-history.svg +3 -0
- package/src/icons/stripe-history/evidence.svg +4 -0
- package/src/icons/stripe-history/independence.svg +7 -0
- package/src/icons/stripe-history/publications.svg +11 -0
- package/src/icons/stripe-history/sources.svg +6 -0
- package/src/icons/stripe-history/stripe-history.svg +3 -0
- package/src/icons/wordcell/backlinks.svg +6 -0
- package/src/icons/wordcell/capture.svg +6 -0
- package/src/icons/wordcell/git-provenance.svg +4 -0
- package/src/icons/wordcell/kb.svg +4 -0
- package/src/icons/wordcell/markdown.svg +4 -0
- package/src/icons/wordcell/scopes.svg +3 -0
- package/src/icons/wordcell/search.svg +7 -0
- package/src/icons.generated.ts +734 -0
- package/src/icons.ts +51 -0
- package/src/index.ts +275 -0
- package/src/lantern-material.css +213 -0
- package/src/launch.ts +624 -0
- package/src/marketing-assets/UPSTREAM.md +18 -0
- package/src/marketing-assets/cells.svg +1 -0
- package/src/marketing-assets/grain.svg +1 -0
- package/src/marketing-forced-colors.css +12 -0
- package/src/mockups/client.tsx +415 -0
- package/src/mockups/core.tsx +318 -0
- package/src/mockups/frames.tsx +368 -0
- package/src/mockups/index.ts +14 -0
- package/src/mockups/surfaces.tsx +681 -0
- package/src/mockups.css +2607 -0
- package/src/palette-appearance.ts +45 -0
- package/src/palette-bridge.css +124 -0
- package/src/palette-color.ts +41 -0
- package/src/palette-system.css +721 -0
- package/src/palette-themes.ts +23 -0
- package/src/palette-tokens.stylex.ts +519 -0
- package/src/palettes.css +3 -0
- package/src/palettes.ts +161 -0
- package/src/paper-theme.css +244 -0
- package/src/plain-publication.css +1241 -0
- package/src/plain-site.css +397 -0
- package/src/platforms.ts +151 -0
- package/src/portfolio.generated.json +1595 -0
- package/src/portfolio.generated.ts +1596 -0
- package/src/portfolio.ts +226 -0
- package/src/product-marketing-foundation.css +433 -0
- package/src/product-marketing-preset.css +261 -0
- package/src/product-marketing.css +2186 -0
- package/src/provider-marks.generated.ts +112 -0
- package/src/provider-marks.ts +143 -0
- package/src/react/animated-rail-stage.stylex.ts +15 -0
- package/src/react/animated-rail-stage.tsx +51 -0
- package/src/react/app-shell.stylex.ts +104 -0
- package/src/react/app-shell.tsx +121 -0
- package/src/react/article.tsx +602 -0
- package/src/react/aurora-dots-background.tsx +34 -0
- package/src/react/charts.stylex.ts +563 -0
- package/src/react/charts.tsx +610 -0
- package/src/react/chat.stylex.ts +34 -0
- package/src/react/chat.tsx +155 -0
- package/src/react/design-gallery.tsx +943 -0
- package/src/react/design-palette.stylex.ts +124 -0
- package/src/react/design-palette.tsx +196 -0
- package/src/react/design-theme-context.tsx +41 -0
- package/src/react/effects.stylex.ts +325 -0
- package/src/react/fader.stylex.ts +71 -0
- package/src/react/fader.tsx +151 -0
- package/src/react/foil-card-math.ts +100 -0
- package/src/react/foil-card-surface.stylex.ts +326 -0
- package/src/react/foil-card-surface.tsx +742 -0
- package/src/react/foil-mark.tsx +35 -0
- package/src/react/foil.stylex.ts +163 -0
- package/src/react/haptics.ts +228 -0
- package/src/react/hero-backdrop.tsx +22 -0
- package/src/react/index.ts +38 -0
- package/src/react/keyboard-shortcuts.ts +228 -0
- package/src/react/lantern-material-gallery.tsx +166 -0
- package/src/react/lantern-material.stylex.ts +39 -0
- package/src/react/launch-beats.tsx +73 -0
- package/src/react/navigation-rail.stylex.ts +130 -0
- package/src/react/navigation-rail.tsx +228 -0
- package/src/react/particle-halo.tsx +115 -0
- package/src/react/phaser-dots.tsx +501 -0
- package/src/react/platform-icons.tsx +94 -0
- package/src/react/platform-install.stylex.ts +385 -0
- package/src/react/platform-install.tsx +389 -0
- package/src/react/playback-transport.stylex.ts +14 -0
- package/src/react/playback-transport.tsx +112 -0
- package/src/react/procedural-backdrop.tsx +192 -0
- package/src/react/procedural-recipe.ts +323 -0
- package/src/react/product-marketing.stylex.ts +4469 -0
- package/src/react/product-marketing.tsx +1611 -0
- package/src/react/production-data-preview-notice.stylex.ts +40 -0
- package/src/react/production-data-preview-notice.tsx +34 -0
- package/src/react/provider-mark.stylex.ts +132 -0
- package/src/react/provider-mark.tsx +120 -0
- package/src/react/relative-time.tsx +117 -0
- package/src/react/route-state.stylex.ts +39 -0
- package/src/react/route-state.tsx +350 -0
- package/src/react/server.ts +11 -0
- package/src/react/social-kit-panel.tsx +103 -0
- package/src/react/sticky-offset.tsx +33 -0
- package/src/react/surfaces.stylex.ts +179 -0
- package/src/react/surfaces.tsx +330 -0
- package/src/react/syntax-code.tsx +33 -0
- package/src/react/theme-color-sync.ts +1 -0
- package/src/react/theme-resolution.ts +11 -0
- package/src/react/theme.stylex.ts +276 -0
- package/src/react/theme.tsx +402 -0
- package/src/reading.css +72 -0
- package/src/relative-time.ts +185 -0
- package/src/reset.css +2 -0
- package/src/site-shell.css +16 -0
- package/src/status-page-html.ts +63 -0
- package/src/status-page.css +276 -0
- package/src/status-page.ts +323 -0
- package/src/styles.css +21 -0
- package/src/syntax-code-lexers.ts +303 -0
- package/src/syntax-highlighting.css +78 -0
- package/src/syntax-highlighting.ts +477 -0
- package/src/syntax-tokens.ts +33 -0
- package/src/testing.ts +307 -0
- package/src/tokens.css +3 -0
- package/src/typography.css +86 -0
- package/vendor/evilcharts/LICENSE +21 -0
- package/vendor/evilcharts/UPSTREAM.md +14 -0
- package/vendor/platform-marks/LICENSE +30 -0
- package/vendor/platform-marks/UPSTREAM.md +15 -0
- package/vendor/platform-marks/apple.svg +1 -0
- package/vendor/platform-marks/linux.svg +1 -0
- package/vendor/provider-marks/CRUSH-LICENSE.md +134 -0
- package/vendor/provider-marks/LICENSE +21 -0
- package/vendor/provider-marks/UPSTREAM.md +27 -0
- package/vendor/provider-marks/aider.svg +3 -0
- package/vendor/provider-marks/crush-heartbit.svg +8 -0
- package/vendor/provider-marks/lobehub/alibabacloud-color.svg +1 -0
- package/vendor/provider-marks/lobehub/alibabacloud.svg +1 -0
- package/vendor/provider-marks/lobehub/amp-color.svg +1 -0
- package/vendor/provider-marks/lobehub/amp.svg +1 -0
- package/vendor/provider-marks/lobehub/anthropic.svg +1 -0
- package/vendor/provider-marks/lobehub/claude-color.svg +1 -0
- package/vendor/provider-marks/lobehub/claude.svg +1 -0
- package/vendor/provider-marks/lobehub/claudecode-color.svg +1 -0
- package/vendor/provider-marks/lobehub/claudecode.svg +1 -0
- package/vendor/provider-marks/lobehub/codex-color.svg +1 -0
- package/vendor/provider-marks/lobehub/codex.svg +1 -0
- package/vendor/provider-marks/lobehub/cursor.svg +1 -0
- package/vendor/provider-marks/lobehub/deepseek-color.svg +1 -0
- package/vendor/provider-marks/lobehub/deepseek.svg +1 -0
- package/vendor/provider-marks/lobehub/devin-color.svg +1 -0
- package/vendor/provider-marks/lobehub/devin.svg +1 -0
- package/vendor/provider-marks/lobehub/gemini-color.svg +1 -0
- package/vendor/provider-marks/lobehub/gemini.svg +1 -0
- package/vendor/provider-marks/lobehub/geminicli-color.svg +1 -0
- package/vendor/provider-marks/lobehub/geminicli.svg +1 -0
- package/vendor/provider-marks/lobehub/goose.svg +1 -0
- package/vendor/provider-marks/lobehub/meta-color.svg +1 -0
- package/vendor/provider-marks/lobehub/meta.svg +1 -0
- package/vendor/provider-marks/lobehub/mistral-color.svg +1 -0
- package/vendor/provider-marks/lobehub/mistral.svg +1 -0
- package/vendor/provider-marks/lobehub/moonshot.svg +1 -0
- package/vendor/provider-marks/lobehub/nvidia-color.svg +1 -0
- package/vendor/provider-marks/lobehub/nvidia.svg +1 -0
- package/vendor/provider-marks/lobehub/openai.svg +1 -0
- package/vendor/provider-marks/lobehub/opencode.svg +1 -0
- package/vendor/provider-marks/lobehub/perplexity-color.svg +1 -0
- package/vendor/provider-marks/lobehub/perplexity.svg +1 -0
- package/vendor/provider-marks/lobehub/qwen-color.svg +1 -0
- package/vendor/provider-marks/lobehub/qwen.svg +1 -0
- package/vendor/provider-marks/lobehub/xai.svg +1 -0
- package/vendor/provider-marks/lobehub/zai.svg +1 -0
package/src/launch.ts
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch beats, launch facts, and the social kit built from them.
|
|
3
|
+
*
|
|
4
|
+
* Data and checks only: no React, no DOM, and no portfolio import, so tests,
|
|
5
|
+
* film builds, and plain scripts can load it. `ARTICLE_COPY.md` describes the
|
|
6
|
+
* "Introducing a product" beats this module types, and `GENERATION_STYLE.md`
|
|
7
|
+
* (hraness/.github) gives the voice rules `assertLaunchKit` enforces.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Beat parts in reading order. `does` may repeat, once per surface or mode, each with its own visual. */
|
|
11
|
+
export const launchBeatParts = ["what", "does", "how", "who", "vision", "limits", "status"] as const;
|
|
12
|
+
export type LaunchBeatPart = (typeof launchBeatParts)[number];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parts that stay in the launch post and never reach a social post or the fact
|
|
16
|
+
* sheet. Caveats, limits, negative findings, and required disclaimers belong in
|
|
17
|
+
* the launch post, docs, and site; social posts carry claims only.
|
|
18
|
+
*/
|
|
19
|
+
export const launchPostOnlyParts: readonly LaunchBeatPart[] = Object.freeze(["limits"]);
|
|
20
|
+
|
|
21
|
+
export type LaunchVisual =
|
|
22
|
+
| Readonly<{ kind: "mockup"; id: string; state: Readonly<Record<string, string>> }>
|
|
23
|
+
| Readonly<{ kind: "clip"; scene: string }>
|
|
24
|
+
| Readonly<{ kind: "diagram"; src: string }>;
|
|
25
|
+
|
|
26
|
+
export type LaunchBeat = Readonly<{
|
|
27
|
+
/** Lowercase slug; the post renders it as the `#beat-<id>` anchor. */
|
|
28
|
+
id: string;
|
|
29
|
+
part: LaunchBeatPart;
|
|
30
|
+
/** Sentence case, no final period. May hold `{fact}` placeholders. */
|
|
31
|
+
headline: string;
|
|
32
|
+
/** One claim that stands alone as a social post. May hold `{fact}` placeholders. */
|
|
33
|
+
post: string;
|
|
34
|
+
visual: LaunchVisual;
|
|
35
|
+
/** Says "Illustration" when the visual is a mockup. May hold `{fact}` placeholders. */
|
|
36
|
+
alt: string;
|
|
37
|
+
/** Keys into the product's launch facts. Every placeholder must be listed here. */
|
|
38
|
+
facts?: readonly string[];
|
|
39
|
+
/** A companion post that goes deeper. */
|
|
40
|
+
detailHref?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The social wording of this beat, when `post` keeps a caveat that makes it
|
|
43
|
+
* accurate in the launch post. It must be accurate on its own. May hold
|
|
44
|
+
* `{fact}` placeholders.
|
|
45
|
+
*/
|
|
46
|
+
socialPost?: string;
|
|
47
|
+
/**
|
|
48
|
+
* `false` keeps this beat in the launch post only, for a beat that is a
|
|
49
|
+
* caveat, limit, or disclaimer. `limits` beats never reach social posts.
|
|
50
|
+
*/
|
|
51
|
+
social?: boolean;
|
|
52
|
+
}>;
|
|
53
|
+
|
|
54
|
+
/** One number, size, rate, or label, with where it came from. */
|
|
55
|
+
export type LaunchFact = Readonly<{ value: string; source: string }>;
|
|
56
|
+
/** The one typed place for every launch number. The post, kit, film captions, and store listing import it. */
|
|
57
|
+
export type LaunchFacts = Readonly<Record<string, LaunchFact>>;
|
|
58
|
+
|
|
59
|
+
/** Status labels from `STYLE.md`. */
|
|
60
|
+
export type LaunchStatus =
|
|
61
|
+
| "In development"
|
|
62
|
+
| "Preview"
|
|
63
|
+
| "Beta"
|
|
64
|
+
| `Latest release: v${number}.${number}.${number}`
|
|
65
|
+
| "Paused"
|
|
66
|
+
| "Retired";
|
|
67
|
+
|
|
68
|
+
/** The messaging fields a kit reads. The portfolio `PortfolioMessaging` record satisfies it. */
|
|
69
|
+
export type LaunchMessaging = Readonly<{
|
|
70
|
+
names: Readonly<{ name: string }>;
|
|
71
|
+
tagline: string;
|
|
72
|
+
meta: string;
|
|
73
|
+
}>;
|
|
74
|
+
|
|
75
|
+
export type LaunchRelease = Readonly<{
|
|
76
|
+
status: LaunchStatus;
|
|
77
|
+
/** Product Hunt topics. */
|
|
78
|
+
tags?: readonly string[];
|
|
79
|
+
}>;
|
|
80
|
+
|
|
81
|
+
export type SocialKit = Readonly<{
|
|
82
|
+
x: readonly string[];
|
|
83
|
+
bluesky: readonly string[];
|
|
84
|
+
threads: readonly string[];
|
|
85
|
+
linkedin: string;
|
|
86
|
+
productHunt: Readonly<{ tagline: string; description: string; tags: readonly string[] }>;
|
|
87
|
+
/** Facts for a person writing Show HN or a first comment. The kit never writes those. */
|
|
88
|
+
showHnFacts: readonly string[];
|
|
89
|
+
/** Where each kit field came from, keyed by field path (`x.0`, `productHunt.tagline`). */
|
|
90
|
+
sources: Readonly<Record<string, string>>;
|
|
91
|
+
}>;
|
|
92
|
+
|
|
93
|
+
export type LaunchKitOptions = Readonly<{
|
|
94
|
+
status: LaunchStatus;
|
|
95
|
+
/** True only when the release record shows a public install. */
|
|
96
|
+
publicInstall: boolean;
|
|
97
|
+
/** The messaging tagline; when given, the Product Hunt tagline must equal it. */
|
|
98
|
+
tagline?: string;
|
|
99
|
+
/** The canonical launch post URL; when given, the last post of every thread must end with it. */
|
|
100
|
+
canonicalUrl?: string;
|
|
101
|
+
/** Names that must not appear in social posts, such as competitors. */
|
|
102
|
+
forbiddenNames?: readonly string[];
|
|
103
|
+
}>;
|
|
104
|
+
|
|
105
|
+
export const LAUNCH_LIMITS = Object.freeze({
|
|
106
|
+
beatsMin: 7,
|
|
107
|
+
beatsMax: 10,
|
|
108
|
+
headline: 70,
|
|
109
|
+
post: 250,
|
|
110
|
+
alt: 125,
|
|
111
|
+
/** X counts most characters as 1, wide characters and emoji as 2, and every link as 23. */
|
|
112
|
+
x: 280,
|
|
113
|
+
xUrl: 23,
|
|
114
|
+
bluesky: 300,
|
|
115
|
+
threads: 500,
|
|
116
|
+
linkedin: 3000,
|
|
117
|
+
/** LinkedIn shows about this much before "see more". */
|
|
118
|
+
linkedinHook: 210,
|
|
119
|
+
productHuntTagline: 60,
|
|
120
|
+
productHuntDescription: 260,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** STYLE.md, GENERATION_STYLE.md, and ARTICLE_COPY.md: words readers take as filler or hype. */
|
|
124
|
+
export const LAUNCH_BANNED_WORDS: readonly string[] = Object.freeze([
|
|
125
|
+
"actually",
|
|
126
|
+
"amid",
|
|
127
|
+
"blazing",
|
|
128
|
+
"cutting-edge",
|
|
129
|
+
"delve",
|
|
130
|
+
"elevate",
|
|
131
|
+
"empower",
|
|
132
|
+
"game-changer",
|
|
133
|
+
"genuinely",
|
|
134
|
+
"harness",
|
|
135
|
+
"in today's world",
|
|
136
|
+
"journey",
|
|
137
|
+
"landscape",
|
|
138
|
+
"leverage",
|
|
139
|
+
"notable",
|
|
140
|
+
"pivotal",
|
|
141
|
+
"powerful",
|
|
142
|
+
"realm",
|
|
143
|
+
"revolutionary",
|
|
144
|
+
"revolutionize",
|
|
145
|
+
"robust",
|
|
146
|
+
"seamless",
|
|
147
|
+
"seamlessly",
|
|
148
|
+
"showcase",
|
|
149
|
+
"significant",
|
|
150
|
+
"supercharge",
|
|
151
|
+
"tapestry",
|
|
152
|
+
"testament",
|
|
153
|
+
"underscore",
|
|
154
|
+
"unlock",
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
/** Internal delivery vocabulary from AGENTS.md; never in public copy. */
|
|
158
|
+
export const LAUNCH_INTERNAL_WORDS: readonly string[] = Object.freeze([
|
|
159
|
+
"admission",
|
|
160
|
+
"admitted",
|
|
161
|
+
"attest",
|
|
162
|
+
"bounded",
|
|
163
|
+
"boundary",
|
|
164
|
+
"custody",
|
|
165
|
+
"gate",
|
|
166
|
+
"lane",
|
|
167
|
+
"lease",
|
|
168
|
+
"manifest",
|
|
169
|
+
"projection",
|
|
170
|
+
"qualification",
|
|
171
|
+
"quarantined",
|
|
172
|
+
"receipt",
|
|
173
|
+
"settlement",
|
|
174
|
+
"surface",
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
/** Openers GENERATION_STYLE.md rules out for the first post, and thread markers anywhere. */
|
|
178
|
+
const HOOK_PATTERNS: readonly RegExp[] = [
|
|
179
|
+
/\bhere'?s why\b/iu,
|
|
180
|
+
/\bhere'?s how\b/iu,
|
|
181
|
+
/\ba thread\b/iu,
|
|
182
|
+
/\bthread below\b/iu,
|
|
183
|
+
/\bwait (for|until)\b/iu,
|
|
184
|
+
/\byou won'?t believe\b/iu,
|
|
185
|
+
];
|
|
186
|
+
const THREAD_MARKER = /(^|\s)\d+\s*\/\s*\d*(\s|$)|\u{1F9F5}/u;
|
|
187
|
+
const INSTALL_PATTERN =
|
|
188
|
+
/\b(install|installs|installing|npm i|npx|bunx|pip|brew|download|get it (now|here)|try it (now|today)|sign up)\b/iu;
|
|
189
|
+
// Two linear scans instead of one `curl\s[^|]*\|` pattern, which backtracks
|
|
190
|
+
// polynomially on repeated "curl " input.
|
|
191
|
+
const CURL_WORD = /\bcurl\s/iu;
|
|
192
|
+
const PIPE_TO_SHELL = /\|\s*(?:ba|z)?sh\b/iu;
|
|
193
|
+
|
|
194
|
+
function asksToInstall(text: string): boolean {
|
|
195
|
+
if (INSTALL_PATTERN.test(text)) return true;
|
|
196
|
+
const curl = CURL_WORD.exec(text);
|
|
197
|
+
return curl !== null && PIPE_TO_SHELL.test(text.slice(curl.index));
|
|
198
|
+
}
|
|
199
|
+
const TRACKING_PARAM = /[?&](utm_[a-z]+|ref|ref_src|fbclid|gclid|mc_[a-z]+|s)=/iu;
|
|
200
|
+
// Explicit ranges, not `\p{…}` escapes, because Next.js compiles dependencies
|
|
201
|
+
// with a Babel build that cannot rewrite Unicode property escapes.
|
|
202
|
+
const EMOJI = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{2300}-\u{23FF}]/u;
|
|
203
|
+
const HASHTAG = /(^|\s)#[A-Za-z0-9_À-ɏͰ-ϿЀ-ӿ]+/u;
|
|
204
|
+
const PLACEHOLDER = /\{([a-z][a-zA-Z0-9_.-]*)\}/gu;
|
|
205
|
+
const BEAT_ID = /^[a-z0-9][a-z0-9-]*$/u;
|
|
206
|
+
const STATUS_LABEL = /^(In development|Preview|Beta|Latest release: v\d+\.\d+\.\d+|Paused|Retired)$/u;
|
|
207
|
+
const SCHEME_URL = /\bhttps?:\/\/[^\s]+/giu;
|
|
208
|
+
const BARE_DOMAIN = /\b[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)*\.(com|net|org|dev|app|io|ai|co|sh|so|xyz|me|site|page|tech)(\/[^\s]*)?\b/giu;
|
|
209
|
+
|
|
210
|
+
export class LaunchKitError extends Error {
|
|
211
|
+
readonly problems: readonly string[];
|
|
212
|
+
|
|
213
|
+
constructor(problems: readonly string[]) {
|
|
214
|
+
super(`Launch kit check failed:\n- ${problems.join("\n- ")}`);
|
|
215
|
+
this.name = "LaunchKitError";
|
|
216
|
+
this.problems = Object.freeze([...problems]);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function escapeRegExp(value: string): string {
|
|
221
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function wordPattern(words: readonly string[]): RegExp {
|
|
225
|
+
const alternatives = words.map((word) => escapeRegExp(word).replace(/'/gu, "['’]").replace(/-/gu, "[- ]?"));
|
|
226
|
+
// Plural and verb endings count: "surfaces", "leveraging", "unlocks".
|
|
227
|
+
return new RegExp(`(?<![A-Za-z0-9\\u00C0-\\u024F])(${alternatives.join("|")})(s|es|d|ed|ing)?(?![A-Za-z0-9\\u00C0-\\u024F])`, "iu");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const BANNED_PATTERN = wordPattern(LAUNCH_BANNED_WORDS);
|
|
231
|
+
const INTERNAL_PATTERN = wordPattern(LAUNCH_INTERNAL_WORDS);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Style problems in one piece of public launch text: banned and internal
|
|
235
|
+
* words, em dashes, exclamation marks, trailing questions, emoji, hashtags,
|
|
236
|
+
* and thread markers. Returns an empty list when the text is clean.
|
|
237
|
+
*/
|
|
238
|
+
export function launchCopyProblems(text: string, label = "text"): string[] {
|
|
239
|
+
const problems: string[] = [];
|
|
240
|
+
const banned = BANNED_PATTERN.exec(text);
|
|
241
|
+
if (banned !== null) problems.push(`${label} uses the banned word "${banned[0]}".`);
|
|
242
|
+
const internal = INTERNAL_PATTERN.exec(text);
|
|
243
|
+
if (internal !== null) problems.push(`${label} uses the internal word "${internal[0]}".`);
|
|
244
|
+
if (text.includes("—")) problems.push(`${label} contains an em dash.`);
|
|
245
|
+
if (text.includes("!")) problems.push(`${label} contains an exclamation mark.`);
|
|
246
|
+
if (/\?["'”’)]*$/u.test(text.trim())) problems.push(`${label} ends with a question mark.`);
|
|
247
|
+
if (EMOJI.test(text)) problems.push(`${label} contains an emoji.`);
|
|
248
|
+
if (HASHTAG.test(text)) problems.push(`${label} contains a hashtag.`);
|
|
249
|
+
if (THREAD_MARKER.test(text)) problems.push(`${label} contains a thread marker.`);
|
|
250
|
+
return problems;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function codePoints(text: string): number {
|
|
254
|
+
return Array.from(text.normalize("NFC")).length;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function graphemes(text: string): number {
|
|
258
|
+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
259
|
+
return Array.from(segmenter.segment(text.normalize("NFC"))).length;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function xWeight(codePoint: number): number {
|
|
263
|
+
const narrow =
|
|
264
|
+
(codePoint >= 0 && codePoint <= 4351) ||
|
|
265
|
+
(codePoint >= 8192 && codePoint <= 8205) ||
|
|
266
|
+
(codePoint >= 8208 && codePoint <= 8223) ||
|
|
267
|
+
(codePoint >= 8242 && codePoint <= 8247);
|
|
268
|
+
return narrow ? 1 : 2;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The weighted length X shows for a post: most Latin characters count 1,
|
|
273
|
+
* wide characters and emoji count 2, and each link counts 23 whatever its length.
|
|
274
|
+
*/
|
|
275
|
+
export function xPostLength(text: string): number {
|
|
276
|
+
let links = 0;
|
|
277
|
+
const withoutLinks = text
|
|
278
|
+
.normalize("NFC")
|
|
279
|
+
.replace(SCHEME_URL, () => {
|
|
280
|
+
links += 1;
|
|
281
|
+
return "";
|
|
282
|
+
})
|
|
283
|
+
.replace(BARE_DOMAIN, () => {
|
|
284
|
+
links += 1;
|
|
285
|
+
return "";
|
|
286
|
+
});
|
|
287
|
+
let length = links * LAUNCH_LIMITS.xUrl;
|
|
288
|
+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
289
|
+
for (const { segment } of segmenter.segment(withoutLinks)) {
|
|
290
|
+
// An emoji sequence counts as one wide character.
|
|
291
|
+
if (EMOJI.test(segment)) {
|
|
292
|
+
length += 2;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
for (const char of segment) length += xWeight(char.codePointAt(0) ?? 0);
|
|
296
|
+
}
|
|
297
|
+
return length;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Bluesky counts graphemes. */
|
|
301
|
+
export function blueskyPostLength(text: string): number {
|
|
302
|
+
return graphemes(text);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Threads and LinkedIn count characters. */
|
|
306
|
+
export function characterLength(text: string): number {
|
|
307
|
+
return codePoints(text);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function visualKey(visual: LaunchVisual): string {
|
|
311
|
+
switch (visual.kind) {
|
|
312
|
+
case "mockup":
|
|
313
|
+
return `mockup:${visual.id}:${JSON.stringify(Object.entries(visual.state).toSorted(([a], [b]) => a.localeCompare(b)))}`;
|
|
314
|
+
case "clip":
|
|
315
|
+
return `clip:${visual.scene}`;
|
|
316
|
+
case "diagram":
|
|
317
|
+
return `diagram:${visual.src}`;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** True when a beat reaches the social kit: not a `limits` beat and not marked `social: false`. */
|
|
322
|
+
export function isSocialBeat(beat: LaunchBeat): boolean {
|
|
323
|
+
return beat.social !== false && !launchPostOnlyParts.includes(beat.part);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The beats a social kit is cut from, in order. */
|
|
327
|
+
export function socialBeats(beats: readonly LaunchBeat[]): readonly LaunchBeat[] {
|
|
328
|
+
return beats.filter(isSocialBeat);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The text a beat contributes to social posts. */
|
|
332
|
+
export function socialPostText(beat: LaunchBeat): string {
|
|
333
|
+
return (beat.socialPost ?? beat.post).trim();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function beatShapeProblems(beats: readonly LaunchBeat[]): string[] {
|
|
337
|
+
const problems: string[] = [];
|
|
338
|
+
if (beats.length < LAUNCH_LIMITS.beatsMin || beats.length > LAUNCH_LIMITS.beatsMax) {
|
|
339
|
+
problems.push(`A launch post has ${LAUNCH_LIMITS.beatsMin} to ${LAUNCH_LIMITS.beatsMax} beats; this one has ${beats.length}.`);
|
|
340
|
+
}
|
|
341
|
+
const ids = new Set<string>();
|
|
342
|
+
const visuals = new Set<string>();
|
|
343
|
+
let previous = -1;
|
|
344
|
+
const counts = new Map<LaunchBeatPart, number>();
|
|
345
|
+
for (const beat of beats) {
|
|
346
|
+
const label = `Beat "${beat.id}"`;
|
|
347
|
+
if (!BEAT_ID.test(beat.id)) problems.push(`${label} needs a lowercase slug id.`);
|
|
348
|
+
if (ids.has(beat.id)) problems.push(`${label} is repeated.`);
|
|
349
|
+
ids.add(beat.id);
|
|
350
|
+
const order = launchBeatParts.indexOf(beat.part);
|
|
351
|
+
if (order < 0) problems.push(`${label} has an unknown part "${String(beat.part)}".`);
|
|
352
|
+
else if (order < previous) problems.push(`${label} (${beat.part}) is out of order; beats run ${launchBeatParts.join(", ")}.`);
|
|
353
|
+
previous = Math.max(previous, order);
|
|
354
|
+
counts.set(beat.part, (counts.get(beat.part) ?? 0) + 1);
|
|
355
|
+
const key = visualKey(beat.visual);
|
|
356
|
+
if (visuals.has(key)) problems.push(`${label} reuses another beat's visual; every beat needs its own.`);
|
|
357
|
+
visuals.add(key);
|
|
358
|
+
if (beat.social === false && (beat.part === "what" || beat.part === "status")) {
|
|
359
|
+
problems.push(`${label} is the ${beat.part === "what" ? "first" : "last"} social post; it cannot be marked social: false.`);
|
|
360
|
+
}
|
|
361
|
+
if (beat.socialPost !== undefined && !isSocialBeat(beat)) {
|
|
362
|
+
problems.push(`${label} never reaches a social post, so it cannot carry a socialPost.`);
|
|
363
|
+
}
|
|
364
|
+
if (beat.detailHref !== undefined && !/^(\/(?!\/)|https:\/\/)/u.test(beat.detailHref)) {
|
|
365
|
+
problems.push(`${label} detailHref must be a site path or an https URL.`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const part of launchBeatParts) {
|
|
369
|
+
const count = counts.get(part) ?? 0;
|
|
370
|
+
if (part === "does" ? count < 1 : count !== 1) {
|
|
371
|
+
problems.push(part === "does" ? 'A launch post needs at least one "does" beat.' : `A launch post needs exactly one "${part}" beat; it has ${count}.`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return problems;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function beatTextProblems(beat: LaunchBeat): string[] {
|
|
378
|
+
const label = `Beat "${beat.id}"`;
|
|
379
|
+
const problems: string[] = [];
|
|
380
|
+
const headline = beat.headline.trim();
|
|
381
|
+
if (headline.length === 0) problems.push(`${label} needs a headline.`);
|
|
382
|
+
if (characterLength(headline) > LAUNCH_LIMITS.headline) problems.push(`${label} headline is longer than ${LAUNCH_LIMITS.headline} characters.`);
|
|
383
|
+
if (/\.$/u.test(headline)) problems.push(`${label} headline ends with a period.`);
|
|
384
|
+
if (/^[a-zß-öø-ÿ]/u.test(headline)) problems.push(`${label} headline starts in lowercase.`);
|
|
385
|
+
if (/\s[A-ZÀ-Þ][a-zß-ÿ]+\s[A-ZÀ-Þ][a-zß-ÿ]+\s[A-ZÀ-Þ][a-zß-ÿ]+/u.test(headline)) problems.push(`${label} headline looks like title case; use sentence case.`);
|
|
386
|
+
if (characterLength(beat.post) > LAUNCH_LIMITS.post) problems.push(`${label} post is longer than ${LAUNCH_LIMITS.post} characters.`);
|
|
387
|
+
if (beat.post.trim().length === 0) problems.push(`${label} needs a post.`);
|
|
388
|
+
if (/\b(above|below|next post|previous post|as mentioned|see thread)\b/iu.test(beat.post)) {
|
|
389
|
+
problems.push(`${label} post refers to another post; each post stands alone.`);
|
|
390
|
+
}
|
|
391
|
+
if (characterLength(beat.alt) > LAUNCH_LIMITS.alt) problems.push(`${label} alt text is longer than ${LAUNCH_LIMITS.alt} characters.`);
|
|
392
|
+
if (beat.alt.trim().length === 0) problems.push(`${label} needs alt text.`);
|
|
393
|
+
if (beat.visual.kind === "mockup" && !/\billustration\b/iu.test(beat.alt)) {
|
|
394
|
+
problems.push(`${label} shows a mockup, so its alt text says "Illustration".`);
|
|
395
|
+
}
|
|
396
|
+
if (beat.socialPost !== undefined) {
|
|
397
|
+
if (beat.socialPost.trim().length === 0) problems.push(`${label} socialPost is empty; leave it out to use the post.`);
|
|
398
|
+
if (characterLength(beat.socialPost) > LAUNCH_LIMITS.post) problems.push(`${label} socialPost is longer than ${LAUNCH_LIMITS.post} characters.`);
|
|
399
|
+
if (/\b(above|below|next post|previous post|as mentioned|see thread)\b/iu.test(beat.socialPost)) {
|
|
400
|
+
problems.push(`${label} socialPost refers to another post; each post stands alone.`);
|
|
401
|
+
}
|
|
402
|
+
problems.push(...launchCopyProblems(beat.socialPost, `${label} socialPost`));
|
|
403
|
+
}
|
|
404
|
+
for (const [field, text] of [["headline", beat.headline], ["post", beat.post], ["alt", beat.alt]] as const) {
|
|
405
|
+
problems.push(...launchCopyProblems(text, `${label} ${field}`));
|
|
406
|
+
}
|
|
407
|
+
if (beat.part === "what") {
|
|
408
|
+
const first = socialPostText(beat);
|
|
409
|
+
if (first.includes("?")) problems.push(`${label} is the first post; it states what the product does and asks nothing.`);
|
|
410
|
+
for (const pattern of HOOK_PATTERNS) if (pattern.test(first)) problems.push(`${label} opens with a teaser; state what the product does.`);
|
|
411
|
+
}
|
|
412
|
+
return problems;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Check beat order, parts, ids, one visual per beat, lengths, and style.
|
|
417
|
+
* Throws a `LaunchKitError` that lists every problem.
|
|
418
|
+
*/
|
|
419
|
+
export function assertLaunchBeats(beats: readonly LaunchBeat[]): void {
|
|
420
|
+
const problems = [...beatShapeProblems(beats), ...beats.flatMap(beatTextProblems)];
|
|
421
|
+
if (problems.length > 0) throw new LaunchKitError(problems);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Every `{key}` placeholder in a piece of beat text. */
|
|
425
|
+
export function launchPlaceholders(text: string): string[] {
|
|
426
|
+
return [...text.matchAll(PLACEHOLDER)].map((match) => match[1] ?? "");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Fill `{key}` placeholders from the launch facts. A beat may use only the
|
|
431
|
+
* keys it lists in `facts`, and its authored text may contain no digits, so
|
|
432
|
+
* every number in the post comes from the facts module. Names containing
|
|
433
|
+
* digits can be allowed with `allowNumerals`.
|
|
434
|
+
*/
|
|
435
|
+
export function resolveLaunchBeats(
|
|
436
|
+
beats: readonly LaunchBeat[],
|
|
437
|
+
facts: LaunchFacts,
|
|
438
|
+
options: Readonly<{ allowNumerals?: readonly string[] }> = {},
|
|
439
|
+
): readonly LaunchBeat[] {
|
|
440
|
+
const problems: string[] = [];
|
|
441
|
+
const allowed = options.allowNumerals ?? [];
|
|
442
|
+
for (const [key, fact] of Object.entries(facts)) {
|
|
443
|
+
if (fact.value.trim().length === 0) problems.push(`Fact "${key}" has no value.`);
|
|
444
|
+
if (fact.source.trim().length === 0) problems.push(`Fact "${key}" has no source.`);
|
|
445
|
+
}
|
|
446
|
+
const resolved = beats.map((beat) => {
|
|
447
|
+
const listed = new Set(beat.facts ?? []);
|
|
448
|
+
for (const key of listed) if (!Object.hasOwn(facts, key)) problems.push(`Beat "${beat.id}" lists the unknown fact "${key}".`);
|
|
449
|
+
const fill = (text: string, field: string): string => {
|
|
450
|
+
let authored = text.replace(PLACEHOLDER, " ");
|
|
451
|
+
for (const name of allowed) authored = authored.split(name).join(" ");
|
|
452
|
+
authored = authored.replace(SCHEME_URL, " ");
|
|
453
|
+
if (/\d/u.test(authored)) problems.push(`Beat "${beat.id}" ${field} types a number; put it in the facts module and use a {placeholder}.`);
|
|
454
|
+
return text.replace(PLACEHOLDER, (match, key: string) => {
|
|
455
|
+
if (!listed.has(key)) {
|
|
456
|
+
problems.push(`Beat "${beat.id}" ${field} uses {${key}} without listing it in facts.`);
|
|
457
|
+
return match;
|
|
458
|
+
}
|
|
459
|
+
return facts[key]?.value ?? match;
|
|
460
|
+
});
|
|
461
|
+
};
|
|
462
|
+
return Object.freeze({
|
|
463
|
+
...beat,
|
|
464
|
+
headline: fill(beat.headline, "headline"),
|
|
465
|
+
post: fill(beat.post, "post"),
|
|
466
|
+
alt: fill(beat.alt, "alt"),
|
|
467
|
+
...(beat.socialPost === undefined ? {} : { socialPost: fill(beat.socialPost, "socialPost") }),
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
if (problems.length > 0) throw new LaunchKitError(problems);
|
|
471
|
+
return Object.freeze(resolved);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function assertCanonicalUrl(url: string): void {
|
|
475
|
+
// Parsed as a string so this module needs no DOM or Node URL global.
|
|
476
|
+
const parsed = /^([a-z][a-z0-9+.-]*):\/\/([^/?#\s]+)(\/[^?#\s]*)?(\?[^#\s]*)?(#\S*)?$/iu.exec(url);
|
|
477
|
+
if (parsed === null) throw new LaunchKitError([`The canonical URL "${url}" is not a URL.`]);
|
|
478
|
+
const [, scheme = "", , , search = "", hash = ""] = parsed;
|
|
479
|
+
const problems: string[] = [];
|
|
480
|
+
if (scheme.toLowerCase() !== "https") problems.push("The canonical URL must use https.");
|
|
481
|
+
if (TRACKING_PARAM.test(search)) problems.push("The canonical URL must not carry tracking parameters.");
|
|
482
|
+
if (hash !== "") problems.push("The canonical URL must point at the post, not an anchor.");
|
|
483
|
+
if (problems.length > 0) throw new LaunchKitError(problems);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Build the social kit from resolved beats: one post per social beat on X,
|
|
488
|
+
* Bluesky, and Threads, one LinkedIn post, Product Hunt fields from the
|
|
489
|
+
* messaging record, and a fact sheet. `limits` beats and beats marked
|
|
490
|
+
* `social: false` stay in the launch post; a beat's `socialPost` replaces its
|
|
491
|
+
* `post`. The last post of each thread ends with the canonical URL. Run
|
|
492
|
+
* `resolveLaunchBeats` first so every number comes from facts.
|
|
493
|
+
*/
|
|
494
|
+
export function buildSocialKit(
|
|
495
|
+
beats: readonly LaunchBeat[],
|
|
496
|
+
messaging: LaunchMessaging,
|
|
497
|
+
release: LaunchRelease,
|
|
498
|
+
canonicalUrl: string,
|
|
499
|
+
): SocialKit {
|
|
500
|
+
assertLaunchBeats(beats);
|
|
501
|
+
assertCanonicalUrl(canonicalUrl);
|
|
502
|
+
const unresolved = beats.filter((beat) => launchPlaceholders(`${beat.headline} ${beat.post} ${beat.alt} ${beat.socialPost ?? ""}`).length > 0);
|
|
503
|
+
if (unresolved.length > 0) {
|
|
504
|
+
throw new LaunchKitError(unresolved.map((beat) => `Beat "${beat.id}" still has placeholders; run resolveLaunchBeats first.`));
|
|
505
|
+
}
|
|
506
|
+
const cut = socialBeats(beats);
|
|
507
|
+
const posts = cut.map((beat, index) => (index === cut.length - 1 ? `${socialPostText(beat)}\n\n${canonicalUrl}` : socialPostText(beat)));
|
|
508
|
+
const sources: Record<string, string> = {
|
|
509
|
+
"productHunt.tagline": "messaging.tagline",
|
|
510
|
+
"productHunt.description": "messaging.meta",
|
|
511
|
+
"productHunt.tags": "release.tags",
|
|
512
|
+
status: "release.status",
|
|
513
|
+
url: "canonicalUrl",
|
|
514
|
+
};
|
|
515
|
+
cut.forEach((beat, index) => {
|
|
516
|
+
for (const channel of ["x", "bluesky", "threads"] as const) sources[`${channel}.${index}`] = `beat:${beat.id}`;
|
|
517
|
+
});
|
|
518
|
+
const linkedin = posts.join("\n\n");
|
|
519
|
+
sources.linkedin = cut.map((beat) => `beat:${beat.id}`).join(",");
|
|
520
|
+
const showHnFacts = [
|
|
521
|
+
messaging.tagline,
|
|
522
|
+
...cut.filter((beat) => beat.part !== "vision").map(socialPostText),
|
|
523
|
+
`${release.status}. ${canonicalUrl}`,
|
|
524
|
+
];
|
|
525
|
+
return Object.freeze({
|
|
526
|
+
x: Object.freeze([...posts]),
|
|
527
|
+
bluesky: Object.freeze([...posts]),
|
|
528
|
+
threads: Object.freeze([...posts]),
|
|
529
|
+
linkedin,
|
|
530
|
+
productHunt: Object.freeze({
|
|
531
|
+
tagline: messaging.tagline,
|
|
532
|
+
description: messaging.meta,
|
|
533
|
+
tags: Object.freeze([...(release.tags ?? [])]),
|
|
534
|
+
}),
|
|
535
|
+
showHnFacts: Object.freeze(showHnFacts),
|
|
536
|
+
sources: Object.freeze(sources),
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Check a launch kit before it ships: channel limits, style rules, no install
|
|
542
|
+
* call to action unless the release has a public install, the status label in
|
|
543
|
+
* the last post, the tagline and URL from their records, and no text from a
|
|
544
|
+
* `limits` beat or a beat marked `social: false`.
|
|
545
|
+
* Throws a `LaunchKitError` that lists every problem.
|
|
546
|
+
*/
|
|
547
|
+
export function assertLaunchKit(beats: readonly LaunchBeat[], kit: SocialKit, options: LaunchKitOptions): void {
|
|
548
|
+
const problems: string[] = [];
|
|
549
|
+
try {
|
|
550
|
+
assertLaunchBeats(beats);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (error instanceof LaunchKitError) problems.push(...error.problems);
|
|
553
|
+
else throw error;
|
|
554
|
+
}
|
|
555
|
+
if (!STATUS_LABEL.test(options.status)) problems.push(`"${options.status}" is not a STYLE.md status label.`);
|
|
556
|
+
if (options.canonicalUrl !== undefined) {
|
|
557
|
+
try {
|
|
558
|
+
assertCanonicalUrl(options.canonicalUrl);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
if (error instanceof LaunchKitError) problems.push(...error.problems);
|
|
561
|
+
else throw error;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const cut = socialBeats(beats);
|
|
565
|
+
const postOnly = beats.filter((beat) => !isSocialBeat(beat)).map((beat) => [beat.id, beat.post.trim()] as const).filter(([, text]) => text.length > 0);
|
|
566
|
+
const forbidden = (options.forbiddenNames ?? []).filter((name) => name.trim().length > 0);
|
|
567
|
+
const forbiddenPattern = forbidden.length === 0 ? null : wordPattern(forbidden);
|
|
568
|
+
const checkText = (text: string, label: string): void => {
|
|
569
|
+
problems.push(...launchCopyProblems(text, label));
|
|
570
|
+
if (forbiddenPattern !== null) {
|
|
571
|
+
const hit = forbiddenPattern.exec(text);
|
|
572
|
+
if (hit !== null) problems.push(`${label} names "${hit[0]}"; comparisons belong on comparison pages.`);
|
|
573
|
+
}
|
|
574
|
+
if (!options.publicInstall && asksToInstall(text)) {
|
|
575
|
+
problems.push(`${label} asks readers to install, but the release has no public install.`);
|
|
576
|
+
}
|
|
577
|
+
for (const match of text.matchAll(SCHEME_URL)) {
|
|
578
|
+
if (TRACKING_PARAM.test(match[0])) problems.push(`${label} links with tracking parameters.`);
|
|
579
|
+
}
|
|
580
|
+
for (const [id, post] of postOnly) {
|
|
581
|
+
if (text.includes(post)) problems.push(`${label} carries beat "${id}", which stays in the launch post; social posts carry no caveats or limits.`);
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
const threads = [
|
|
585
|
+
["x", kit.x, xPostLength, LAUNCH_LIMITS.x],
|
|
586
|
+
["bluesky", kit.bluesky, blueskyPostLength, LAUNCH_LIMITS.bluesky],
|
|
587
|
+
["threads", kit.threads, characterLength, LAUNCH_LIMITS.threads],
|
|
588
|
+
] as const;
|
|
589
|
+
for (const [channel, posts, measure, limit] of threads) {
|
|
590
|
+
if (posts.length === 0) problems.push(`The ${channel} thread is empty.`);
|
|
591
|
+
if (posts.length > cut.length) problems.push(`The ${channel} thread has more posts than social beats.`);
|
|
592
|
+
posts.forEach((post, index) => {
|
|
593
|
+
const label = `${channel} post ${index + 1}`;
|
|
594
|
+
const length = measure(post);
|
|
595
|
+
if (length > limit) problems.push(`${label} is ${length} long; the limit is ${limit}.`);
|
|
596
|
+
checkText(post, label);
|
|
597
|
+
});
|
|
598
|
+
const last = posts.at(-1) ?? "";
|
|
599
|
+
if (!last.includes(options.status)) problems.push(`The last ${channel} post must state the status "${options.status}".`);
|
|
600
|
+
if (options.canonicalUrl !== undefined && !last.trimEnd().endsWith(options.canonicalUrl)) {
|
|
601
|
+
problems.push(`The last ${channel} post must end with the canonical URL.`);
|
|
602
|
+
}
|
|
603
|
+
posts.slice(0, -1).forEach((post, index) => {
|
|
604
|
+
if (/\bhttps?:\/\//u.test(post)) problems.push(`${channel} post ${index + 1} carries a link; only the last post links.`);
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
if (characterLength(kit.linkedin) > LAUNCH_LIMITS.linkedin) problems.push(`The LinkedIn post is longer than ${LAUNCH_LIMITS.linkedin} characters.`);
|
|
608
|
+
const hook = kit.linkedin.split(/\n\s*\n/u)[0] ?? "";
|
|
609
|
+
if (characterLength(hook) > LAUNCH_LIMITS.linkedinHook) problems.push(`The LinkedIn opening paragraph is longer than ${LAUNCH_LIMITS.linkedinHook} characters.`);
|
|
610
|
+
if (!kit.linkedin.includes(options.status)) problems.push(`The LinkedIn post must state the status "${options.status}".`);
|
|
611
|
+
checkText(kit.linkedin, "LinkedIn post");
|
|
612
|
+
const { productHunt } = kit;
|
|
613
|
+
if (characterLength(productHunt.tagline) > LAUNCH_LIMITS.productHuntTagline) problems.push(`The Product Hunt tagline is longer than ${LAUNCH_LIMITS.productHuntTagline} characters.`);
|
|
614
|
+
if (characterLength(productHunt.description) > LAUNCH_LIMITS.productHuntDescription) {
|
|
615
|
+
problems.push(`The Product Hunt description is longer than ${LAUNCH_LIMITS.productHuntDescription} characters.`);
|
|
616
|
+
}
|
|
617
|
+
if (options.tagline !== undefined && productHunt.tagline !== options.tagline) problems.push("The Product Hunt tagline must be the messaging tagline.");
|
|
618
|
+
if (kit.sources["productHunt.tagline"] !== "messaging.tagline") problems.push("The Product Hunt tagline must come from the messaging record.");
|
|
619
|
+
if (kit.sources.status !== "release.status") problems.push("The status must come from the release record.");
|
|
620
|
+
checkText(productHunt.tagline, "Product Hunt tagline");
|
|
621
|
+
checkText(productHunt.description, "Product Hunt description");
|
|
622
|
+
kit.showHnFacts.forEach((fact, index) => checkText(fact, `fact ${index + 1}`));
|
|
623
|
+
if (problems.length > 0) throw new LaunchKitError([...new Set(problems)]);
|
|
624
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Static marketing field
|
|
2
|
+
|
|
3
|
+
Original Hraness SVG textures, authored 2026-09-11, covered by the package MIT
|
|
4
|
+
license. The checked generator is `scripts/marketing-textures.ts`. These are
|
|
5
|
+
background assets only; never apply them to a logo or over meaningful content.
|
|
6
|
+
|
|
7
|
+
- `grain.svg`: deterministic black/white one-unit stipple, seed 4652026,
|
|
8
|
+
viewBox 128 × 128, group opacity .03.
|
|
9
|
+
- `cells.svg`: 64 individually shaded panes in a 768 × 768 viewBox. Each
|
|
10
|
+
96-unit square has a seeded light direction and low-opacity gradient.
|
|
11
|
+
Panes have no stroke; adjacent faces suggest their edges. Refined 2026-09-13.
|
|
12
|
+
Refined 2026-09-23 to reduce light-mode tile contrast: black stop opacity is
|
|
13
|
+
.008–.034, while white stops, seeded directions and face geometry are unchanged.
|
|
14
|
+
- Grain SHA-256: `b40c33a0e382c8e9d0518b4720321b5c262a929c28d40a190a902d07acd06553`
|
|
15
|
+
- Cells SHA-256: `2391e9b3ee964e1178fedc55c766d12ac43bfeda92cfa44aab16c64a15f9d712`
|
|
16
|
+
|
|
17
|
+
There is no animation, runtime canvas, or image overlay. The original seeded
|
|
18
|
+
face gradients express depth without a shader or visible grid stroke.
|