@stdout-design/cli 0.6.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,383 @@
1
+ import { defineSchema } from "@stdout-design/cli/schema";
2
+ import { z } from "zod";
3
+
4
+ export const templateId = "trend-poster" as const;
5
+
6
+ export const propsSchema = defineSchema(
7
+ z.object({
8
+ aside: z
9
+ .string()
10
+ .default("to steal & remix")
11
+ .describe("Serif aside next to the edition"),
12
+ cta: z
13
+ .string()
14
+ .default("Save for later")
15
+ .describe("Button label in the footer"),
16
+ edition: z
17
+ .string()
18
+ .default("2027")
19
+ .describe("Big outlined word under the headline"),
20
+ footer: z
21
+ .string()
22
+ .default("Make the thumb stop.")
23
+ .describe("Serif line in the footer"),
24
+ headline: z
25
+ .string()
26
+ .min(1)
27
+ .default("Ways to stop the scroll")
28
+ .describe("Headline — prefixed with the number of items"),
29
+ highlight: z
30
+ .string()
31
+ .default("#ff6a3d")
32
+ .describe("Highlight color for the edition and button"),
33
+ issue: z.string().default("Issue 07").describe("Small label top-start"),
34
+ items: z
35
+ .array(z.string())
36
+ .default([
37
+ "Loud type",
38
+ "Halftone",
39
+ "Clashing color",
40
+ "Raw collage",
41
+ "Stickers",
42
+ "Big shapes",
43
+ ])
44
+ .describe("Six item names, one per grid cell"),
45
+ locale: z
46
+ .string()
47
+ .optional()
48
+ .describe("Current locale code for RTL support"),
49
+ strap: z
50
+ .string()
51
+ .default("A field guide for the feed")
52
+ .describe("Small line top-end"),
53
+ })
54
+ );
55
+
56
+ export type Props = z.infer<typeof propsSchema>;
57
+
58
+ const INK = "#121212";
59
+ const PAPER = "#efece4";
60
+ const COBALT = "#3046ff";
61
+ // Latin face first; Arabic glyphs fall through to the Arabic face.
62
+ const TITLE = "Anton, Lalezar, sans-serif";
63
+ const HEAVY = "Archivo Black, Lalezar, sans-serif";
64
+ const SERIF = "Instrument Serif, Amiri, serif";
65
+ const MONO = "Space Mono, IBM Plex Sans Arabic, monospace";
66
+ const CELLS_PER_ROW = 3;
67
+ const SPECIMEN_SIZE = 120;
68
+
69
+ const starburst = (spikes: number, outer: number, inner: number): string =>
70
+ Array.from({ length: spikes * 2 }, (_, index) => {
71
+ const radius = index % 2 === 0 ? outer : inner;
72
+ const angle = (Math.PI * index) / spikes - Math.PI / 2;
73
+ return `${50 + radius * Math.cos(angle)},${50 + radius * Math.sin(angle)}`;
74
+ }).join(" ");
75
+
76
+ const Sparkle = ({ color }: { color: string }) => (
77
+ <svg width="16" height="16" viewBox="0 0 24 24">
78
+ <path d="M12 0 15 9 24 12 15 15 12 24 9 15 0 12 9 9Z" fill={color} />
79
+ </svg>
80
+ );
81
+
82
+ /** One small specimen per cell, picked by position so any six labels work. */
83
+ const Specimen = ({
84
+ highlight,
85
+ index,
86
+ }: {
87
+ highlight: string;
88
+ index: number;
89
+ }) => {
90
+ switch (index % 6) {
91
+ case 0: {
92
+ return (
93
+ <div
94
+ tw="flex items-center justify-center rounded-full"
95
+ style={{
96
+ backgroundColor: COBALT,
97
+ height: SPECIMEN_SIZE,
98
+ width: SPECIMEN_SIZE,
99
+ }}
100
+ >
101
+ <span
102
+ style={{
103
+ color: PAPER,
104
+ fontFamily: HEAVY,
105
+ fontSize: 60,
106
+ lineHeight: 1,
107
+ }}
108
+ >
109
+ Aa
110
+ </span>
111
+ </div>
112
+ );
113
+ }
114
+ case 1: {
115
+ return (
116
+ <div
117
+ tw="flex"
118
+ style={{
119
+ backgroundImage: `radial-gradient(${INK} 34%, transparent 36%)`,
120
+ backgroundSize: "10px 10px",
121
+ borderRadius: "60px 60px 0 60px",
122
+ height: SPECIMEN_SIZE,
123
+ width: SPECIMEN_SIZE,
124
+ }}
125
+ />
126
+ );
127
+ }
128
+ case 2: {
129
+ return (
130
+ <svg width={SPECIMEN_SIZE} height={SPECIMEN_SIZE} viewBox="0 0 100 100">
131
+ <rect width="100" height="50" fill={highlight} />
132
+ <rect y="50" width="100" height="50" fill={COBALT} />
133
+ <circle
134
+ cx="50"
135
+ cy="50"
136
+ r="24"
137
+ fill="#f5c3ff"
138
+ stroke={INK}
139
+ strokeWidth="3"
140
+ />
141
+ </svg>
142
+ );
143
+ }
144
+ case 3: {
145
+ return (
146
+ <div
147
+ tw="flex relative"
148
+ style={{ height: SPECIMEN_SIZE, width: SPECIMEN_SIZE + 20 }}
149
+ >
150
+ <div
151
+ tw="flex absolute"
152
+ style={{
153
+ backgroundColor: COBALT,
154
+ height: 70,
155
+ left: 0,
156
+ top: 10,
157
+ transform: "rotate(-8deg)",
158
+ width: 90,
159
+ }}
160
+ />
161
+ <div
162
+ tw="flex absolute"
163
+ style={{
164
+ backgroundColor: "#fff",
165
+ border: `2px solid ${INK}`,
166
+ height: 70,
167
+ left: 40,
168
+ top: 40,
169
+ transform: "rotate(6deg)",
170
+ width: 90,
171
+ }}
172
+ />
173
+ <div
174
+ tw="flex absolute"
175
+ style={{
176
+ backgroundColor: highlight,
177
+ height: 18,
178
+ left: 60,
179
+ top: 32,
180
+ transform: "rotate(-20deg)",
181
+ width: 50,
182
+ }}
183
+ />
184
+ </div>
185
+ );
186
+ }
187
+ case 4: {
188
+ return (
189
+ <svg width={SPECIMEN_SIZE} height={SPECIMEN_SIZE} viewBox="0 0 100 100">
190
+ <polygon
191
+ points={starburst(10, 48, 30)}
192
+ fill={highlight}
193
+ stroke={INK}
194
+ strokeWidth="3"
195
+ />
196
+ <circle
197
+ cx="50"
198
+ cy="50"
199
+ r="13"
200
+ fill={PAPER}
201
+ stroke={INK}
202
+ strokeWidth="3"
203
+ />
204
+ </svg>
205
+ );
206
+ }
207
+ default: {
208
+ return (
209
+ <svg width={SPECIMEN_SIZE} height={SPECIMEN_SIZE} viewBox="0 0 100 100">
210
+ <path d="M0 100 V50 A50 50 0 0 1 50 0 H100 V100Z" fill={INK} />
211
+ <circle cx="66" cy="66" r="20" fill={highlight} />
212
+ </svg>
213
+ );
214
+ }
215
+ }
216
+ };
217
+
218
+ const TrendPoster = ({
219
+ aside,
220
+ cta,
221
+ edition,
222
+ footer,
223
+ headline,
224
+ highlight,
225
+ issue,
226
+ items,
227
+ locale,
228
+ strap,
229
+ }: Props) => {
230
+ const isRtl = locale?.startsWith("ar") ?? false;
231
+ // Flex rows mirror themselves under dir="rtl"; absolute offsets don't.
232
+ const end = isRtl ? "left" : "right";
233
+ const rows = [
234
+ items.slice(0, CELLS_PER_ROW),
235
+ items.slice(CELLS_PER_ROW, CELLS_PER_ROW * 2),
236
+ ];
237
+
238
+ return (
239
+ <div
240
+ lang={isRtl ? "ar" : undefined}
241
+ dir={isRtl ? "rtl" : "ltr"}
242
+ tw="flex flex-col w-full h-full px-12 py-10"
243
+ style={{ backgroundColor: PAPER, color: INK, fontFamily: MONO }}
244
+ >
245
+ <div
246
+ tw="flex items-center justify-between text-lg mb-3"
247
+ style={{ flexShrink: 0 }}
248
+ >
249
+ <div tw="flex items-center" style={{ gap: 12 }}>
250
+ <span style={{ fontWeight: 700 }}>STDOUT</span>
251
+ <Sparkle color={INK} />
252
+ <span>{issue.toUpperCase()}</span>
253
+ </div>
254
+ <span>{strap.toUpperCase()}</span>
255
+ </div>
256
+
257
+ <div tw="flex flex-col" style={{ flexShrink: 0 }}>
258
+ <span
259
+ style={{
260
+ fontFamily: TITLE,
261
+ fontSize: isRtl ? 92 : 96,
262
+ lineHeight: isRtl ? 1.2 : 0.9,
263
+ textTransform: "uppercase",
264
+ }}
265
+ >
266
+ {`${items.length} ${headline}`}
267
+ </span>
268
+ <div tw="flex items-end" style={{ gap: 24 }}>
269
+ <span
270
+ style={{
271
+ WebkitTextStroke: `3px ${INK}`,
272
+ color: highlight,
273
+ fontFamily: HEAVY,
274
+ fontSize: 104,
275
+ letterSpacing: isRtl ? 0 : "-0.04em",
276
+ lineHeight: 1,
277
+ textShadow: `5px 5px 0 ${INK}`,
278
+ }}
279
+ >
280
+ {edition}
281
+ </span>
282
+ <span
283
+ tw="mb-3"
284
+ style={{
285
+ fontFamily: SERIF,
286
+ fontSize: 42,
287
+ fontStyle: isRtl ? "normal" : "italic",
288
+ }}
289
+ >
290
+ {aside}
291
+ </span>
292
+ </div>
293
+ </div>
294
+
295
+ <div tw="flex flex-col flex-1 mt-5" style={{ minHeight: 0 }}>
296
+ {rows.map((row, rowIndex) => (
297
+ <div
298
+ key={`row-${rowIndex}`}
299
+ tw="flex flex-1"
300
+ style={{ borderTop: `3px solid ${INK}`, minHeight: 0 }}
301
+ >
302
+ {row.map((item, cellIndex) => {
303
+ const index = rowIndex * CELLS_PER_ROW + cellIndex;
304
+ return (
305
+ <div
306
+ key={item}
307
+ tw="flex flex-col flex-1 pt-3 relative overflow-hidden"
308
+ style={{
309
+ [isRtl ? "borderRight" : "borderLeft"]:
310
+ cellIndex === 0 ? "none" : `2px solid ${INK}`,
311
+ [isRtl ? "paddingRight" : "paddingLeft"]:
312
+ cellIndex === 0 ? 0 : 18,
313
+ }}
314
+ >
315
+ {/* Anchored to the bottom corner: short canvases crop it
316
+ like a print bleed. Rendered first so the label paints
317
+ on top if they meet. */}
318
+ <div tw="flex absolute" style={{ bottom: 14, [end]: 18 }}>
319
+ <Specimen highlight={highlight} index={index} />
320
+ </div>
321
+ <div tw="flex items-baseline relative" style={{ gap: 12 }}>
322
+ <span tw="text-2xl" style={{ fontWeight: 700 }}>
323
+ {String(index + 1).padStart(2, "0")}
324
+ </span>
325
+ <span
326
+ tw="px-1"
327
+ style={{
328
+ backgroundColor: PAPER,
329
+ fontFamily: HEAVY,
330
+ fontSize: 20,
331
+ textTransform: "uppercase",
332
+ }}
333
+ >
334
+ {item}
335
+ </span>
336
+ </div>
337
+ </div>
338
+ );
339
+ })}
340
+ </div>
341
+ ))}
342
+ </div>
343
+
344
+ <div
345
+ tw="flex items-center justify-between pt-5"
346
+ style={{ borderTop: `3px solid ${INK}`, flexShrink: 0 }}
347
+ >
348
+ <span style={{ fontFamily: SERIF, fontSize: 40, lineHeight: 1.1 }}>
349
+ {footer}
350
+ </span>
351
+ <div
352
+ tw="flex items-center px-6 py-3 rounded-full"
353
+ style={{
354
+ backgroundColor: highlight,
355
+ border: `3px solid ${INK}`,
356
+ fontFamily: HEAVY,
357
+ fontSize: 20,
358
+ gap: 12,
359
+ }}
360
+ >
361
+ <span>{cta.toUpperCase()}</span>
362
+ <svg
363
+ width="22"
364
+ height="22"
365
+ viewBox="0 0 24 24"
366
+ fill="none"
367
+ style={{ transform: isRtl ? "scaleX(-1)" : "none" }}
368
+ >
369
+ <path
370
+ d="M4 12h15M13 5l7 7-7 7"
371
+ stroke={INK}
372
+ strokeWidth="3"
373
+ strokeLinecap="round"
374
+ strokeLinejoin="round"
375
+ />
376
+ </svg>
377
+ </div>
378
+ </div>
379
+ </div>
380
+ );
381
+ };
382
+
383
+ export default TrendPoster;