@popcraft/kit 0.30.0 → 0.32.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 (201) hide show
  1. package/dist/chunks/{agent-CGSIEC2R.js → agent-LNZDZLDR.js} +1 -1
  2. package/dist/chunks/{build-AUXXY3JX.js → build-AKMRSWRB.js} +2 -2
  3. package/dist/chunks/{builtin-Q5UN4DWS.js → builtin-KY56DU2Y.js} +3 -3
  4. package/dist/chunks/{check-template-2EKU4JL7.js → check-template-OYCBJNDF.js} +2 -2
  5. package/dist/chunks/{chunk-YT6TGRM4.js → chunk-2PEG56PC.js} +40 -26
  6. package/dist/chunks/{chunk-KOKO3HTP.js → chunk-3NI5OGFT.js} +1 -1
  7. package/dist/chunks/{chunk-TQPKOPCW.js → chunk-3ZO43HDI.js} +2 -2
  8. package/dist/chunks/{chunk-MJ6VIF3D.js → chunk-4X5G4QPZ.js} +1 -1
  9. package/dist/chunks/{chunk-3MAEAHID.js → chunk-6CRT7IDI.js} +2 -2
  10. package/dist/chunks/chunk-6S72I27Z.js +194 -0
  11. package/dist/chunks/chunk-7DKGI65Y.js +305 -0
  12. package/dist/chunks/chunk-APBRV4W6.js +71 -0
  13. package/dist/chunks/{chunk-AN3H4JWT.js → chunk-APVJKUJP.js} +4 -4
  14. package/dist/chunks/chunk-CGNNKKW7.js +70 -0
  15. package/dist/chunks/{chunk-IPEOO7PN.js → chunk-CKBPEGTH.js} +2 -2
  16. package/dist/chunks/chunk-COJM27N2.js +73 -0
  17. package/dist/chunks/chunk-CVB5F7FJ.js +92 -0
  18. package/dist/chunks/{chunk-DVX7UAKA.js → chunk-DQ7AF2R5.js} +1 -1
  19. package/dist/chunks/{chunk-UHQTNURS.js → chunk-DVD56API.js} +5 -5
  20. package/dist/chunks/{chunk-UM6QUOOH.js → chunk-DZIPU2Q4.js} +3 -3
  21. package/dist/chunks/{chunk-7QYQVENQ.js → chunk-FC2FGSU2.js} +5 -5
  22. package/dist/chunks/{chunk-T2GF6JE3.js → chunk-GAZ57RX2.js} +1 -1
  23. package/dist/chunks/{chunk-R3D5OMSG.js → chunk-GF6N47NL.js} +1 -1
  24. package/dist/chunks/{chunk-3DNAX4HN.js → chunk-HBPURYPZ.js} +2 -2
  25. package/dist/chunks/{chunk-7J4MGACN.js → chunk-HN2A65AT.js} +10 -10
  26. package/dist/chunks/{chunk-HST5H23G.js → chunk-JRQ4ZPRK.js} +1 -1
  27. package/dist/chunks/chunk-LP7LPUQJ.js +81 -0
  28. package/dist/chunks/chunk-M2LWLS56.js +65 -0
  29. package/dist/chunks/{chunk-GT5X67P7.js → chunk-MFPMMKHP.js} +7 -7
  30. package/dist/chunks/{chunk-LE33OZ4V.js → chunk-N2RBQBUE.js} +43 -43
  31. package/dist/chunks/{chunk-DUYDXAR5.js → chunk-O3W7HYS7.js} +1 -1
  32. package/dist/chunks/chunk-OXI222BA.js +54 -0
  33. package/dist/chunks/chunk-P23KECK3.js +57 -0
  34. package/dist/chunks/{chunk-LNQN4PGS.js → chunk-P5M5QCAJ.js} +24 -24
  35. package/dist/chunks/{chunk-X3HT5IHG.js → chunk-POZOGQG7.js} +11 -11
  36. package/dist/chunks/{chunk-K7OOOI7B.js → chunk-QGE2XW66.js} +4 -4
  37. package/dist/chunks/{chunk-STG7FEIQ.js → chunk-S4Y3VMQH.js} +3 -3
  38. package/dist/chunks/chunk-UBT4DZYW.js +304 -0
  39. package/dist/chunks/chunk-UJTTWR5P.js +71 -0
  40. package/dist/chunks/{chunk-652VC4P7.js → chunk-UNAAYJKD.js} +39 -39
  41. package/dist/chunks/{chunk-AXKXWL35.js → chunk-WKKHCEXN.js} +5 -5
  42. package/dist/chunks/chunk-X22CGIKE.js +75 -0
  43. package/dist/chunks/chunk-X4UI5JKC.js +74 -0
  44. package/dist/chunks/chunk-XQJ2VVC6.js +69 -0
  45. package/dist/chunks/{chunk-CQ6QGYKJ.js → chunk-YI7HCLNY.js} +1 -1
  46. package/dist/chunks/{chunk-CVWIMZTL.js → chunk-YOYWFBRJ.js} +53 -53
  47. package/dist/chunks/chunk-ZBFR7DBR.js +70 -0
  48. package/dist/chunks/{chunk-KO24NRJ5.js → chunk-ZW7KTUDU.js} +2 -2
  49. package/dist/chunks/chunk-ZY3DR3P6.js +63 -0
  50. package/dist/chunks/chunk-ZYNF7B6N.js +70 -0
  51. package/dist/chunks/{client-NBSGVJPN.js → client-ORABD53R.js} +2 -2
  52. package/dist/chunks/{client-5YPZUV5L.js → client-YF3PYAFZ.js} +2 -2
  53. package/dist/chunks/{client-3FYDPRVC.js → client-YOSPARZX.js} +1 -1
  54. package/dist/chunks/{client-actions-J2SQWW4O.js → client-actions-LYJHQ4IE.js} +4 -4
  55. package/dist/chunks/{dangerous-3EDPWONV.js → dangerous-VCZB63L6.js} +3 -3
  56. package/dist/chunks/{editor-seams-NG3BESM6.js → editor-seams-4PF6BD4R.js} +9 -9
  57. package/dist/chunks/{editor-store-NDV4IXDK.js → editor-store-UAEROYSB.js} +1 -1
  58. package/dist/chunks/{export-watermark-SNBNUS3L.js → export-watermark-2N7ALJXN.js} +5 -5
  59. package/dist/chunks/{eyedropper-L5DVFZV5.js → eyedropper-3P75IZGM.js} +1 -1
  60. package/dist/chunks/{generate-media-ASNNFDML.js → generate-media-DYXU4BZL.js} +2 -2
  61. package/dist/chunks/{host-5DYGKFJ7.js → host-J4QG5WB7.js} +6 -6
  62. package/dist/chunks/{integrations-2BG5XVX3.js → integrations-KGJ2HE6V.js} +1 -1
  63. package/dist/chunks/{kit-modules-NMONCEP4.js → kit-modules-4HQU5EI2.js} +1 -1
  64. package/dist/chunks/{libraries-QV4QPEYK.js → libraries-NIRTR2IA.js} +5 -5
  65. package/dist/chunks/{live-EI26E2RB.js → live-XZKNAJ2I.js} +3 -3
  66. package/dist/chunks/{media-tool-OVVKBD3N.js → media-tool-YFIYMYI5.js} +1 -1
  67. package/dist/chunks/{new-file-DX5L4NF6.js → new-file-RNWHN4S6.js} +3 -3
  68. package/dist/chunks/{plugin-P6FYIVKR.js → plugin-AN5GLVH5.js} +2 -2
  69. package/dist/chunks/{plugin-import-V2ORN2OW.js → plugin-import-J5ZNYSIY.js} +13 -13
  70. package/dist/chunks/{presence-YNIJGI73.js → presence-YNSLRFAM.js} +2 -2
  71. package/dist/chunks/{project-UK457FEX.js → project-HSN7DOPK.js} +1 -1
  72. package/dist/chunks/{publish-site-E4A557HC.js → publish-site-T2C4IDD2.js} +6 -6
  73. package/dist/chunks/{publish-store-U5SDQCU4.js → publish-store-I2FOGDDF.js} +4 -4
  74. package/dist/chunks/{raster-actions-4E7H3G62.js → raster-actions-IVIGDYQU.js} +1 -1
  75. package/dist/chunks/{runner-DHWGPGPD.js → runner-IOXTGHBT.js} +16 -16
  76. package/dist/chunks/{store-CUL3XS5L.js → store-ZVOK3773.js} +5 -5
  77. package/dist/chunks/{sync-U23VGFOY.js → sync-XT2ZCTVE.js} +5 -5
  78. package/dist/chunks/{tabs-K6MESOUX.js → tabs-RIIW7KNI.js} +8 -8
  79. package/dist/chunks/{team-7APVVP43.js → team-KM3FZ6AU.js} +1 -1
  80. package/dist/chunks/{team-client-XTH5DXVW.js → team-client-2EXJKRDB.js} +1 -1
  81. package/dist/chunks/{tools-PE6DEYSG.js → tools-5ISIPPXP.js} +2 -2
  82. package/dist/chunks/{ui-commands-WQJFLB5K.js → ui-commands-GYCIF7UQ.js} +11 -11
  83. package/dist/chunks/{versions-Y4F3IO37.js → versions-JCQTXXUB.js} +5 -5
  84. package/dist/fonts/chars.txt +1 -1
  85. package/dist/index.js +4 -4
  86. package/dist/seed/files.json +1 -1
  87. package/dist/seed/popcraft.comic-nested.json.gz +0 -0
  88. package/dist/seed/popcraft.comic-pinup.json.gz +0 -0
  89. package/dist/templates/count.js +1 -1
  90. package/dist/templates/index.js +226 -210
  91. package/dist/templates/index.json +663 -0
  92. package/dist/templates/motion/landing-hero/index.js +4 -4
  93. package/dist/templates/motion/launch-trailer/document.js +226 -210
  94. package/dist/templates/motion/launch-trailer/index.js +4 -4
  95. package/dist/templates/motion/launch-trailer/scenes.js +3 -3
  96. package/dist/templates/motion/template-wall.js +2 -2
  97. package/dist/templates/print/comic-hero-cascade.js +100 -0
  98. package/dist/templates/print/comic-hero-chase.js +100 -0
  99. package/dist/templates/print/comic-hero-cover-modern.js +100 -0
  100. package/dist/templates/print/comic-hero-grid-break.js +100 -0
  101. package/dist/templates/print/comic-hero-interrogation.js +100 -0
  102. package/dist/templates/print/comic-hero-issue.js +109 -0
  103. package/dist/templates/print/comic-hero-manga-action.js +100 -0
  104. package/dist/templates/print/comic-hero-nested.js +100 -0
  105. package/dist/templates/print/comic-hero-overlap.js +100 -0
  106. package/dist/templates/print/comic-hero-parallel.js +100 -0
  107. package/dist/templates/print/comic-hero-pinup.js +100 -0
  108. package/dist/templates/print/comic-hero-widescreen.js +100 -0
  109. package/dist/templates/print/comic-modern-issue.js +4 -4
  110. package/dist/templates/saas/email-decks.js +2 -2
  111. package/dist/templates/saas/index.js +62 -62
  112. package/dist/templates/saas/launch-kit.js +5 -5
  113. package/dist/templates/saas/launch-video.js +2 -2
  114. package/dist/templates/saas/stores.js +4 -4
  115. package/dist/templates/slideshow/index.js +3 -3
  116. package/dist/templates/themes/bauhaus.js +2 -2
  117. package/dist/templates/themes/black-friday-coupons.js +2 -2
  118. package/dist/templates/themes/blueprint.js +2 -2
  119. package/dist/templates/themes/claymation.js +2 -2
  120. package/dist/templates/themes/comic-book.js +5 -5
  121. package/dist/{chunks/chunk-ATEECJLR.js → templates/themes/comic-hero-art.js} +11 -9
  122. package/dist/templates/themes/comic-hero-page.js +67 -0
  123. package/dist/templates/themes/comic-hero.js +97 -1
  124. package/dist/{chunks/chunk-ODXZPZKI.js → templates/themes/comic-indie-art.js} +7 -9
  125. package/dist/templates/themes/comic-indie-page.js +47 -0
  126. package/dist/templates/themes/comic-indie.js +85 -1
  127. package/dist/templates/themes/cyberpunk.js +2 -2
  128. package/dist/templates/themes/everyday.js +2 -2
  129. package/dist/templates/themes/festival-presale-family-a.js +2 -2
  130. package/dist/templates/themes/festival-presale-family-b.js +2 -2
  131. package/dist/templates/themes/holidays.js +11 -11
  132. package/dist/templates/themes/index.js +127 -111
  133. package/dist/templates/themes/lunar-new-year.js +2 -2
  134. package/dist/templates/themes/manuscript.js +2 -2
  135. package/dist/templates/themes/memories.js +5 -5
  136. package/dist/templates/themes/noir.js +2 -2
  137. package/dist/templates/themes/printshop.js +14 -14
  138. package/dist/templates/themes/psychedelic.js +2 -2
  139. package/dist/templates/themes/space-opera.js +2 -2
  140. package/dist/templates/themes/studio.js +2 -2
  141. package/dist/templates/themes/tour-tickets.js +2 -2
  142. package/package.json +1 -1
  143. package/src/templates/print/comic-hero-cascade.ts +42 -0
  144. package/src/templates/print/comic-hero-chase.ts +42 -0
  145. package/src/templates/print/comic-hero-cover-modern.ts +34 -0
  146. package/src/templates/print/comic-hero-grid-break.ts +39 -0
  147. package/src/templates/print/comic-hero-interrogation.ts +41 -0
  148. package/src/templates/print/comic-hero-issue.ts +44 -0
  149. package/src/templates/print/comic-hero-manga-action.ts +38 -0
  150. package/src/templates/print/comic-hero-nested.ts +39 -0
  151. package/src/templates/print/comic-hero-overlap.ts +40 -0
  152. package/src/templates/print/comic-hero-parallel.ts +40 -0
  153. package/src/templates/print/comic-hero-pinup.ts +29 -0
  154. package/src/templates/print/comic-hero-widescreen.ts +35 -0
  155. package/src/templates/themes/comic-hero-art.ts +66 -0
  156. package/src/templates/themes/comic-hero-page.ts +326 -0
  157. package/src/templates/themes/comic-hero.ts +15 -2
  158. package/src/templates/themes/comic-indie-art.ts +73 -0
  159. package/src/templates/themes/comic-indie-page.ts +182 -0
  160. package/src/templates/themes/comic-indie.ts +280 -2
  161. package/types/templates/src/print/comic-hero-cascade.d.ts +5 -0
  162. package/types/templates/src/print/comic-hero-chase.d.ts +5 -0
  163. package/types/templates/src/print/comic-hero-cover-modern.d.ts +5 -0
  164. package/types/templates/src/print/comic-hero-grid-break.d.ts +5 -0
  165. package/types/templates/src/print/comic-hero-interrogation.d.ts +5 -0
  166. package/types/templates/src/print/comic-hero-issue.d.ts +2 -0
  167. package/types/templates/src/print/comic-hero-manga-action.d.ts +5 -0
  168. package/types/templates/src/print/comic-hero-nested.d.ts +5 -0
  169. package/types/templates/src/print/comic-hero-overlap.d.ts +5 -0
  170. package/types/templates/src/print/comic-hero-parallel.d.ts +5 -0
  171. package/types/templates/src/print/comic-hero-pinup.d.ts +5 -0
  172. package/types/templates/src/print/comic-hero-widescreen.d.ts +5 -0
  173. package/types/templates/src/themes/comic-hero-art.d.ts +318 -0
  174. package/types/templates/src/themes/comic-hero-page.d.ts +177 -0
  175. package/types/templates/src/themes/comic-indie-art.d.ts +371 -0
  176. package/types/templates/src/themes/comic-indie-page.d.ts +125 -0
  177. package/dist/chunks/{chunk-BAZUZXS6.js → chunk-2BVGM5PJ.js} +3 -3
  178. package/dist/chunks/{chunk-WXY6LWRB.js → chunk-2J3PCCBE.js} +3 -3
  179. package/dist/chunks/{chunk-RH6WJNB4.js → chunk-2ZOSBENB.js} +3 -3
  180. package/dist/chunks/{chunk-FLXSWF6O.js → chunk-5GSRWIV2.js} +3 -3
  181. package/dist/chunks/{chunk-2WCMIJSA.js → chunk-5NGWNEKU.js} +3 -3
  182. package/dist/chunks/{chunk-IUK367P7.js → chunk-5PSOLUKI.js} +0 -0
  183. package/dist/chunks/{chunk-RUBJMQMF.js → chunk-7A3MIEOO.js} +3 -3
  184. package/dist/chunks/{chunk-R66IPFG4.js → chunk-7D46BJKY.js} +32 -32
  185. package/dist/chunks/{chunk-6H5ULKBL.js → chunk-7KFQX6N5.js} +3 -3
  186. package/dist/chunks/{chunk-EWGXFNXM.js → chunk-DF5IDTZB.js} +3 -3
  187. package/dist/chunks/{chunk-VT7UU3UP.js → chunk-ESV3AAQ4.js} +3 -3
  188. package/dist/chunks/{chunk-UJORPKRW.js → chunk-GN5W7TJD.js} +3 -3
  189. package/dist/chunks/{chunk-HQAHQJHQ.js → chunk-IPRJUV2O.js} +9 -9
  190. package/dist/chunks/{chunk-JVKC6UFV.js → chunk-KWXWK2LA.js} +3 -3
  191. package/dist/chunks/{chunk-PN6KHUSF.js → chunk-LOQNCZFE.js} +3 -3
  192. package/dist/chunks/{chunk-AKCMEBIQ.js → chunk-MNN76UQS.js} +12 -12
  193. package/dist/chunks/{chunk-ZRK4E77C.js → chunk-OGZJE5X2.js} +3 -3
  194. package/dist/chunks/{chunk-OTJJNSLB.js → chunk-PEDQKANG.js} +3 -3
  195. package/dist/chunks/{chunk-LDIYJJ24.js → chunk-PTRN7H4N.js} +11 -11
  196. package/dist/chunks/{chunk-J3XCBROI.js → chunk-QQMSM7EK.js} +3 -3
  197. package/dist/chunks/{chunk-FRFW37G6.js → chunk-TBIZI5NH.js} +3 -3
  198. package/dist/chunks/{chunk-BSX52WVT.js → chunk-UTFC2DI3.js} +8 -8
  199. package/dist/chunks/{chunk-GTR25NJ5.js → chunk-XIIFQUWN.js} +3 -3
  200. package/dist/chunks/{chunk-PGUAHUFE.js → chunk-XTFV46GB.js} +9 -9
  201. package/dist/chunks/{chunk-4ENF3NH3.js → chunk-ZLVYZPUR.js} +28 -28
