@stdout-design/cli 0.5.0 → 0.7.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.
@@ -0,0 +1,362 @@
1
+ import { defineSchema } from "@stdout-design/cli/schema";
2
+ import { z } from "zod";
3
+
4
+ export const templateId = "launch-hero" as const;
5
+
6
+ export const propsSchema = defineSchema(
7
+ z.object({
8
+ accentColor: z
9
+ .string()
10
+ .default("#2b3bff")
11
+ .describe("Page background color"),
12
+ badge: z
13
+ .string()
14
+ .default("Drop 07")
15
+ .describe("Text on the starburst sticker"),
16
+ brand: z.string().default("OFFBEAT®").describe("Wordmark in the nav bar"),
17
+ categories: z
18
+ .array(z.string())
19
+ .default(["Tees", "Hoodies", "Caps"])
20
+ .describe("Three category card labels"),
21
+ cta: z.string().default("Shop now").describe("Primary button label"),
22
+ headline: z
23
+ .string()
24
+ .min(1)
25
+ .default("Wear It Loud")
26
+ .describe("Hero headline — each word gets its own line"),
27
+ links: z
28
+ .array(z.string())
29
+ .default(["Drops", "Archive", "Club"])
30
+ .describe("Nav bar links"),
31
+ locale: z
32
+ .string()
33
+ .optional()
34
+ .describe("Current locale code for RTL support"),
35
+ note: z
36
+ .string()
37
+ .default("Ships worldwide")
38
+ .describe("Small note at the end of the stats strip"),
39
+ stats: z
40
+ .array(z.string())
41
+ .default(["12 drops a year", "0 restocks", "100% cotton"])
42
+ .describe("Stats strip — the first word is the big number"),
43
+ subheadline: z
44
+ .string()
45
+ .default("Small-batch streetwear, printed in a city that never whispers.")
46
+ .describe("Supporting line under the headline"),
47
+ })
48
+ );
49
+
50
+ export type Props = z.infer<typeof propsSchema>;
51
+
52
+ const INK = "#0e0e12";
53
+ const CREAM = "#fff6e5";
54
+ const PINK = "#ff4fa3";
55
+ const LIME = "#c8f560";
56
+ // Latin face first; Arabic glyphs fall through to the Arabic face.
57
+ const DISPLAY = "Archivo Black, Lalezar, sans-serif";
58
+ const MONO = "Space Mono, IBM Plex Sans Arabic, monospace";
59
+ const CARD_STYLES = [
60
+ { art: "bolt", color: CREAM, tilt: -5 },
61
+ { art: "spiral", color: PINK, tilt: 4 },
62
+ { art: "eye", color: LIME, tilt: -2 },
63
+ ] as const;
64
+
65
+ type Art = (typeof CARD_STYLES)[number]["art"];
66
+
67
+ const starburst = (spikes: number, outer: number, inner: number): string =>
68
+ Array.from({ length: spikes * 2 }, (_, index) => {
69
+ const radius = index % 2 === 0 ? outer : inner;
70
+ const angle = (Math.PI * index) / spikes - Math.PI / 2;
71
+ return `${50 + radius * Math.cos(angle)},${50 + radius * Math.sin(angle)}`;
72
+ }).join(" ");
73
+
74
+ // Arrows and sparkles are drawn, not typed: display faces rarely ship
75
+ // those glyphs, and a missing glyph renders as tofu.
76
+ const Arrow = ({ color, flip }: { color: string; flip: boolean }) => (
77
+ <svg
78
+ width="22"
79
+ height="22"
80
+ viewBox="0 0 24 24"
81
+ fill="none"
82
+ style={{ transform: flip ? "scaleX(-1)" : "none" }}
83
+ >
84
+ <path
85
+ d="M4 12h15M13 5l7 7-7 7"
86
+ stroke={color}
87
+ strokeWidth="3"
88
+ strokeLinecap="round"
89
+ strokeLinejoin="round"
90
+ />
91
+ </svg>
92
+ );
93
+
94
+ const Sparkle = ({ color }: { color: string }) => (
95
+ <svg width="18" height="18" viewBox="0 0 24 24">
96
+ <path d="M12 0 15 9 24 12 15 15 12 24 9 15 0 12 9 9Z" fill={color} />
97
+ </svg>
98
+ );
99
+
100
+ const CardArt = ({ art }: { art: Art }) => {
101
+ if (art === "bolt") {
102
+ return (
103
+ <svg width="110" height="130" viewBox="0 0 80 100">
104
+ <path
105
+ d="M48 2 8 58h26l-6 40 44-60H44Z"
106
+ fill={LIME}
107
+ stroke={INK}
108
+ strokeWidth="5"
109
+ strokeLinejoin="round"
110
+ />
111
+ </svg>
112
+ );
113
+ }
114
+ if (art === "spiral") {
115
+ return (
116
+ <svg width="130" height="130" viewBox="0 0 100 100">
117
+ <path
118
+ d="M50 50m0 0a6 6 0 0 1 12 0a12 12 0 0 1-24 0a18 18 0 0 1 36 0a24 24 0 0 1-48 0a30 30 0 0 1 60 0a36 36 0 0 1-72 0"
119
+ fill="none"
120
+ stroke={INK}
121
+ strokeWidth="6"
122
+ strokeLinecap="round"
123
+ />
124
+ </svg>
125
+ );
126
+ }
127
+ return (
128
+ <svg width="140" height="100" viewBox="0 0 140 100">
129
+ <path
130
+ d="M6 50Q70-14 134 50Q70 114 6 50Z"
131
+ fill={CREAM}
132
+ stroke={INK}
133
+ strokeWidth="6"
134
+ />
135
+ <circle
136
+ cx="70"
137
+ cy="50"
138
+ r="22"
139
+ fill="#2b3bff"
140
+ stroke={INK}
141
+ strokeWidth="5"
142
+ />
143
+ <circle cx="70" cy="50" r="9" fill={INK} />
144
+ </svg>
145
+ );
146
+ };
147
+
148
+ const LaunchHero = ({
149
+ accentColor,
150
+ badge,
151
+ brand,
152
+ categories,
153
+ cta,
154
+ headline,
155
+ links,
156
+ locale,
157
+ note,
158
+ stats,
159
+ subheadline,
160
+ }: Props) => {
161
+ const isRtl = locale?.startsWith("ar") ?? false;
162
+ // Flex rows mirror themselves under dir="rtl"; absolute offsets don't.
163
+ const start = isRtl ? "right" : "left";
164
+ const end = isRtl ? "left" : "right";
165
+ const marginStart = isRtl ? "marginRight" : "marginLeft";
166
+ const words = headline.split(/\s+/u).filter(Boolean);
167
+
168
+ return (
169
+ <div
170
+ lang={isRtl ? "ar" : undefined}
171
+ dir={isRtl ? "rtl" : "ltr"}
172
+ tw="flex flex-col w-full h-full overflow-hidden relative"
173
+ style={{ backgroundColor: accentColor, color: CREAM, fontFamily: MONO }}
174
+ >
175
+ {/* Halftone corner */}
176
+ <div
177
+ tw="absolute"
178
+ style={{
179
+ backgroundImage: `radial-gradient(${INK} 30%, transparent 32%)`,
180
+ backgroundSize: "18px 18px",
181
+ bottom: -60,
182
+ [end]: -60,
183
+ height: 460,
184
+ opacity: 0.22,
185
+ width: 560,
186
+ }}
187
+ />
188
+
189
+ {/* Nav */}
190
+ <div
191
+ tw="flex items-center justify-between px-12 py-6"
192
+ style={{ borderBottom: `3px solid ${CREAM}` }}
193
+ >
194
+ {/* Pinned LTR so bidi doesn't move the ® to the front in Arabic. */}
195
+ <span dir="ltr" style={{ fontFamily: DISPLAY, fontSize: 38 }}>
196
+ {brand}
197
+ </span>
198
+ <div tw="flex items-center text-xl" style={{ gap: 32 }}>
199
+ {links.map((link) => (
200
+ <span key={link}>{link}</span>
201
+ ))}
202
+ <div
203
+ tw="flex items-center px-6 py-3 rounded-full text-xl"
204
+ style={{
205
+ backgroundColor: CREAM,
206
+ color: INK,
207
+ fontWeight: 700,
208
+ gap: 12,
209
+ }}
210
+ >
211
+ <span>{cta}</span>
212
+ <Arrow color={INK} flip={isRtl} />
213
+ </div>
214
+ </div>
215
+ </div>
216
+
217
+ {/* Hero */}
218
+ <div tw="flex flex-1 px-12 py-8 relative" style={{ minHeight: 0 }}>
219
+ <div tw="flex flex-col justify-center flex-1">
220
+ {words.map((word, index) => (
221
+ <span
222
+ key={`${word}-${index}`}
223
+ style={{
224
+ WebkitTextStroke: `3px ${INK}`,
225
+ color: index % 2 === 1 ? PINK : CREAM,
226
+ fontFamily: DISPLAY,
227
+ fontSize: isRtl ? 112 : 132,
228
+ letterSpacing: isRtl ? 0 : "-0.03em",
229
+ lineHeight: isRtl ? 1.02 : 0.95,
230
+ [marginStart]: index * 36,
231
+ textShadow: `7px 7px 0 ${INK}`,
232
+ textTransform: "uppercase",
233
+ }}
234
+ >
235
+ {word}
236
+ </span>
237
+ ))}
238
+ <div
239
+ tw="flex text-2xl mt-7"
240
+ style={{ lineHeight: 1.4, maxWidth: 600 }}
241
+ >
242
+ {subheadline}
243
+ </div>
244
+ </div>
245
+
246
+ {/* Collage */}
247
+ <div tw="flex w-[36%] relative">
248
+ {categories.slice(0, CARD_STYLES.length).map((category, index) => {
249
+ const style = CARD_STYLES[index] ?? CARD_STYLES[0];
250
+ return (
251
+ <div
252
+ key={category}
253
+ tw="flex flex-col justify-between absolute rounded-3xl p-6"
254
+ style={{
255
+ backgroundColor: style.color,
256
+ border: `3px solid ${INK}`,
257
+ boxShadow: `9px 9px 0 ${INK}`,
258
+ color: INK,
259
+ height: "38%",
260
+ [start]: index % 2 === 0 ? 10 : 90,
261
+ top: `${2 + index * 30}%`,
262
+ transform: `rotate(${isRtl ? -style.tilt : style.tilt}deg)`,
263
+ width: 270,
264
+ }}
265
+ >
266
+ <div tw="flex items-start justify-between" style={{ gap: 12 }}>
267
+ <span
268
+ style={{
269
+ fontFamily: DISPLAY,
270
+ fontSize: 44,
271
+ lineHeight: 1.1,
272
+ }}
273
+ >
274
+ {category}
275
+ </span>
276
+ <span tw="text-lg">{`0${index + 1}`}</span>
277
+ </div>
278
+ <div tw="flex justify-end">
279
+ <CardArt art={style.art} />
280
+ </div>
281
+ </div>
282
+ );
283
+ })}
284
+ </div>
285
+ <div
286
+ tw="flex absolute items-center justify-center"
287
+ style={{
288
+ height: 170,
289
+ [start]: "50%",
290
+ top: 18,
291
+ transform: `rotate(${isRtl ? -12 : 12}deg)`,
292
+ width: 170,
293
+ }}
294
+ >
295
+ <svg
296
+ width="170"
297
+ height="170"
298
+ viewBox="0 0 100 100"
299
+ style={{ left: 0, position: "absolute", top: 0 }}
300
+ >
301
+ <polygon
302
+ points={starburst(16, 49, 39)}
303
+ fill={LIME}
304
+ stroke={INK}
305
+ strokeWidth="2.5"
306
+ />
307
+ </svg>
308
+ {/* Absolute so it paints above the (absolute) starburst. */}
309
+ <div tw="flex absolute inset-0 items-center justify-center">
310
+ <span
311
+ tw="text-center"
312
+ style={{
313
+ color: INK,
314
+ fontFamily: DISPLAY,
315
+ fontSize: 28,
316
+ lineHeight: 1.05,
317
+ maxWidth: 110,
318
+ }}
319
+ >
320
+ {badge}
321
+ </span>
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
+ {/* Stats strip */}
327
+ <div
328
+ tw="flex items-center px-12 py-5"
329
+ style={{ backgroundColor: INK, gap: 48 }}
330
+ >
331
+ {stats.map((stat) => {
332
+ const [value = "", ...label] = stat.split(" ");
333
+ return (
334
+ <div key={stat} tw="flex items-end" style={{ gap: 12 }}>
335
+ <span
336
+ style={{ fontFamily: DISPLAY, fontSize: 40, lineHeight: 1.1 }}
337
+ >
338
+ {value}
339
+ </span>
340
+ <span tw="text-lg mb-1" style={{ opacity: 0.7 }}>
341
+ {label.join(" ")}
342
+ </span>
343
+ </div>
344
+ );
345
+ })}
346
+ <div
347
+ tw="flex items-center text-lg"
348
+ style={{
349
+ color: LIME,
350
+ gap: 8,
351
+ [marginStart]: "auto",
352
+ }}
353
+ >
354
+ <Sparkle color={LIME} />
355
+ <span>{note.toUpperCase()}</span>
356
+ </div>
357
+ </div>
358
+ </div>
359
+ );
360
+ };
361
+
362
+ export default LaunchHero;