@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,371 @@
1
+ import { defineSchema } from "@stdout-design/cli/schema";
2
+ import { z } from "zod";
3
+
4
+ export const templateId = "retro-poster" as const;
5
+
6
+ export const propsSchema = defineSchema(
7
+ z.object({
8
+ body: z
9
+ .string()
10
+ .default(
11
+ "Ninety minutes, two sides, zero skips. Hand-picked tracks, hand-written labels, and a pencil for when the tape gets tangled."
12
+ )
13
+ .describe("Short paragraph beside the target mark"),
14
+ headline: z
15
+ .string()
16
+ .min(1)
17
+ .default("Mixtape Club")
18
+ .describe("Stacked headline in the bottom corner — one word per line"),
19
+ label: z
20
+ .string()
21
+ .default("Summer mix '89")
22
+ .describe("Handwritten title on the cassette label"),
23
+ locale: z
24
+ .string()
25
+ .optional()
26
+ .describe("Current locale code for RTL support"),
27
+ paper: z.string().default("#efe6d2").describe("Poster paper color"),
28
+ stamp: z
29
+ .string()
30
+ .default("Limited edition")
31
+ .describe("Rubber-stamp headline"),
32
+ stampNote: z
33
+ .string()
34
+ .default("No. 0042 / 5000")
35
+ .describe("Small line inside the stamp"),
36
+ tagline: z
37
+ .string()
38
+ .default("Analog since forever")
39
+ .describe("Small line under the headline"),
40
+ title: z
41
+ .string()
42
+ .default("Press Play")
43
+ .describe("Oversized title across the top"),
44
+ })
45
+ );
46
+
47
+ export type Props = z.infer<typeof propsSchema>;
48
+
49
+ const INK = "#1d1a17";
50
+ const SHELL = "#2e2b35";
51
+ const RUST = "#d9532c";
52
+ // Latin face first; Arabic glyphs fall through to the Arabic face.
53
+ const TITLE = "Anton, Lalezar, sans-serif";
54
+ const DISPLAY = "Archivo Black, Lalezar, sans-serif";
55
+ const SCRIPT = "Instrument Serif, Amiri, serif";
56
+ const MONO = "Space Mono, IBM Plex Sans Arabic, monospace";
57
+ const STRIPES = ["#7a2e3a", RUST, "#f0a830", "#2f8f83"];
58
+ const BARCODE = [
59
+ 3, 1, 2, 1, 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1,
60
+ ];
61
+ const BAR_UNIT = 1.6;
62
+ // Fixed, not fluid: on short canvases the stripes simply run off the
63
+ // bottom edge instead of the cassette squashing.
64
+ const CASSETTE_WIDTH = 420;
65
+ const CASSETTE_HEIGHT = 270;
66
+ const REEL_TEETH = [0, 60, 120, 180, 240, 300];
67
+
68
+ const Reel = ({ cx }: { cx: number }) => (
69
+ <g>
70
+ <circle cx={cx} cy="0" r="26" fill="#f6efe0" stroke={INK} strokeWidth="3" />
71
+ <circle cx={cx} cy="0" r="12" fill={SHELL} />
72
+ {REEL_TEETH.map((angle) => (
73
+ <rect
74
+ key={angle}
75
+ x={cx - 2.5}
76
+ y="-12"
77
+ width="5"
78
+ height="6"
79
+ fill="#f6efe0"
80
+ transform={`rotate(${angle} ${cx} 0)`}
81
+ />
82
+ ))}
83
+ </g>
84
+ );
85
+
86
+ const Cassette = ({ label, isRtl }: { label: string; isRtl: boolean }) => (
87
+ <div
88
+ tw="flex relative"
89
+ style={{
90
+ flexShrink: 0,
91
+ height: CASSETTE_HEIGHT,
92
+ transform: `rotate(${isRtl ? 6 : -6}deg)`,
93
+ width: CASSETTE_WIDTH,
94
+ }}
95
+ >
96
+ <svg
97
+ width={CASSETTE_WIDTH}
98
+ height={CASSETTE_HEIGHT}
99
+ viewBox="0 0 420 270"
100
+ style={{ left: 0, position: "absolute", top: 0 }}
101
+ >
102
+ {/* Offset print shadow, then the shell */}
103
+ <rect x="12" y="12" width="406" height="256" rx="22" fill={INK} />
104
+ <rect
105
+ x="2"
106
+ y="2"
107
+ width="406"
108
+ height="256"
109
+ rx="22"
110
+ fill={SHELL}
111
+ stroke={INK}
112
+ strokeWidth="4"
113
+ />
114
+ {[22, 386].map((x) => (
115
+ <g key={x}>
116
+ <circle cx={x} cy="22" r="6" fill="#1a181f" />
117
+ <circle cx={x} cy="238" r="6" fill="#1a181f" />
118
+ </g>
119
+ ))}
120
+ {/* Label */}
121
+ <rect x="34" y="28" width="342" height="150" rx="10" fill="#f6efe0" />
122
+ <rect x="34" y="28" width="342" height="26" rx="10" fill={RUST} />
123
+ <rect x="34" y="44" width="342" height="10" fill={RUST} />
124
+ {/* Window */}
125
+ <rect
126
+ x="110"
127
+ y="96"
128
+ width="190"
129
+ height="62"
130
+ rx="31"
131
+ fill={SHELL}
132
+ stroke={INK}
133
+ strokeWidth="3"
134
+ />
135
+ <rect x="150" y="112" width="110" height="30" fill="#6b5a4a" />
136
+ <g transform="translate(0 127)">
137
+ <Reel cx={142} />
138
+ <Reel cx={268} />
139
+ </g>
140
+ {/* Bottom bevel */}
141
+ <path
142
+ d="M86 258 L110 206 H300 L324 258 Z"
143
+ fill="#3a3642"
144
+ stroke={INK}
145
+ strokeWidth="3"
146
+ />
147
+ {[150, 182, 228, 260].map((x) => (
148
+ <circle key={x} cx={x} cy="232" r="6" fill="#1a181f" />
149
+ ))}
150
+ </svg>
151
+ {/* Text overlays sit outside the SVG so they use the loaded fonts. */}
152
+ <span
153
+ tw="absolute"
154
+ style={{
155
+ color: "#fff6e5",
156
+ fontFamily: DISPLAY,
157
+ fontSize: 18,
158
+ left: 46,
159
+ top: 29,
160
+ }}
161
+ >
162
+ A
163
+ </span>
164
+ <span
165
+ tw="absolute"
166
+ style={{
167
+ color: "#fff6e5",
168
+ fontFamily: MONO,
169
+ fontSize: 14,
170
+ right: 58,
171
+ top: 32,
172
+ }}
173
+ >
174
+ C-90
175
+ </span>
176
+ <div
177
+ tw="flex absolute justify-center"
178
+ style={{ left: 40, right: 50, top: 54 }}
179
+ >
180
+ <span
181
+ style={{
182
+ color: INK,
183
+ fontFamily: SCRIPT,
184
+ fontSize: 36,
185
+ fontStyle: isRtl ? "normal" : "italic",
186
+ lineHeight: 1.1,
187
+ }}
188
+ >
189
+ {label}
190
+ </span>
191
+ </div>
192
+ </div>
193
+ );
194
+
195
+ const Barcode = () => {
196
+ let x = 0;
197
+ const bars: { x: number; width: number }[] = [];
198
+ for (const [index, units] of BARCODE.entries()) {
199
+ if (index % 2 === 0) {
200
+ bars.push({ width: units * BAR_UNIT, x });
201
+ }
202
+ x += units * BAR_UNIT + 0.8;
203
+ }
204
+ return (
205
+ <svg width="120" height="34" viewBox="0 0 120 34">
206
+ {bars.map((bar) => (
207
+ <rect
208
+ key={bar.x}
209
+ x={bar.x}
210
+ y="0"
211
+ width={bar.width}
212
+ height="34"
213
+ fill={INK}
214
+ />
215
+ ))}
216
+ </svg>
217
+ );
218
+ };
219
+
220
+ const RetroPoster = ({
221
+ body,
222
+ headline,
223
+ label,
224
+ locale,
225
+ paper,
226
+ stamp,
227
+ stampNote,
228
+ tagline,
229
+ title,
230
+ }: Props) => {
231
+ const isRtl = locale?.startsWith("ar") ?? false;
232
+ // Flex rows mirror themselves under dir="rtl"; absolute offsets don't.
233
+ const start = isRtl ? "right" : "left";
234
+ const words = headline.split(/\s+/u).filter(Boolean);
235
+
236
+ return (
237
+ <div
238
+ lang={isRtl ? "ar" : undefined}
239
+ dir={isRtl ? "rtl" : "ltr"}
240
+ tw="flex flex-col w-full h-full px-12 pt-10 relative overflow-hidden"
241
+ style={{ backgroundColor: paper, color: INK, fontFamily: MONO }}
242
+ >
243
+ {/* Paper grain */}
244
+ <div
245
+ tw="absolute inset-0"
246
+ style={{
247
+ backgroundImage:
248
+ "radial-gradient(rgba(29,26,23,0.07) 1px, transparent 1.5px)",
249
+ backgroundSize: "7px 7px",
250
+ }}
251
+ />
252
+
253
+ <span
254
+ tw="relative"
255
+ style={{
256
+ fontFamily: TITLE,
257
+ fontSize: isRtl ? 104 : 124,
258
+ lineHeight: isRtl ? 1.1 : 0.9,
259
+ textTransform: "uppercase",
260
+ }}
261
+ >
262
+ {title}
263
+ </span>
264
+ <div
265
+ tw="flex relative my-5"
266
+ style={{ backgroundColor: INK, flexShrink: 0, height: 4 }}
267
+ />
268
+
269
+ {/* minHeight 0: the fixed-size cassette and stripes may overflow this
270
+ row, but must not stretch it (and the text column) past the canvas. */}
271
+ <div tw="flex flex-1 relative" style={{ gap: 40, minHeight: 0 }}>
272
+ {/* Tape stripes run out of the cassette and off the page */}
273
+ <div
274
+ tw="flex absolute"
275
+ style={{ bottom: 0, [start]: 120, top: CASSETTE_HEIGHT - 50 }}
276
+ >
277
+ {STRIPES.map((color) => (
278
+ <div
279
+ key={color}
280
+ tw="h-full w-10"
281
+ style={{ backgroundColor: color }}
282
+ />
283
+ ))}
284
+ </div>
285
+
286
+ <div tw="flex pt-2">
287
+ <Cassette label={label} isRtl={isRtl} />
288
+ </div>
289
+
290
+ <div tw="flex flex-col flex-1 justify-between items-end pb-8 relative">
291
+ <div tw="flex items-start" style={{ gap: 28 }}>
292
+ <div
293
+ tw="flex rounded-full"
294
+ style={{
295
+ backgroundImage: `repeating-radial-gradient(circle, ${INK} 0 8px, transparent 8px 16px)`,
296
+ flexShrink: 0,
297
+ height: 104,
298
+ width: 104,
299
+ }}
300
+ />
301
+ <div
302
+ tw="flex text-base"
303
+ style={{
304
+ [isRtl ? "borderRight" : "borderLeft"]: `3px solid ${INK}`,
305
+ [isRtl ? "paddingRight" : "paddingLeft"]: 20,
306
+ lineHeight: 1.55,
307
+ width: 330,
308
+ }}
309
+ >
310
+ {body}
311
+ </div>
312
+ </div>
313
+
314
+ <div
315
+ tw="flex flex-col absolute px-5 py-3"
316
+ style={{
317
+ border: `4px solid ${RUST}`,
318
+ color: RUST,
319
+ opacity: 0.92,
320
+ [start]: 20,
321
+ top: "40%",
322
+ transform: `rotate(${isRtl ? 8 : -8}deg)`,
323
+ }}
324
+ >
325
+ <span
326
+ style={{
327
+ fontFamily: DISPLAY,
328
+ fontSize: 30,
329
+ lineHeight: 1.1,
330
+ textTransform: "uppercase",
331
+ }}
332
+ >
333
+ {stamp}
334
+ </span>
335
+ <span
336
+ tw="text-base mt-1"
337
+ style={{ letterSpacing: isRtl ? 0 : "0.2em" }}
338
+ >
339
+ {stampNote}
340
+ </span>
341
+ </div>
342
+
343
+ <div tw="flex flex-col items-end">
344
+ {words.map((word, index) => (
345
+ <span
346
+ key={`${word}-${index}`}
347
+ style={{
348
+ fontFamily: DISPLAY,
349
+ fontSize: isRtl ? 84 : 92,
350
+ letterSpacing: isRtl ? 0 : "-0.04em",
351
+ lineHeight: isRtl ? 1.15 : 0.9,
352
+ textTransform: "uppercase",
353
+ }}
354
+ >
355
+ {word}
356
+ </span>
357
+ ))}
358
+ <div tw="flex items-center mt-4" style={{ gap: 20 }}>
359
+ <Barcode />
360
+ <span tw="text-2xl" style={{ fontWeight: 700 }}>
361
+ {tagline}
362
+ </span>
363
+ </div>
364
+ </div>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ );
369
+ };
370
+
371
+ export default RetroPoster;