@@ -0,0 +1,326 @@
1
+ // The comic hero pack's page and lettering kit: frames and tiers, panels as mask groups holding a generated picture,
2
+ // and the letterer's tools (balloons with tails that point at the speaker, captions, sound effects).
3
+ //
4
+ // A page is a sheet laid out as a vertical stack of tiers (each a fixed box), and panels are placed in a tier at
5
+ // hand-picked frames, so a frame can be a canted box, a wedge or a slanted slice. Every panel is a mask group of
6
+ // [frame shape as mask, its ground, the picture, the ink edge]: the picture is clipped to the frame and a person can
7
+ // drop their own into it. Touching frames get different grounds. Lettering sits in the tier, over the frames, so a
8
+ // balloon can cross a gutter, placed along the reading path: left to right, top to bottom.
9
+
10
+ import type { Kit } from '../kit'
11
+ import type { VectorPath } from '@/lib/document/types'
12
+ import { art, type ArtId } from './comic-hero-art'
13
+
14
+ export interface Pt { x: number; y: number }
15
+ export interface Frame { x: number; y: number; width: number; height: number; points: Pt[] }
16
+
17
+ const COLORS = {
18
+ paper: '#F1ECE2', text: '#0E0D10', onPaper: '#3A3640',
19
+ primary: '#C8102E', onPrimary: '#FFFFFF',
20
+ accent: '#F5C518', onAccent: '#0E0D10',
21
+ card: '#FFFFFF', onCard: '#0E0D10',
22
+ } as const
23
+
24
+ /** The story's title and issue, as brand fields a kit or a person can change. */
25
+ export const STORY = { title: 'Nightstar', series: 'Blackout', issue: 'No. 1' } as const
26
+
27
+ /** A comic page's brand, its style's palette, its type and its lettering components. */
28
+ export function comicSetup(k: Kit, o: { paper?: string } = {}) {
29
+ k.quantizeGeometry = true
30
+ k.brand({ ...COLORS, ...(o.paper ? { paper: o.paper } : {}) }, { title: STORY.title, series: STORY.series, issue: STORY.issue })
31
+ // The panels' grounds (seen only where a picture does not reach) and the ink that edges every frame.
32
+ k.palette({ heroG1: '#1B2238', heroG2: '#2E2A26', heroG3: '#3A1F24', heroInk: '#0E0D10' })
33
+ k.textStyle('Lettering', { size: 12, fontWeight: 800, lineHeight: 14, letterSpacing: 0, textAlign: 'CENTER' })
34
+ k.textStyle('Caption', { size: 11, fontWeight: 700, lineHeight: 14, letterSpacing: 0 })
35
+ const line = 2.5
36
+ // Balloons come in three widths, each its own component: an instance laid out at its component's width
37
+ // measures its words at that width, so a balloon is never asked to be narrower than the lines it was set in.
38
+ for (const [suffix, bw] of BALLOON_WIDTHS) {
39
+ k.component(`Speech balloon${suffix}`, { width: bw, padding: { top: 11, right: 14, bottom: 13, left: 14 }, radius: 22, fill: 'card', stroke: { color: 'onCard', width: line, align: 'INSIDE' }, align: 'CENTER', props: { Text: 'SAY SOMETHING.' } }, id => {
40
+ k.propText(id, 'Text', { style: 'Lettering', color: 'onCard', align: 'CENTER', width: 'FILL' })
41
+ })
42
+ k.component(`Thought balloon${suffix}`, { width: bw, padding: { top: 14, right: 16, bottom: 15, left: 16 }, radius: 26, fill: 'card', stroke: { color: 'onCard', width: line, align: 'INSIDE', dash: [3, 4] }, align: 'CENTER', props: { Text: 'WONDER ABOUT IT.' } }, id => {
43
+ k.propText(id, 'Text', { style: 'Lettering', color: 'onCard', align: 'CENTER', width: 'FILL' })
44
+ })
45
+ }
46
+ }
47
+
48
+ /** The display styles, each made the first time a page sets type in it (a style nobody uses is clutter). */
49
+ const DISPLAY = {
50
+ 'SFX': { size: 30, fontWeight: 900, fontStyle: 'italic' as const, lineHeight: 30, letterSpacing: -1, textAlign: 'CENTER' as const },
51
+ 'SFX big': { size: 64, fontWeight: 900, fontStyle: 'italic' as const, lineHeight: 60, letterSpacing: -2.5, textAlign: 'CENTER' as const },
52
+ 'Masthead': { size: 70, fontWeight: 900, lineHeight: 64, letterSpacing: -3.5, textAlign: 'CENTER' as const },
53
+ 'Title': { size: 30, fontWeight: 900, lineHeight: 30, letterSpacing: -1 },
54
+ 'Credit': { size: 10, fontWeight: 700, lineHeight: 12, letterSpacing: 1 },
55
+ }
56
+ const made = new WeakMap<Kit, Set<string>>()
57
+ /** `name`, one of the display styles, made on first use. */
58
+ export function display(k: Kit, name: keyof typeof DISPLAY): string {
59
+ const set = made.get(k) ?? new Set<string>()
60
+ made.set(k, set)
61
+ if (!set.has(name)) { k.textStyle(name, DISPLAY[name]); set.add(name) }
62
+ return name
63
+ }
64
+
65
+ /** The balloon components' widths: small, regular and wide. */
66
+ const BALLOON_WIDTHS: [string, number][] = [[' (small)', 96], ['', 128], [' (wide)', 160]]
67
+ const balloonFor = (w: number): [string, number] => BALLOON_WIDTHS.find(([, bw]) => w <= bw) ?? BALLOON_WIDTHS[BALLOON_WIDTHS.length - 1]
68
+
69
+ // ─── Page geometry ────────────────────────────────────────────────────────────────────────────────────────────────
70
+
71
+ export const rectFrame = (x: number, y: number, width: number, height: number): Frame =>
72
+ ({ x, y, width, height, points: [{ x: 0, y: 0 }, { x: width, y: 0 }, { x: width, y: height }, { x: 0, y: height }] })
73
+
74
+ /** A frame from corners in tier space: its box, and the corners made panel-local. */
75
+ export function frameOf(corners: Pt[]): Frame {
76
+ const xs = corners.map(p => p.x), ys = corners.map(p => p.y)
77
+ const x = Math.min(...xs), y = Math.min(...ys)
78
+ return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(...xs) - x), height: Math.round(Math.max(...ys) - y), points: corners.map(p => ({ x: Math.round(p.x - x), y: Math.round(p.y - y) })) }
79
+ }
80
+
81
+ /** Widths filling `total` in the proportions `parts`, with `gutter` between them. */
82
+ export function shares(total: number, gutter: number, parts: number[]): number[] {
83
+ const sum = parts.reduce((a, b) => a + b, 0), free = total - gutter * (parts.length - 1)
84
+ const out = parts.map(p => Math.floor((free * p) / sum))
85
+ out[out.length - 1] = free - out.slice(0, -1).reduce((a, b) => a + b, 0)
86
+ return out
87
+ }
88
+
89
+ /** A plain tier: rectangles side by side. */
90
+ export function tier(width: number, height: number, parts: number[], gutter = 10, y = 0): Frame[] {
91
+ let x = 0
92
+ return shares(width, gutter, parts).map(w => { const f = rectFrame(x, y, w, height); x += w + gutter; return f })
93
+ }
94
+
95
+ /** A tier cut by SLANTED gutters (each leans `lean` px): frames that share a diagonal edge read as motion. */
96
+ export function slantTier(width: number, height: number, parts: number[], lean = 24, gutter = 10, y = 0): Frame[] {
97
+ const widths = shares(width, gutter, parts), edges: number[] = [0]
98
+ widths.forEach((w, i) => edges.push(edges[i] + w + (i < widths.length - 1 ? gutter : 0)))
99
+ return widths.map((_, i) => {
100
+ const first = i === 0, last = i === widths.length - 1
101
+ const lTop = first ? 0 : edges[i] + lean, lBot = first ? 0 : edges[i] - lean
102
+ const rTop = last ? width : edges[i + 1] - gutter + lean, rBot = last ? width : edges[i + 1] - gutter - lean
103
+ return frameOf([{ x: lTop, y }, { x: rTop, y }, { x: rBot, y: y + height }, { x: lBot, y: y + height }])
104
+ })
105
+ }
106
+
107
+ /** A frame whose top and bottom edges tilt: `dy0` / `dy1` px down at the right end of each (a Dutch tier). */
108
+ export function tilted(x: number, y: number, w: number, h: number, dyTop: number, dyBot: number): Frame {
109
+ return frameOf([{ x, y: y + Math.max(0, -dyTop) }, { x: x + w, y: y + Math.max(0, dyTop) }, { x: x + w, y: y + h - Math.max(0, -dyBot) }, { x, y: y + h - Math.max(0, dyBot) }])
110
+ }
111
+
112
+ /** A slice of a ring around (cx, cy): the radial page. */
113
+ export function wedgeRing(cx: number, cy: number, rIn: number, rOut: number, from: number, to: number): Frame {
114
+ const pts: Pt[] = [], steps = 12
115
+ for (let i = 0; i <= steps; i++) { const a = ((from + ((to - from) * i) / steps) * Math.PI) / 180; pts.push({ x: cx + rOut * Math.cos(a), y: cy + rOut * Math.sin(a) }) }
116
+ for (let i = steps; i >= 0; i--) { const a = ((from + ((to - from) * i) / steps) * Math.PI) / 180; pts.push({ x: cx + rIn * Math.cos(a), y: cy + rIn * Math.sin(a) }) }
117
+ return frameOf(pts)
118
+ }
119
+
120
+ /** A circle (n-gon) frame: the radial page's hub. */
121
+ export function disc(cx: number, cy: number, r: number): Frame {
122
+ return frameOf(Array.from({ length: 36 }, (_, i) => ({ x: cx + r * Math.cos((i * Math.PI) / 18), y: cy + r * Math.sin((i * Math.PI) / 18) })))
123
+ }
124
+
125
+ /** Grow a polygon from its centroid: the paper keyline an inset frame sits on. */
126
+ export function inflate(points: Pt[], by: number): Pt[] {
127
+ const cx = points.reduce((a, p) => a + p.x, 0) / points.length, cy = points.reduce((a, p) => a + p.y, 0) / points.length
128
+ return points.map(p => { const dx = p.x - cx, dy = p.y - cy, d = Math.max(1, Math.hypot(dx, dy)); return { x: p.x + (dx / d) * by, y: p.y + (dy / d) * by } })
129
+ }
130
+
131
+ // ─── The page ─────────────────────────────────────────────────────────────────────────────────────────────────────
132
+
133
+ /** A sheet as a column of tiers with `gutter` between them, inside `margin`. Returns the live area. */
134
+ export function pageGrid(k: Kit, sheet: string, margin: number, gutter: number): { W: number; H: number } {
135
+ k.flowSheet(sheet, gutter, margin)
136
+ const n = k.node(sheet)
137
+ return { W: n.width - margin * 2, H: n.height - margin * 2 }
138
+ }
139
+
140
+ /** A tier: a fixed box in the sheet's column. */
141
+ export const tierBox = (k: Kit, sheet: string, W: number, h: number, name: string) => k.stack(sheet, { width: W, height: h, name })
142
+
143
+ /** Tier heights that fill `H` in proportion to `parts`, with `gutter` between. */
144
+ export const rows = (H: number, gutter: number, parts: number[]) => shares(H, gutter, parts)
145
+
146
+ /**
147
+ * A panel at frame `f` in `tierId`: a mask group of [the frame's shape as mask, its ground, picture `pic`, the ink
148
+ * edge]. `ground` is the panel's own ground (touching panels must differ). Returns the group.
149
+ */
150
+ export function panel(k: Kit, tierId: string, f: Frame, pic: ArtId, o: { ground: Ground; name?: string; keyline?: number; edge?: boolean }): string {
151
+ if (o.keyline) {
152
+ const inTier = f.points.map(p => ({ x: p.x + f.x, y: p.y + f.y }))
153
+ const grown = inflate(inTier, o.keyline)
154
+ const xs = grown.map(p => p.x), ys = grown.map(p => p.y), gx = Math.max(0, Math.min(...xs)), gy = Math.max(0, Math.min(...ys))
155
+ const tierN = k.node(tierId)
156
+ const clamp = grown.map(p => ({ x: Math.min(tierN.width, Math.max(0, p.x)) - gx, y: Math.min(tierN.height, Math.max(0, p.y)) - gy }))
157
+ const bw = Math.max(...clamp.map(p => p.x)), bh = Math.max(...clamp.map(p => p.y))
158
+ k.vector(tierId, bw, bh, [{ closed: true, points: clamp }], 'paper', { name: 'Keyline', absolute: { x: gx, y: gy } })
159
+ }
160
+ const id = k.group(tierId, { x: f.x, y: f.y, width: f.width, height: f.height }, { name: o.name ?? 'Panel', absolute: true })
161
+ k.patch(id, { isMaskGroup: true, isPhotoFrame: true })
162
+ const shape: VectorPath = { closed: true, points: f.points.map(p => ({ x: p.x, y: p.y })) }
163
+ k.vector(id, f.width, f.height, [shape], 'paper', { name: 'Panel mask', mask: true })
164
+ k.rect(id, f.width, f.height, o.ground, { name: 'Ground' })
165
+ k.image(id, art(pic), f.width, f.height, { name: 'Picture' })
166
+ if (o.edge !== false) k.vector(id, f.width, f.height, [shape], [], { name: 'Panel edge', stroke: { color: 'heroInk', width: 3, join: 'MITER' } })
167
+ return id
168
+ }
169
+
170
+ /** The panel grounds: touching panels take different ones. */
171
+ export type Ground = 'heroG1' | 'heroG2' | 'heroG3'
172
+
173
+ // ─── Lettering ────────────────────────────────────────────────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * A speech balloon at (x, y) in the tier, `width` wide, its tail running from inside the balloon to `to` (the
177
+ * speaker's mouth, in tier space). The tail is drawn first, so the balloon covers its root and only the point shows.
178
+ */
179
+ export function say(k: Kit, tierId: string, words: string, o: { x: number; y: number; width?: number; to?: Pt; thought?: boolean; name?: string }) {
180
+ const [suffix, w] = balloonFor(o.width ?? 128)
181
+ o = { ...o, ...within(k, tierId, o.x, o.y, w, 70) }
182
+ if (o.to) {
183
+ const root = { x: o.x + w * 0.5 + (o.to.x > o.x + w / 2 ? w * 0.12 : -w * 0.12), y: o.y + 16 }
184
+ // The tail stops short of the mouth: it points at the speaker, it does not touch them. The balloon's height is
185
+ // estimated from its words (about 6.6 px a capital at 12 px), so the tail runs some 30 px past its foot.
186
+ const lines = Math.ceil((words.length * 6.6) / (w - 28)), foot = o.y + 24 + lines * 14
187
+ const dist = Math.hypot(o.to.x - root.x, o.to.y - root.y) || 1, reach = Math.min(dist, Math.max(foot - root.y + 30, 34))
188
+ o = { ...o, to: { x: root.x + ((o.to.x - root.x) * reach) / dist, y: root.y + ((o.to.y - root.y) * reach) / dist } }
189
+ if (o.thought && o.to) {
190
+ // A thought trails off in bubbles toward the thinker.
191
+ const paths: VectorPath[] = []
192
+ for (let i = 1; i <= 3; i++) {
193
+ const t = 0.45 + i * 0.16, cx = root.x + (o.to.x - root.x) * t, cy = root.y + (o.to.y - root.y) * t, r = 7 - i * 1.6
194
+ paths.push({ closed: true, points: Array.from({ length: 10 }, (_, j) => ({ x: cx + r * Math.cos((j * Math.PI) / 5), y: cy + r * Math.sin((j * Math.PI) / 5) })) })
195
+ }
196
+ tailVector(k, tierId, paths, 'Thought bubble')
197
+ } else if (o.to) {
198
+ const dx = o.to.x - root.x, dy = o.to.y - root.y, len = Math.hypot(dx, dy) || 1, nx = -dy / len, ny = dx / len, half = 9
199
+ tailVector(k, tierId, [{ closed: true, points: [{ x: root.x + nx * half, y: root.y + ny * half }, { x: o.to.x, y: o.to.y }, { x: root.x - nx * half, y: root.y - ny * half }] }], 'Balloon tail')
200
+ }
201
+ }
202
+ return k.instance(tierId, `${o.thought ? 'Thought balloon' : 'Speech balloon'}${suffix}`, { Text: words.toUpperCase() }, { absolute: { x: Math.round(o.x), y: Math.round(o.y) }, name: o.name ?? (o.thought ? 'Thought' : 'Balloon') })
203
+ }
204
+
205
+ /** (x, y) pulled back so a `w` × `h` box stays inside the tier. */
206
+ function within(k: Kit, tierId: string, x: number, y: number, w: number, h: number): Pt {
207
+ const t = k.node(tierId)
208
+ return { x: Math.max(0, Math.min(x, t.width - w)), y: Math.max(0, Math.min(y, t.height - h)) }
209
+ }
210
+
211
+ /** Paths in tier space as a card-filled, ink-lined layer boxed tight round them. */
212
+ function tailVector(k: Kit, tierId: string, paths: VectorPath[], name: string) {
213
+ const xs = paths.flatMap(p => p.points.map(q => q.x)), ys = paths.flatMap(p => p.points.map(q => q.y))
214
+ const x0 = Math.min(...xs), y0 = Math.min(...ys)
215
+ const local = paths.map(p => ({ ...p, points: p.points.map(q => ({ x: q.x - x0, y: q.y - y0 })) }))
216
+ k.vector(tierId, Math.max(1, Math.max(...xs) - x0), Math.max(1, Math.max(...ys) - y0), local, 'card', { name, stroke: { color: 'onCard', width: 2, join: 'MITER' }, absolute: { x: x0, y: y0 } })
217
+ }
218
+
219
+ /** A caption box at (x, y): the narrator, in the corner where the reader's eye enters the panel. */
220
+ export function caption(k: Kit, tierId: string, words: string, o: { x: number; y: number; width?: number; name?: string }) {
221
+ o = { ...o, ...within(k, tierId, o.x, o.y, o.width ?? 200, 50) }
222
+ const id = k.stack(tierId, { width: o.width ?? 200, padding: { top: 7, right: 10, bottom: 8, left: 10 }, fill: 'accent', stroke: { color: 'onAccent', width: 2, align: 'INSIDE', join: 'MITER' }, name: o.name ?? 'Caption box', absolute: { x: Math.round(o.x), y: Math.round(o.y) } })
223
+ k.text(id, words.replace(/(\{\{.*?\}\})|([^{]+)/g, (m, tok) => (tok ? m : m.toUpperCase())), { style: 'Caption', color: 'onAccent', width: 'FILL', name: 'Caption words' })
224
+ return id
225
+ }
226
+
227
+ /**
228
+ * A sound effect drawn into the art: the word on an impact star (accent, inked, on a hard ink shadow), tilted.
229
+ * `big` for the one hit a page is built round.
230
+ */
231
+ export function sfx(k: Kit, tierId: string, words: string, o: { x: number; y: number; w: number; h: number; deg?: number; big?: boolean; fill?: 'accent' | 'primary'; seed?: number; name?: string }) {
232
+ const fill = o.fill ?? 'accent', ink = fill === 'accent' ? 'onAccent' : 'onPrimary'
233
+ // The burst is sized to its word: about 25 px a letter at 34 px (48 big), plus the spikes.
234
+ const need = words.length * (o.big ? 46 : 25) + 50
235
+ if (o.w < need) o = { ...o, h: Math.round(o.h * (need / o.w) ** 0.5), w: need }
236
+ o = { ...o, ...within(k, tierId, o.x, o.y, o.w, o.h) }
237
+ const id = k.stack(tierId, { width: o.w, height: o.h, align: 'CENTER', justify: 'CENTER', name: o.name ?? 'Sound effect', absolute: { x: Math.round(o.x), y: Math.round(o.y) } })
238
+ const n = 13, pts: Pt[] = []
239
+ let s = (o.seed ?? 7) >>> 0
240
+ const r = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 2 ** 32 }
241
+ for (let i = 0; i < n * 2; i++) {
242
+ const a = (i * Math.PI) / n - Math.PI / 2, rad = i % 2 ? 0.62 + r() * 0.08 : 0.9 + r() * 0.1
243
+ pts.push({ x: o.w / 2 + (o.w / 2) * rad * Math.cos(a), y: o.h / 2 + (o.h / 2) * rad * Math.sin(a) })
244
+ }
245
+ const star: VectorPath = { closed: true, points: pts }
246
+ k.vector(id, o.w, o.h, [star], ink, { name: 'Impact star shadow', absolute: { x: 0, y: 0 } })
247
+ k.vector(id, o.w, o.h, [{ closed: true, points: pts.map(p => ({ x: p.x - 5, y: p.y - 5 })) }], fill, { name: 'Impact star', stroke: { color: ink, width: 3, join: 'MITER' }, absolute: { x: 0, y: 0 } })
248
+ k.text(id, words.toUpperCase(), { style: display(k, o.big ? 'SFX big' : 'SFX'), color: ink, align: 'CENTER', name: 'Sound effect words', autoWidth: true })
249
+ if (o.deg) k.patch(id, { rotation: o.deg })
250
+ return id
251
+ }
252
+
253
+ /** The folio: the series and page number in the foot margin, on paper (the one line read off the art). */
254
+ export const FOLIO = 24
255
+ export function folio(k: Kit, sheet: string, words: string) {
256
+ k.text(sheet, words.toUpperCase(), { style: display(k, 'Credit'), color: 'text', width: 'FILL', name: 'Folio' })
257
+ }
258
+
259
+ /** A full-bleed sheet's foot: a paper strip under the art with the line in it, so the line is read on paper. */
260
+ export function foot(k: Kit, tierId: string, w: number, h: number, words: string, name: string) {
261
+ const id = k.stack(tierId, { width: w, height: h, padding: { top: 14, right: 32, bottom: 14, left: 32 }, justify: 'CENTER', fill: 'text', name: 'Foot', absolute: { x: 0, y: k.node(tierId).height - h } })
262
+ k.text(id, words.toUpperCase(), { style: display(k, 'Title'), color: 'paper', width: 'FILL', name })
263
+ return id
264
+ }
265
+
266
+ /** The meta every comic page shares: a print page in the comic category. */
267
+ export function comicMeta(name: string, description: string, tags: string[]) {
268
+ return {
269
+ name, description, category: 'comic', tags: ['comic', 'print', 'nightstar', 'action', ...tags],
270
+ platforms: ['print' as const], formats: ['page' as const], useCases: ['comic' as const],
271
+ created: '2026-09-22', updated: '2026-10-11',
272
+ }
273
+ }
274
+
275
+ /** A frame the size of the sheet plus its bleed on every side, for a tier at the sheet's origin. */
276
+ export function bleedFrame(k: Kit, w: number, h: number): Frame {
277
+ const b = k.doc.settings?.bleed
278
+ const [l, t, r, bo] = [b?.left ?? 0, b?.top ?? 0, b?.right ?? 0, b?.bottom ?? 0]
279
+ return rectFrame(-l, -t, w + l + r, h + t + bo)
280
+ }
281
+
282
+ /** The comic page and spread sizes in px. */
283
+ export const SIZE = {
284
+ page: { w: Math.round(6.625 * 96), h: Math.round(10.25 * 96) },
285
+ cover: { w: Math.round(6.875 * 96), h: Math.round(10.5 * 96) },
286
+ spread: { w: Math.round(13.25 * 96), h: Math.round(10.25 * 96) },
287
+ } as const
288
+
289
+ /** A polygon (tier space) cut to the tier's box, as a frame. */
290
+ export function clipFrame(pts: Pt[], w: number, h: number): Frame {
291
+ const edges: [(p: Pt) => boolean, (a: Pt, b: Pt) => Pt][] = [
292
+ [p => p.x >= 0, (a, b) => ({ x: 0, y: a.y + ((b.y - a.y) * (0 - a.x)) / (b.x - a.x) })],
293
+ [p => p.x <= w, (a, b) => ({ x: w, y: a.y + ((b.y - a.y) * (w - a.x)) / (b.x - a.x) })],
294
+ [p => p.y >= 0, (a, b) => ({ x: a.x + ((b.x - a.x) * (0 - a.y)) / (b.y - a.y), y: 0 })],
295
+ [p => p.y <= h, (a, b) => ({ x: a.x + ((b.x - a.x) * (h - a.y)) / (b.y - a.y), y: h })],
296
+ ]
297
+ let out = pts
298
+ for (const [inside, cross] of edges) {
299
+ const src = out
300
+ out = []
301
+ for (let i = 0; i < src.length; i++) {
302
+ const a = src[i], b = src[(i + 1) % src.length]
303
+ if (inside(a)) { out.push(a); if (!inside(b)) out.push(cross(a, b)) } else if (inside(b)) out.push(cross(a, b))
304
+ }
305
+ }
306
+ return frameOf(out)
307
+ }
308
+
309
+ /**
310
+ * A slice of the page around a hub at (cx, cy): from angle `from` to `to` (degrees, clockwise from +x), starting
311
+ * `rIn` out, with gutters of even width `g` along both radial edges, running out to the tier's edges.
312
+ */
313
+ export function radialSlice(cx: number, cy: number, rIn: number, from: number, to: number, g: number, w: number, h: number): Frame {
314
+ const R = Math.hypot(w, h) * 2, pts: Pt[] = []
315
+ const edge = (deg: number, side: number, t: number): Pt => {
316
+ const a = (deg * Math.PI) / 180, d = { x: Math.cos(a), y: Math.sin(a) }, n = { x: -Math.sin(a), y: Math.cos(a) }
317
+ return { x: cx + n.x * side * (g / 2) + d.x * t, y: cy + n.y * side * (g / 2) + d.y * t }
318
+ }
319
+ const steps = 8
320
+ for (let i = 0; i <= steps; i++) { const a = ((from + ((to - from) * i) / steps) * Math.PI) / 180; pts.push({ x: cx + rIn * Math.cos(a), y: cy + rIn * Math.sin(a) }) }
321
+ pts.push(edge(to, -1, R))
322
+ const mid = (((from + to) / 2) * Math.PI) / 180
323
+ pts.push({ x: cx + R * 1.5 * Math.cos(mid), y: cy + R * 1.5 * Math.sin(mid) })
324
+ pts.push(edge(from, 1, R))
325
+ return clipFrame(pts, w, h)
326
+ }
@@ -1,5 +1,18 @@
1
- // Comic hero pack: one gritty action series (generated art, editable lettering). Registered empty so its pack PR never edits index.ts; every source in it, in gallery order.
1
+ // Comic hero pack: one gritty action series (generated art, editable lettering), "Nightstar: Blackout", in reading
2
+ // order: cover, the chase, the fight, the blackout, the last punch, the questions and the pin-up.
2
3
  import type { TemplateSource } from '../build'
4
+ import heroCoverModern from '../print/comic-hero-cover-modern'
5
+ import heroChase from '../print/comic-hero-chase'
6
+ import heroWidescreen from '../print/comic-hero-widescreen'
7
+ import heroGridBreak from '../print/comic-hero-grid-break'
8
+ import heroCascade from '../print/comic-hero-cascade'
9
+ import heroOverlap from '../print/comic-hero-overlap'
10
+ import heroNested from '../print/comic-hero-nested'
11
+ import heroParallel from '../print/comic-hero-parallel'
12
+ import heroMangaAction from '../print/comic-hero-manga-action'
13
+ import heroInterrogation from '../print/comic-hero-interrogation'
14
+ import heroPinup from '../print/comic-hero-pinup'
15
+ import heroIssue from '../print/comic-hero-issue'
3
16
 
4
- const pack: readonly TemplateSource[] = []
17
+ const pack: readonly TemplateSource[] = [heroCoverModern, heroChase, heroWidescreen, heroGridBreak, heroCascade, heroOverlap, heroNested, heroParallel, heroMangaAction, heroInterrogation, heroPinup, heroIssue]
5
18
  export default pack
@@ -0,0 +1,73 @@
1
+ // "Last Summer on Alder Street"'s art: one picture a panel, made for these pages with an image model (GPT Image 2,
2
+ // through the Vercel AI Gateway) from one style line and one fixed sentence per character, so the cast stays on model
3
+ // from page to page. Shipped as the portfolios' art is (../web/portfolio-art.ts): JPEG under the media store's
4
+ // content hash in public/templates/media. Made for these templates: free to use with them.
5
+
6
+ export const INDIE_ART = {
7
+ 'gag-1': { hash: '7278309005e257083761d335f87c1ea2', width: 1280, height: 853, subject: "Wide shot of a cluttered sunny flat kitchen in the morning: Theo carries a stack of empty cardboard boxes past Juno, who sits at the table with a coffee mug, not looking up." },
8
+ 'gag-2': { hash: '74c096aec779b8b2d3dd47dd0d02d93b', width: 1280, height: 853, subject: "Same kitchen, same framing: Theo carries an even taller stack of cardboard boxes past Juno, who now peers over her mug at him." },
9
+ 'gag-3': { hash: 'a8dbddf1dd83322ed04c2ab535638359', width: 1280, height: 853, subject: "Same kitchen: the boxes are stacked to the ceiling, Juno stands up, arms folded, eyebrow raised at Theo who freezes guiltily." },
10
+ 'gag-4': { hash: '01fa53b2ac15ff9dfc7b533e9ac1df57', width: 1280, height: 853, subject: "Close medium shot: Theo sheepishly offers Juno a single roll of packing tape like a gift, Juno deadpan." },
11
+ 'strip-1': { hash: '703aed5d82aa21a0a7cab51feab13bee', width: 1280, height: 853, subject: "Juno opens the flat's front door to find Mrs Okafor holding a huge leggy tomato plant in a pot, warm hallway light." },
12
+ 'strip-2': { hash: '23a1d00f97b7e08e75eaf84714d789c3', width: 1280, height: 853, subject: "Mrs Okafor pushes the tomato plant into Juno's arms, smiling knowingly; Juno awkward, leaves in her face." },
13
+ 'strip-3': { hash: 'd7492163159c81911b0e81776946286c', width: 1280, height: 853, subject: "Juno alone in the hallway hugging the giant tomato plant, looking toward a half-packed box in the background, a little lost." },
14
+ 'talk-1': { hash: 'b9ea25bab982909e9883639ba12f0a86', width: 1280, height: 853, subject: "Two-shot: Juno and Theo sit at opposite ends of a worn green sofa in a small living room at dusk, lamp light, boxes behind them." },
15
+ 'talk-2': { hash: '87f0dba9b035f0c3464211cb208ae348', width: 853, height: 1280, subject: "Close-up profile of Theo facing right, eyes down, rubbing the back of his neck, warm lamp light." },
16
+ 'talk-3': { hash: 'c81ce1b77f0e3a030e56cc8753b633d5', width: 853, height: 1280, subject: "Close-up profile of Juno facing left, jaw set, glasses catching lamp light." },
17
+ 'talk-4': { hash: 'a85bb81ceb51c2571a75de40f4fa7369', width: 853, height: 1280, subject: "Close-up of Theo turning to face us, earnest, a small apologetic smile." },
18
+ 'talk-5': { hash: 'd71d59b54e57edcd73744e754fd9e1a2', width: 853, height: 1280, subject: "Close-up of Juno looking away out a window, sunset colours on her face, eyes wet but not crying." },
19
+ 'talk-6': { hash: '23f5f1d9ed62e4a581b6d100da88ca7b', width: 1280, height: 853, subject: "Two-shot from behind the sofa: Juno and Theo now sitting closer, both looking at the window, shoulders almost touching." },
20
+ 'silent-1': { hash: 'c720a8acf2f8d1327884e800a82b8fbb', width: 1280, height: 853, subject: "Juno walks out of a red-brick terraced house onto a quiet tree-lined street at golden hour, hands in cardigan pockets." },
21
+ 'silent-2': { hash: '9512d89de474ecc6993d23db349b7bc7', width: 1024, height: 1024, subject: "Juno passes a corner shop with crates of fruit outside, glancing at a cafe where two chairs sit empty." },
22
+ 'silent-3': { hash: '3c96304d31faebcf83fabf1139bae4b5', width: 1024, height: 1024, subject: "Close-up of Juno's hand brushing along a chain-link fence covered in morning glories." },
23
+ 'silent-4': { hash: 'f6000b487b383f1f80659128b5f2e918', width: 1024, height: 1024, subject: "Juno stops on a small footbridge over a canal, leaning on the rail, sunset reflecting in the water." },
24
+ 'silent-5': { hash: '11a48aed6ac5a63ee1f2453ed9158310', width: 1024, height: 1024, subject: "A narrowboat drifts under the footbridge on a golden canal, ducks in its wake, no people visible." },
25
+ 'silent-6': { hash: '49f8e54dd649900ebf68d781355dc68d', width: 1280, height: 853, subject: "Juno, small in the frame, sits on a bench by the canal under a huge orange evening sky." },
26
+ 'neg-1': { hash: 'a4e2acb20644b2676056e5a77cbe2c54', width: 853, height: 1280, subject: "Juno sits alone on the worn wooden stairs of an old apartment stairwell, knees hugged, single warm light from above, lots of dark shadow." },
27
+ 'neg-2': { hash: '91581bb8ab834a4f3726fe7b67c2454c', width: 1024, height: 1024, subject: "Mrs Okafor lowers herself to sit beside Juno on the stairs, offering her a small paper bag of plums." },
28
+ 'neg-3': { hash: 'add60674b9245b03ff9f05a695da729b', width: 1280, height: 853, subject: "Juno and Mrs Okafor seen small from the top of the stairwell, sitting side by side eating plums, deep shadows around them." },
29
+ 'riso-1': { hash: '885801e1cd1c8e6afd5413c22f3a0423', width: 1280, height: 853, subject: "Flashback, years earlier: younger Juno and Theo struggle to carry a green sofa up a narrow staircase, laughing." },
30
+ 'riso-2': { hash: '3f1f03442ea4c3f235e1677bc29baf8e', width: 1024, height: 1024, subject: "Flashback: Juno and Theo sitting on the floor of an empty flat eating takeaway noodles from cartons by candlelight." },
31
+ 'riso-3': { hash: '7f28cf9edf034e02648cecc863dfddac', width: 1024, height: 1024, subject: "Flashback: Theo painting a wall of the empty flat a warm terracotta colour, paint on his nose." },
32
+ 'riso-4': { hash: 'd3215f1687917d6c093c326b766b98c2', width: 1024, height: 1024, subject: "Flashback: younger Mrs Okafor in her doorway hands Juno a spare key on a red string." },
33
+ 'riso-5': { hash: 'f842fdebf33c9e05df871ef7ff94a65e', width: 1024, height: 1024, subject: "Flashback: Juno putting a small tomato seedling in a pot on a windowsill." },
34
+ 'riso-6': { hash: 'd5d1426f40a95fd777cc85feefec496d', width: 1280, height: 853, subject: "Flashback: Juno and Theo asleep on the green sofa in the unpacked flat, morning sun pouring in." },
35
+ 'zine-1': { hash: '5d076bd8c3c6a0e13dd3bfa6e365d96c', width: 1024, height: 1024, subject: "Juno and Theo sprawled on the living-room floor drawing a party flyer with markers, scissors and glue around them." },
36
+ 'zine-2': { hash: 'a9481090802497624cb73a623e5eb2b7', width: 1024, height: 1024, subject: "Theo cutting out a paper sun shape with scissors, tongue poking out in concentration." },
37
+ 'zine-3': { hash: '57fa82febd9a935169d62325a0e1e4c6', width: 1024, height: 1024, subject: "Juno feeding a sheet into an old photocopier in a tiny print shop lit pink." },
38
+ 'zine-4': { hash: 'db42a2bb05dd170319d2f69ee7381ecf', width: 1024, height: 1024, subject: "Juno and Theo holding up warm fresh photocopies, grinning, in the print shop." },
39
+ 'zine-5': { hash: 'aadef44e85bd40011e22b2b6fa96a92e', width: 1024, height: 1024, subject: "Juno taping a flyer to a lamp post on the street at evening." },
40
+ 'zine-6': { hash: 'fc15cc0a1d2b05148bd79042c9094378', width: 1024, height: 1024, subject: "Mrs Okafor reading a flyer slid under her door, smiling, reading glasses on her nose." },
41
+ 'web-1': { hash: 'c3d34559e732a1489f6f38746fd0b1b5', width: 1280, height: 853, subject: "Juno and Theo climb a ladder through a hatch onto a flat tar rooftop carrying a tangle of string lights." },
42
+ 'web-2': { hash: '6a4e270e1205bcf3d96fabed6c73f32f', width: 1280, height: 853, subject: "Theo balancing on an upturned crate hanging string lights between two chimneys, sky turning orange." },
43
+ 'web-3': { hash: 'c59a21c74bf19a77966eb9f5d1855d23', width: 1280, height: 853, subject: "Juno arranging mismatched chairs and potted plants on the rooftop, the tomato plant in pride of place." },
44
+ 'web-4': { hash: 'f74227b40602a67fee78ff5716a59f15', width: 1280, height: 853, subject: "Close-up of string lights flickering on against a deep orange and violet sky over rooftops." },
45
+ 'web-5': { hash: '1b5867887ae2ef854abacce4265074ab', width: 1280, height: 853, subject: "Wide shot: the finished rooftop with glowing lights, Juno and Theo standing back admiring it, city skyline at dusk." },
46
+ 'manga-1': { hash: '26bc139ef62bce888d2c74f9ed2cd3b2', width: 1280, height: 853, subject: "Lively rooftop party at dusk: friends dancing under string lights, Mrs Okafor holding a plate of food, Juno and Theo laughing in the middle." },
47
+ 'manga-2': { hash: '38042d09bd02f9a1705ac3c5d762aaa5', width: 853, height: 1280, subject: "Mrs Okafor dancing with surprising energy, arms raised, under the string lights." },
48
+ 'manga-3': { hash: '7293bb7864906a4ca7e032fcec439e22', width: 1024, height: 1024, subject: "Close-up: a fat raindrop splashes onto a paper plate of jollof rice on the rooftop." },
49
+ 'manga-4': { hash: '20905e8fc620309c95b8e0791f5106eb', width: 1280, height: 853, subject: "Sudden heavy summer rain on the rooftop, Juno and Theo scrambling to cover the record player with a jacket, both soaked and laughing." },
50
+ 'manga-5': { hash: 'e25acbb7127fa8477f3883c00de13b57', width: 1024, height: 1024, subject: "Mrs Okafor calmly opening a huge golf umbrella in the rain." },
51
+ 'manga-6': { hash: '86c6caef7d63da6bd2de241dc797f929', width: 1280, height: 853, subject: "Juno, Theo and Mrs Okafor squeezed together under one big umbrella on the wet rooftop, lights reflecting in puddles." },
52
+ 'icon-1': { hash: '1356a79a188f5eef8fe728b918b7f557', width: 900, height: 900, subject: "Two mismatched coffee mugs side by side on a sunlit kitchen windowsill, still life." },
53
+ 'icon-2': { hash: '389c17f33c4caa20ebce699d7c54193d', width: 900, height: 900, subject: "A ring of keys on a red string hanging on a hook by a door, still life." },
54
+ 'icon-3': { hash: 'fed8cc1df96978caa384a1145334c228', width: 900, height: 900, subject: "A potted tomato plant heavy with red tomatoes on a windowsill, still life." },
55
+ 'icon-4': { hash: '470f93f1619abe9669fec1f8380868ca', width: 900, height: 900, subject: "A stack of vinyl records leaning against a green sofa, still life." },
56
+ 'icon-5': { hash: '5d7e780cb6c9885df54232c4557a2992', width: 900, height: 900, subject: "A taped cardboard box with a bicycle helmet on top, still life." },
57
+ 'icon-6': { hash: 'dfc999e2f0c13dd3d7831a5cb7199650', width: 900, height: 900, subject: "A wall with a terracotta-painted patch and pencil height marks, still life." },
58
+ 'icon-7': { hash: '658b68e0fdd56fe3463d06db380e82d9', width: 900, height: 900, subject: "Two toothbrushes in one cup in a small tiled bathroom, still life." },
59
+ 'icon-8': { hash: '419e000f9e5f13053a2bdb5def981789', width: 900, height: 900, subject: "A rumpled blanket on an empty green sofa in evening light, still life." },
60
+ 'icon-9': { hash: 'd923966ad48dda7a1ca0ab4bed402661', width: 900, height: 900, subject: "An open window with a curtain blowing, a sunset sky outside, still life." },
61
+ 'hour-1': { hash: 'a3d065213622660994c37684a5b576a1', width: 1280, height: 853, subject: "Wide shot: Juno and Theo sit on the edge of the rooftop at sunset, legs dangling, the city spread below." },
62
+ 'hour-2': { hash: '3d8f4de451b45e44fc2f985c40c1a76c', width: 1280, height: 853, subject: "Medium shot: Theo hands Juno one of the red-string spare keys, Juno looks at it." },
63
+ 'hour-3': { hash: '199e5d72656c0a6201fc3660cd5ef6e7', width: 1024, height: 1024, subject: "Extreme close-up of Juno's hand closing around a key on a red string, sunset light." },
64
+ 'hour-4': { hash: '58726efa52734b9f560123c086b44ded', width: 1280, height: 853, subject: "Juno rests her head on Theo's shoulder, both looking at the sunset, back view." },
65
+ 'hour-5': { hash: '823c59f93e221b37145a2e6fb346d1d3', width: 1280, height: 853, subject: "Very wide shot: two tiny silhouettes on the rooftop against an enormous orange and pink sunset sky." },
66
+ 'back-1': { hash: 'be8e16cc738e2a3c6c15a426534885ba', width: 900, height: 900, subject: "Theo waving from the window of a departing train, a duffel bag beside him, morning light." },
67
+ 'back-2': { hash: '76cabed26da043b88bb115fb65bf45a7', width: 900, height: 900, subject: "Juno and Mrs Okafor watering the tomato plant together on a sunny windowsill, smiling." },
68
+ } as const
69
+
70
+ export type IndieArtId = keyof typeof INDIE_ART
71
+
72
+ /** A panel's picture address in a design: the media store's, by its hash. */
73
+ export const indieArt = (id: IndieArtId): string => `media://${INDIE_ART[id].hash}`
@@ -0,0 +1,182 @@
1
+ // The indie comic pages' kit: "Last Summer on Alder Street", one slice-of-life story told across the comic formats.
2
+ // The art is generated (comic-indie-art.ts); everything a person reads is lettering they can edit. A page is a sheet
3
+ // laid out as a vertical stack of tiers, and each panel is a mask group of [frame shape as mask, its ground, the art,
4
+ // the ink edge], so the art is clipped to the frame and a person can drop their own picture into it. Lettering sits
5
+ // in the tier, over the frames, along the reading path.
6
+
7
+ import type { Kit } from '../kit'
8
+ import type { Effect, VectorPath } from '@/lib/document/types'
9
+ import { indieArt, type IndieArtId } from './comic-indie-art'
10
+
11
+ export interface Pt { x: number; y: number }
12
+ export interface Frame { x: number; y: number; width: number; height: number; points: Pt[] }
13
+
14
+ const COLORS = {
15
+ paper: '#FBF1E4', text: '#2A1E1A', onPaper: '#4A3A35',
16
+ primary: '#D9603B', onPrimary: '#FFF8EE',
17
+ accent: '#F6C66B', onAccent: '#2A1E1A',
18
+ card: '#FFF8EE', onCard: '#2A1E1A',
19
+ } as const
20
+
21
+ /** The panels' grounds (what shows while art loads, or if a person removes it) and the ink. */
22
+ const GROUNDS = { g1: '#F2A65A', g2: '#C8553D', g3: '#6E4B6B', g4: '#F7D9A8', ink: '#2A1E1A' } as const
23
+
24
+ export const STORY = { title: 'Last Summer on Alder Street', series: 'Alder Street', issue: 'No. 1' } as const
25
+
26
+ /** A page's brand, palette, type and lettering components. */
27
+ export function indieSetup(k: Kit) {
28
+ k.quantizeGeometry = true
29
+ k.brand({ ...COLORS }, { title: STORY.title, series: STORY.series, issue: STORY.issue })
30
+ k.palette({ ...GROUNDS })
31
+ k.textStyle('Lettering', { size: 12, fontWeight: 700, lineHeight: 14, letterSpacing: 0, textAlign: 'CENTER' })
32
+ k.textStyle('Caption', { size: 11, fontWeight: 600, lineHeight: 14, letterSpacing: 0 })
33
+ for (const [suffix, bw] of BALLOON_WIDTHS) {
34
+ k.component(`Speech balloon${suffix}`, { width: bw, padding: { top: 10, right: 13, bottom: 12, left: 13 }, radius: 22, fill: 'card', stroke: { color: 'onCard', width: 2, align: 'INSIDE' }, align: 'CENTER', props: { Text: 'Say something.' } }, id => {
35
+ k.propText(id, 'Text', { style: 'Lettering', color: 'onCard', align: 'CENTER', width: 'FILL' })
36
+ })
37
+ }
38
+ }
39
+
40
+ const DISPLAY = {
41
+ 'SFX': { size: 26, fontWeight: 900, fontStyle: 'italic' as const, lineHeight: 26, letterSpacing: -1, textAlign: 'CENTER' as const },
42
+ 'Title': { size: 26, fontWeight: 900, lineHeight: 28, letterSpacing: -0.5 },
43
+ 'Masthead': { size: 40, fontWeight: 900, lineHeight: 40, letterSpacing: -1.5 },
44
+ 'Body': { size: 11, fontWeight: 400, lineHeight: 16, letterSpacing: 0 },
45
+ 'Credit': { size: 9, fontWeight: 700, lineHeight: 12, letterSpacing: 1 },
46
+ }
47
+ const made = new WeakMap<Kit, Set<string>>()
48
+ /** One of the display styles, made on first use. */
49
+ export function display(k: Kit, name: keyof typeof DISPLAY): string {
50
+ const set = made.get(k) ?? new Set<string>()
51
+ made.set(k, set)
52
+ if (!set.has(name)) { k.textStyle(name, DISPLAY[name]); set.add(name) }
53
+ return name
54
+ }
55
+
56
+ const BALLOON_WIDTHS: [string, number][] = [[' (small)', 96], ['', 128], [' (wide)', 168]]
57
+ const balloonFor = (w: number): [string, number] => BALLOON_WIDTHS.find(([, bw]) => w <= bw) ?? BALLOON_WIDTHS[BALLOON_WIDTHS.length - 1]
58
+
59
+ // ─── Geometry ─────────────────────────────────────────────────────────────────────────────────────────────────────
60
+
61
+ export const rectFrame = (x: number, y: number, width: number, height: number): Frame =>
62
+ ({ x, y, width, height, points: [{ x: 0, y: 0 }, { x: width, y: 0 }, { x: width, y: height }, { x: 0, y: height }] })
63
+
64
+ /** A frame from corners in tier space. */
65
+ export function frameOf(corners: Pt[]): Frame {
66
+ const xs = corners.map(p => p.x), ys = corners.map(p => p.y)
67
+ const x = Math.min(...xs), y = Math.min(...ys)
68
+ return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(...xs) - x), height: Math.round(Math.max(...ys) - y), points: corners.map(p => ({ x: Math.round(p.x - x), y: Math.round(p.y - y) })) }
69
+ }
70
+
71
+ export function shares(total: number, gutter: number, parts: number[]): number[] {
72
+ const sum = parts.reduce((a, b) => a + b, 0), free = total - gutter * (parts.length - 1)
73
+ const out = parts.map(p => Math.floor((free * p) / sum))
74
+ out[out.length - 1] = free - out.slice(0, -1).reduce((a, b) => a + b, 0)
75
+ return out
76
+ }
77
+
78
+ /** Rectangles side by side. */
79
+ export function tier(width: number, height: number, parts: number[], gutter = 10): Frame[] {
80
+ let x = 0
81
+ return shares(width, gutter, parts).map(w => { const f = rectFrame(x, 0, w, height); x += w + gutter; return f })
82
+ }
83
+
84
+ /** Frames side by side on gutters that lean `lean` px. */
85
+ export function slantTier(width: number, height: number, parts: number[], lean = 16, gutter = 10): Frame[] {
86
+ const widths = shares(width, gutter, parts), edges: number[] = [0]
87
+ widths.forEach((w, i) => edges.push(edges[i] + w + (i < widths.length - 1 ? gutter : 0)))
88
+ return widths.map((_, i) => {
89
+ const first = i === 0, last = i === widths.length - 1
90
+ const lTop = first ? 0 : edges[i] + lean, lBot = first ? 0 : edges[i] - lean
91
+ const rTop = last ? width : edges[i + 1] - gutter + lean, rBot = last ? width : edges[i + 1] - gutter - lean
92
+ return frameOf([{ x: lTop, y: 0 }, { x: rTop, y: 0 }, { x: rBot, y: height }, { x: lBot, y: height }])
93
+ })
94
+ }
95
+
96
+ /** A sheet as a column of tiers. Returns the live area. */
97
+ export function pageGrid(k: Kit, sheet: string, margin: number, gutter: number): { W: number; H: number } {
98
+ k.flowSheet(sheet, gutter, margin)
99
+ const n = k.node(sheet)
100
+ return { W: n.width - margin * 2, H: n.height - margin * 2 - FOLIO - gutter }
101
+ }
102
+
103
+ /** The folio line under the page's last tier: the series and the page's own title, on the paper. */
104
+ const FOLIO = 14
105
+ export function folio(k: Kit, sheet: string, label: string) {
106
+ const row = k.stack(sheet, { width: 'FILL', height: FOLIO, direction: 'HORIZONTAL', justify: 'SPACE_BETWEEN', align: 'CENTER', name: 'Folio' })
107
+ k.text(row, STORY.title, { style: display(k, 'Credit'), color: 'text', name: 'Folio title' })
108
+ k.text(row, label, { style: display(k, 'Credit'), color: 'onPaper', name: 'Folio label' })
109
+ }
110
+
111
+ export const tierBox = (k: Kit, sheet: string, W: number, h: number, name: string, fill?: string) =>
112
+ k.stack(sheet, { width: W, height: h, name, ...(fill ? { fill } : {}) })
113
+
114
+ export const rows = (H: number, gutter: number, parts: number[]) => shares(H, gutter, parts)
115
+
116
+ /** A Ben-Day screen: the one flashback page's second plate. */
117
+ const TONE: Effect[] = [{ id: 'e1', type: 'HALFTONE', angle: 45, dotSize: 7, contrast: 0.5, colorMode: 'SOURCE', shape: 'CIRCLE', layout: 'UNIFORM', toneSource: 'LINEAR_GRADIENT', gradientAngle: 90, center: { x: 0.5, y: 0.5 }, visible: true } as Effect]
118
+
119
+ /** A panel at frame `f`: a mask group of [shape as mask, ground, art, (tone), ink edge]. */
120
+ export function panel(k: Kit, tierId: string, f: Frame, art: IndieArtId, o: { ground: keyof typeof GROUNDS; name: string; tone?: boolean; keyline?: boolean }): string {
121
+ if (o.keyline) k.rect(tierId, f.width + 12, f.height + 12, 'paper', { name: 'Keyline', absolute: { x: f.x - 6, y: f.y - 6 } })
122
+ const id = k.group(tierId, { x: f.x, y: f.y, width: f.width, height: f.height }, { name: o.name, absolute: true })
123
+ k.patch(id, { isMaskGroup: true, isPhotoFrame: true })
124
+ const shape: VectorPath = { closed: true, points: f.points.map(p => ({ x: p.x, y: p.y })) }
125
+ k.vector(id, f.width, f.height, [shape], 'paper', { name: 'Panel mask', mask: true })
126
+ k.rect(id, f.width, f.height, o.ground, { name: 'Ground' })
127
+ k.image(id, indieArt(art), f.width, f.height, { name: `${o.name} art` })
128
+ if (o.tone) { const t = k.rect(id, f.width, f.height, 'g3', { name: 'Ben-Day screen', effects: TONE, opacity: 0.35 }); k.patch(t, { blendMode: 'MULTIPLY' }) }
129
+ k.vector(id, f.width, f.height, [shape], [], { name: 'Panel edge', stroke: { color: 'ink', width: 2.5, join: 'ROUND' } })
130
+ return id
131
+ }
132
+
133
+ // ─── Lettering ────────────────────────────────────────────────────────────────────────────────────────────────────
134
+
135
+ /** A speech balloon at (x, y) in the tier, its tail pointing toward `to` (the speaker, in tier space). */
136
+ export function say(k: Kit, tierId: string, words: string, o: { x: number; y: number; width?: number; to?: Pt; name?: string }) {
137
+ const [suffix, w] = balloonFor(o.width ?? 128)
138
+ if (o.to) {
139
+ const root = { x: o.x + w * 0.5 + (o.to.x > o.x + w / 2 ? w * 0.12 : -w * 0.12), y: o.y + 16 }
140
+ const lines = Math.ceil((words.length * 6.2) / (w - 26)), foot = o.y + 22 + lines * 14
141
+ const dist = Math.hypot(o.to.x - root.x, o.to.y - root.y) || 1, reach = Math.min(dist, Math.max(foot - root.y + 22, 30))
142
+ const to = { x: root.x + ((o.to.x - root.x) * reach) / dist, y: root.y + ((o.to.y - root.y) * reach) / dist }
143
+ const dx = to.x - root.x, dy = to.y - root.y, len = Math.hypot(dx, dy) || 1, nx = -dy / len, ny = dx / len, half = 8
144
+ const pts = [{ x: root.x + nx * half, y: root.y + ny * half }, to, { x: root.x - nx * half, y: root.y - ny * half }]
145
+ const x0 = Math.min(...pts.map(p => p.x)), y0 = Math.min(...pts.map(p => p.y))
146
+ k.vector(tierId, Math.max(1, Math.max(...pts.map(p => p.x)) - x0), Math.max(1, Math.max(...pts.map(p => p.y)) - y0),
147
+ [{ closed: true, points: pts.map(p => ({ x: p.x - x0, y: p.y - y0 })) }], 'card', { name: 'Balloon tail', stroke: { color: 'onCard', width: 2, join: 'ROUND' }, absolute: { x: x0, y: y0 } })
148
+ }
149
+ return k.instance(tierId, `Speech balloon${suffix}`, { Text: words }, { absolute: { x: Math.round(o.x), y: Math.round(o.y) }, name: o.name ?? 'Balloon' })
150
+ }
151
+
152
+ /** A caption box: the narrator. */
153
+ export function caption(k: Kit, tierId: string, words: string, o: { x: number; y: number; width?: number; name?: string }) {
154
+ const id = k.stack(tierId, { width: o.width ?? 200, padding: { top: 6, right: 9, bottom: 7, left: 9 }, fill: 'accent', stroke: { color: 'onAccent', width: 1.5, align: 'INSIDE' }, name: o.name ?? 'Caption box', absolute: { x: Math.round(o.x), y: Math.round(o.y) } })
155
+ k.text(id, words, { style: 'Caption', color: 'onAccent', width: 'FILL', name: 'Caption words' })
156
+ return id
157
+ }
158
+
159
+ /** A small sound effect on a soft impact star. */
160
+ export function sfx(k: Kit, tierId: string, words: string, o: { x: number; y: number; w: number; h: number; deg?: number; seed?: number; name?: string }) {
161
+ const id = k.stack(tierId, { width: o.w, height: o.h, align: 'CENTER', justify: 'CENTER', name: o.name ?? 'Sound effect', absolute: { x: Math.round(o.x), y: Math.round(o.y) } })
162
+ const n = 11, pts: Pt[] = []
163
+ let s = (o.seed ?? 7) >>> 0
164
+ const r = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 2 ** 32 }
165
+ for (let i = 0; i < n * 2; i++) {
166
+ const a = (i * Math.PI) / n - Math.PI / 2, rad = i % 2 ? 0.7 + r() * 0.06 : 0.92 + r() * 0.08
167
+ pts.push({ x: o.w / 2 + (o.w / 2) * rad * Math.cos(a), y: o.h / 2 + (o.h / 2) * rad * Math.sin(a) })
168
+ }
169
+ k.vector(id, o.w, o.h, [{ closed: true, points: pts }], 'accent', { name: 'Impact star', stroke: { color: 'onAccent', width: 2, join: 'ROUND' }, absolute: { x: 0, y: 0 } })
170
+ k.text(id, words, { style: display(k, 'SFX'), color: 'onAccent', align: 'CENTER', name: 'Sound effect words', autoWidth: true })
171
+ if (o.deg) k.patch(id, { rotation: o.deg })
172
+ return id
173
+ }
174
+
175
+ /** The meta every page of the story shares. */
176
+ export function indieMeta(name: string, description: string, tags: string[]) {
177
+ return {
178
+ name, description, category: 'comic', tags: ['comic', 'print', 'alder street', 'slice of life', 'indie', ...tags],
179
+ platforms: ['print' as const], formats: ['page' as const], useCases: ['comic' as const],
180
+ created: '2026-09-22', updated: '2026-10-10',
181
+ }
182
+ }