getraw 0.2.2 → 0.3.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,139 @@
1
+ import { analyzePlayerJs, getNsigProcessorFn } from "./js-analyzer";
2
+ import type { PlayerScriptResult } from "./js-analyzer";
3
+
4
+ const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36";
5
+
6
+ let cachedPlayerUrl: string | null = null;
7
+ let cachedScript: PlayerScriptResult | null = null;
8
+
9
+ async function getPlayerUrl(pageHtml?: string): Promise<string> {
10
+ if (cachedPlayerUrl) return cachedPlayerUrl;
11
+
12
+ if (pageHtml) {
13
+ const match = pageHtml.match(/\/s\/player\/([a-zA-Z0-9_-]+)\/[^"]+?base\.js/);
14
+ if (match) {
15
+ cachedPlayerUrl = `https://www.youtube.com${match[0]}`;
16
+ return cachedPlayerUrl;
17
+ }
18
+ }
19
+
20
+ const resp = await fetch("https://www.youtube.com/iframe_api", {
21
+ headers: { "User-Agent": USER_AGENT },
22
+ });
23
+ if (!resp.ok) throw new Error(`Failed to fetch iframe_api: ${resp.status}`);
24
+ const text = await resp.text();
25
+ const match = text.match(/player\\\/([a-zA-Z0-9_-]+)\\\//);
26
+ if (!match) throw new Error("Could not extract player ID from iframe_api");
27
+ cachedPlayerUrl = `https://www.youtube.com/s/player/${match[1]}/player_ias.vflset/en_US/base.js`;
28
+ return cachedPlayerUrl;
29
+ }
30
+
31
+ async function getPlayerScript(pageHtml?: string): Promise<PlayerScriptResult> {
32
+ if (cachedScript) return cachedScript;
33
+
34
+ const playerUrl = await getPlayerUrl(pageHtml);
35
+ const resp = await fetch(playerUrl, {
36
+ headers: { "User-Agent": USER_AGENT },
37
+ });
38
+ if (!resp.ok) throw new Error(`Failed to fetch player JS: ${resp.status}`);
39
+ const playerJs = await resp.text();
40
+
41
+ cachedScript = analyzePlayerJs(playerJs);
42
+ return cachedScript;
43
+ }
44
+
45
+ export async function getSignatureTimestamp(pageHtml?: string): Promise<number> {
46
+ const script = await getPlayerScript(pageHtml);
47
+ return script.signatureTimestamp;
48
+ }
49
+
50
+ async function evalPlayerScript(
51
+ script: PlayerScriptResult,
52
+ n?: string,
53
+ sp?: string,
54
+ s?: string,
55
+ ): Promise<{ sig?: string; n?: string }> {
56
+ const code = `${script.output}\n${getNsigProcessorFn(n, sp, s)}`;
57
+ const fn = new Function(code);
58
+ const result: unknown = fn();
59
+ if (typeof result !== "object" || result === null) {
60
+ throw new Error("Got invalid result from player script evaluation");
61
+ }
62
+ return result as { sig?: string; n?: string };
63
+ }
64
+
65
+ export async function decipherStreamUrl(
66
+ rawUrl: string | undefined,
67
+ signatureCipher: string | undefined,
68
+ pageHtml?: string,
69
+ ): Promise<string | null> {
70
+ if (!rawUrl && !signatureCipher) return null;
71
+
72
+ const script = await getPlayerScript(pageHtml);
73
+
74
+ let urlString: string;
75
+ let sig: string | undefined;
76
+ let sp: string | undefined;
77
+
78
+ if (signatureCipher) {
79
+ const params = new URLSearchParams(signatureCipher);
80
+ urlString = params.get("url") ?? "";
81
+ sig = params.get("s") ?? undefined;
82
+ sp = params.get("sp") ?? "signature";
83
+ if (!urlString) return null;
84
+ } else if (rawUrl) {
85
+ urlString = rawUrl;
86
+ } else {
87
+ return null;
88
+ }
89
+
90
+ const urlObj = new URL(urlString);
91
+ const n = urlObj.searchParams.get("n") ?? undefined;
92
+
93
+ const needsEval = sig !== undefined || n !== undefined;
94
+
95
+ if (needsEval && script.hasNsigFunction) {
96
+ const result = await evalPlayerScript(script, n, sp, sig);
97
+
98
+ if (typeof result.sig === "string" && sp) {
99
+ urlObj.searchParams.set(sp, result.sig);
100
+ }
101
+
102
+ if (typeof result.n === "string") {
103
+ if (!result.n.startsWith("enhanced_except_")) {
104
+ urlObj.searchParams.set("n", result.n);
105
+ }
106
+ }
107
+ }
108
+
109
+ const client = urlObj.searchParams.get("c");
110
+ const CLIENT_VERSIONS: Record<string, string> = {
111
+ WEB: "2.20250615.01.00",
112
+ MWEB: "2.20250614.01.00",
113
+ WEB_REMIX: "1.20250611.01.00",
114
+ WEB_KIDS: "2.20250612.00.00",
115
+ TVHTML5: "7.20250612.16.00",
116
+ TVHTML5_SIMPLY: "2.0",
117
+ TVHTML5_SIMPLY_EMBEDDED_PLAYER: "2.0",
118
+ WEB_EMBEDDED_PLAYER: "2.20250613.01.00",
119
+ };
120
+ if (client && CLIENT_VERSIONS[client]) {
121
+ urlObj.searchParams.set("cver", CLIENT_VERSIONS[client]);
122
+ }
123
+
124
+ return urlObj.toString();
125
+ }
126
+
127
+ export function clearPlayerCache(): void {
128
+ cachedPlayerUrl = null;
129
+ cachedScript = null;
130
+ }
131
+
132
+ export function setPageHtmlForPlayerExtraction(html: string): void {
133
+ if (!cachedPlayerUrl) {
134
+ const match = html.match(/\/s\/player\/([a-zA-Z0-9_-]+)\/[^"]+?base\.js/);
135
+ if (match) {
136
+ cachedPlayerUrl = `https://www.youtube.com${match[0]}`;
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,155 @@
1
+ import { SabrStream } from "googlevideo/sabr-stream";
2
+ import { buildSabrFormat } from "googlevideo/utils";
3
+ import { Innertube, Platform } from "youtubei.js";
4
+ import type { Format } from "../../core/types";
5
+ import { writeFileSync, appendFileSync, unlinkSync, existsSync } from "fs";
6
+
7
+ // Bun-native eval for youtubei.js player script execution
8
+ Platform.shim.eval = async (data: { output: string }) => {
9
+ const code = data.output + "\nreturn { ...exportedVars };";
10
+ return new Function(code)();
11
+ };
12
+
13
+ let _yt: Awaited<ReturnType<typeof Innertube.create>> | null = null;
14
+
15
+ async function getYt() {
16
+ if (!_yt) {
17
+ _yt = await Innertube.create();
18
+ }
19
+ return _yt;
20
+ }
21
+
22
+ export interface SabrDownloadResult {
23
+ videoPath: string | null;
24
+ audioPath: string | null;
25
+ selectedVideoItag: number | null;
26
+ selectedAudioItag: number | null;
27
+ }
28
+
29
+ export async function downloadViaSabr(
30
+ videoId: string,
31
+ outputPath: string,
32
+ options: {
33
+ videoQuality?: string;
34
+ onProgress?: (bytes: number, type: "video" | "audio") => void;
35
+ } = {},
36
+ ): Promise<SabrDownloadResult> {
37
+ const yt = await getYt();
38
+ const info = await yt.getInfo(videoId);
39
+
40
+ const streamingUrl = await yt.session.player?.decipher(
41
+ info.streaming_data?.server_abr_streaming_url,
42
+ );
43
+
44
+ const uConfig =
45
+ (info as unknown as Record<string, unknown>).page?.[0]?.player_config
46
+ ?.media_common_config?.media_ustreamer_request_config
47
+ ?.video_playback_ustreamer_config;
48
+
49
+ if (!streamingUrl) {
50
+ throw new Error("No SABR streaming URL available");
51
+ }
52
+
53
+ const sabrFormats =
54
+ info.streaming_data?.adaptive_formats?.map((f: unknown) =>
55
+ buildSabrFormat(f),
56
+ ) ?? [];
57
+
58
+ if (sabrFormats.length === 0) {
59
+ throw new Error("No adaptive formats available for SABR");
60
+ }
61
+
62
+ const sabr = new SabrStream({
63
+ formats: sabrFormats,
64
+ serverAbrStreamingUrl: streamingUrl,
65
+ videoPlaybackUstreamerConfig: uConfig,
66
+ durationMs: (info.basic_info.duration ?? 0) * 1000,
67
+ clientInfo: {
68
+ clientName: 1,
69
+ clientVersion: yt.session.context.client.clientVersion,
70
+ },
71
+ });
72
+
73
+ const { videoStream, audioStream, selectedFormats } = await sabr.start({
74
+ videoQuality: options.videoQuality ?? "720p",
75
+ audioQuality: "AUDIO_QUALITY_MEDIUM",
76
+ });
77
+
78
+ const videoPath = outputPath.replace(/\.[^.]+$/, ".video.mp4");
79
+ const audioPath = outputPath.replace(/\.[^.]+$/, ".audio.m4a");
80
+
81
+ // Download video stream
82
+ if (existsSync(videoPath)) unlinkSync(videoPath);
83
+ const videoReader = videoStream.getReader();
84
+ let videoBytes = 0;
85
+ while (true) {
86
+ const { done, value } = await videoReader.read();
87
+ if (done) break;
88
+ appendFileSync(videoPath, value);
89
+ videoBytes += value.byteLength;
90
+ options.onProgress?.(videoBytes, "video");
91
+ }
92
+
93
+ // Download audio stream
94
+ if (existsSync(audioPath)) unlinkSync(audioPath);
95
+ const audioReader = audioStream.getReader();
96
+ let audioBytes = 0;
97
+ while (true) {
98
+ const { done, value } = await audioReader.read();
99
+ if (done) break;
100
+ appendFileSync(audioPath, value);
101
+ audioBytes += value.byteLength;
102
+ options.onProgress?.(audioBytes, "audio");
103
+ }
104
+
105
+ return {
106
+ videoPath: videoBytes > 0 ? videoPath : null,
107
+ audioPath: audioBytes > 0 ? audioPath : null,
108
+ selectedVideoItag: selectedFormats.videoFormat?.itag ?? null,
109
+ selectedAudioItag: selectedFormats.audioFormat?.itag ?? null,
110
+ };
111
+ }
112
+
113
+ export async function getSabrFormats(videoId: string): Promise<Format[]> {
114
+ const yt = await getYt();
115
+ const info = await yt.getInfo(videoId);
116
+
117
+ const adaptiveFormats = info.streaming_data?.adaptive_formats ?? [];
118
+ const formats: Format[] = [];
119
+
120
+ for (const f of adaptiveFormats) {
121
+ const raw = f as unknown as Record<string, unknown>;
122
+ const mime = String(raw.mime_type ?? "");
123
+ const mimeMatch = mime.match(/^(video|audio)\/(\w+);\s*codecs="([^"]+)"/);
124
+ const ext = mimeMatch?.[2] ?? "mp4";
125
+ const codecs = mimeMatch?.[3] ?? "";
126
+ const isVideo = mime.startsWith("video");
127
+ const isAudio = mime.startsWith("audio");
128
+
129
+ formats.push({
130
+ format_id: String(raw.itag ?? ""),
131
+ url: "sabr://requires-sabr-download",
132
+ ext: isAudio && ext === "mp4" ? "m4a" : ext,
133
+ vcodec: isVideo ? codecs.split(",")[0]?.trim() : "none",
134
+ acodec: isAudio
135
+ ? codecs
136
+ : isVideo && codecs.includes(",")
137
+ ? codecs.split(",")[1]?.trim()
138
+ : undefined,
139
+ width: (raw.width as number) ?? undefined,
140
+ height: (raw.height as number) ?? undefined,
141
+ fps: (raw.fps as number) ?? undefined,
142
+ tbr: raw.bitrate
143
+ ? Math.round((raw.bitrate as number) / 1000)
144
+ : undefined,
145
+ filesize: raw.content_length
146
+ ? parseInt(String(raw.content_length), 10)
147
+ : undefined,
148
+ format_note: String(raw.quality_label ?? raw.quality ?? ""),
149
+ audio_channels: (raw.audio_channels as number) ?? undefined,
150
+ protocol: "sabr",
151
+ });
152
+ }
153
+
154
+ return formats;
155
+ }
@@ -1,14 +0,0 @@
1
- #!/usr/bin/env node
2
- // Patches youtubei.js to use Bun-native JS evaluation instead of the default stub
3
- import { writeFileSync } from "fs";
4
- import { resolve } from "path";
5
-
6
- const evalPath = resolve("node_modules/youtubei.js/dist/src/platform/jsruntime/default.js");
7
- const evalCode = `export default async function evaluate(data) {
8
- const fn = new Function(data.output);
9
- return fn();
10
- }
11
- `;
12
-
13
- writeFileSync(evalPath, evalCode);
14
- console.log("Patched youtubei.js jsruntime for Bun-native evaluation");
@@ -1,173 +0,0 @@
1
- # getraw — Product Video Expansion
2
-
3
- ## Style Block
4
-
5
- - **BG:** #0c0c0f (near-black, blue undertone)
6
- - **FG:** #e8e8ec (off-white)
7
- - **Accent:** #00ff88 (terminal green)
8
- - **Surface:** #161620
9
- - **Muted:** #6b6b80
10
- - **Headline:** Space Grotesk 700, -0.03em tracking
11
- - **Code:** JetBrains Mono 400
12
- - **Labels:** JetBrains Mono 500 uppercase, 0.08em tracking
13
-
14
- ## Rhythm Declaration
15
-
16
- `hook-PUNCH-breathe-BUILD-PEAK-CTA`
17
-
18
- 6 scenes, ~30 seconds total. Fast hook, slam the name, breathe with the problem, build features, peak with the stats, close with install command.
19
-
20
- ## Global Rules
21
-
22
- - Primary transition: glitch (0.3s) — 60% of transitions
23
- - Accent transition: staggered blocks (0.25s) — topic changes
24
- - All decoratives have ambient motion (scan-line drift, grid pulse, cursor blink)
25
- - Terminal green (#00ff88) used ONLY for: commands, active highlights, key stats
26
- - Everything else in off-white or muted
27
-
28
- ---
29
-
30
- ## Scene 1: Hook (0s–5s)
31
-
32
- **Concept:** A terminal cursor blinks on black. A command types out character by character: `$ getraw "https://youtube.com/watch?v=..."`. The moment Enter is hit, the screen EXPLODES with data — format listings cascading down like a matrix waterfall. The viewer's reaction: "what is this tool?"
33
-
34
- **Mood:** Cinematic hacker. The opening frame of a tech thriller.
35
-
36
- **Depth layers:**
37
- - BG: #0c0c0f solid + subtle scan-line overlay at 8% opacity drifting upward + faint grid dots at 5% pulsing
38
- - MG: Terminal text, typing animation, cursor blink
39
- - FG: Faint terminal frame border (1px #2a2a3a), timestamp label top-right "v0.1.0"
40
-
41
- **Animation choreography:**
42
- - Cursor BLINKS twice (0.5s interval) at t=0.2
43
- - Command TYPES character-by-character at 40ms/char starting t=0.8
44
- - On "enter" at t=3.0: format data CASCADES in from top, staggered 30ms per line
45
- - Scan-lines drift upward continuously
46
-
47
- **Transition out:** Glitch, 0.3s, power3.inOut
48
-
49
- ---
50
-
51
- ## Scene 2: Name Drop (5s–10s)
52
-
53
- **Concept:** The word "getraw" SLAMS into frame at massive scale — 160px, terminal green, filling 70% of the width. Below it, a subtitle types on: "yt-dlp replacement. Built in Bun." The green glows softly, casting light on the dark surface. This is the brand moment.
54
-
55
- **Mood:** Impact. Like a logo sting but for a CLI tool.
56
-
57
- **Depth layers:**
58
- - BG: Radial glow from center (#00ff88 at 12% opacity, scale breathing 1.0→1.05) + ghost text "MEDIA DOWNLOADER" at 4% opacity, 200px, behind the title
59
- - MG: "getraw" headline + subtitle line
60
- - FG: Two horizontal rules (top and bottom of frame, #2a2a3a, scaleX from 0), version badge "v0.1.0" bottom-right
61
-
62
- **Animation choreography:**
63
- - "getraw" SLAMS from y:80 with scale overshoot (1.05→1.0), expo.out, 0.5s at t=0.3
64
- - Subtitle TYPES on character-by-character at t=1.2, JetBrains Mono, muted color
65
- - Horizontal rules DRAW from center outward (scaleX: 0→1) at t=0.5, 0.6s
66
- - Radial glow BREATHES continuously (scale 1.0↔1.05, 3s loop)
67
-
68
- **Transition out:** Staggered blocks, 0.25s — signals topic change
69
-
70
- ---
71
-
72
- ## Scene 3: The Problem (10s–15s)
73
-
74
- **Concept:** Split frame. Left side: "yt-dlp" with a list of pain points stacking up — "Python dependency", "External JS runtime", "36K line interpreter", "Slow startup". Each appears with a red-ish muted strike. Right side: blank, waiting. The viewer feels the weight of the problem before the solution appears.
75
-
76
- **Mood:** Tension. Editorial comparison. The "before" that makes the "after" land.
77
-
78
- **Depth layers:**
79
- - BG: Subtle vertical divider line at center (1px #2a2a3a) + grid dots left half at 5%
80
- - MG: "yt-dlp" label top-left + stacking pain points + right side empty space
81
- - FG: Small "THE PROBLEM" label top-center in muted, monospace uppercase
82
-
83
- **Animation choreography:**
84
- - "THE PROBLEM" label FADES in at t=0.2, subtle
85
- - "yt-dlp" SLIDES in from left at t=0.4
86
- - Pain points STACK one by one, each DROPPING from y:-20 with stagger 0.3s starting t=0.8
87
- - Each pain point gets a subtle strikethrough line that DRAWS across after landing
88
- - Right side stays intentionally empty — tension
89
-
90
- **Transition out:** Glitch, 0.3s
91
-
92
- ---
93
-
94
- ## Scene 4: The Solution (15s–21s)
95
-
96
- **Concept:** The empty right side from scene 3 is now the full frame. "getraw" in green, and below it, three feature cards STAGGER in: "Native JS execution", "30+ site extractors", "Bun-powered CLI". Each card has a small icon-like label and a one-liner. Clean, confident, no clutter. The solution is elegant.
97
-
98
- **Mood:** Confidence. Clean resolve after tension.
99
-
100
- **Depth layers:**
101
- - BG: Radial glow bottom-left (#00ff88 at 10%) + faint circuit-board pattern at 3% opacity
102
- - MG: "getraw" label + three feature cards in surface color (#161620) with border
103
- - FG: Small arrow indicators (→) next to each card, accent green, appearing with stagger
104
-
105
- **Animation choreography:**
106
- - "getraw" SLIDES in from left at t=0.2, accent green, smaller (64px)
107
- - Feature cards CASCADE from right, stagger 0.15s, each from x:40 + opacity:0, expo.out
108
- - Arrow indicators POP in with scale overshoot 0.15s after their card lands
109
- - Circuit pattern DRIFTS slowly rightward
110
-
111
- **Transition out:** Glitch, 0.3s
112
-
113
- ---
114
-
115
- ## Scene 5: Stats (21s–26s)
116
-
117
- **Concept:** Three big numbers SLAM in simultaneously: "30+" sites, "386" tests, "50ms" startup. Each in massive type (120px), terminal green. Below each number, a label in muted monospace. This is the proof. Numbers don't lie.
118
-
119
- **Mood:** Peak energy. Data as spectacle.
120
-
121
- **Depth layers:**
122
- - BG: Three vertical accent lines (#00ff88 at 8%) behind each stat, full height, subtle pulse
123
- - MG: Three stat columns with numbers + labels
124
- - FG: "BENCHMARKS" label top-center, monospace uppercase muted + scan-line overlay intensified to 12%
125
-
126
- **Animation choreography:**
127
- - "BENCHMARKS" label FADES in at t=0.1
128
- - All three numbers SLAM simultaneously from y:60, scale:1.1→1.0, expo.out, 0.4s at t=0.3
129
- - Labels TYPE on below each number, stagger 0.1s starting t=0.8
130
- - Accent lines PULSE once on number impact (opacity 8%→15%→8%, 0.6s)
131
-
132
- **Transition out:** Staggered blocks, 0.3s — wind down
133
-
134
- ---
135
-
136
- ## Scene 6: CTA / Install (26s–30s)
137
-
138
- **Concept:** Back to terminal. A clean command prompt: `$ bun install -g getraw`. Below it, the npm badge and GitHub link. The cursor blinks at the end. Simple. The viewer knows exactly what to do next.
139
-
140
- **Mood:** Resolution. Clear call to action. The cursor blink is the mic drop.
141
-
142
- **Depth layers:**
143
- - BG: Subtle scan-lines returning + faint radial glow center (#00ff88 at 6%)
144
- - MG: Install command + npm/GitHub info
145
- - FG: Terminal frame border, "getraw.dev" bottom-center (aspirational), cursor blinking
146
-
147
- **Animation choreography:**
148
- - Terminal frame DRAWS in (border animation, 0.4s) at t=0.1
149
- - Command TYPES on at t=0.5, 50ms/char, green text
150
- - npm line FADES in at t=2.0, muted
151
- - GitHub URL FADES in at t=2.3, muted
152
- - Cursor BLINKS indefinitely (well, 4 blinks with calculated repeat count)
153
-
154
- **Transition out:** Final scene — elements fade to black over 0.8s. Last thing visible: the blinking cursor.
155
-
156
- ---
157
-
158
- ## Recurring Motifs
159
-
160
- - Terminal cursor blink (scenes 1, 6)
161
- - Scan-line overlay (all scenes, varying intensity)
162
- - Character-by-character typing (scenes 1, 2, 6)
163
- - Accent green used only for active/important elements
164
-
165
- ## Negative Prompt
166
-
167
- - No gradient text
168
- - No cyan or purple — green only
169
- - No rounded corners > 8px
170
- - No web-UI card shadows
171
- - No centered-everything layouts
172
- - No Inter, Roboto, or banned fonts
173
- - No pure #000 or #fff
package/video/design.md DELETED
@@ -1,82 +0,0 @@
1
- ---
2
- name: getraw
3
- colors:
4
- primary: "#0c0c0f"
5
- on-primary: "#e8e8ec"
6
- accent: "#00ff88"
7
- accent-dim: "#00cc6a"
8
- surface: "#161620"
9
- surface-border: "#2a2a3a"
10
- muted: "#6b6b80"
11
- typography:
12
- headline:
13
- fontFamily: Space Grotesk
14
- fontSize: 5rem
15
- fontWeight: 700
16
- letterSpacing: -0.03em
17
- body:
18
- fontFamily: JetBrains Mono
19
- fontSize: 1.5rem
20
- fontWeight: 400
21
- label:
22
- fontFamily: JetBrains Mono
23
- fontSize: 0.875rem
24
- fontWeight: 500
25
- textTransform: uppercase
26
- letterSpacing: 0.08em
27
- rounded:
28
- none: 0px
29
- sm: 4px
30
- md: 8px
31
- spacing:
32
- sm: 8px
33
- md: 16px
34
- lg: 40px
35
- motion:
36
- energy: high
37
- easing:
38
- entry: "expo.out"
39
- exit: "power3.in"
40
- ambient: "sine.inOut"
41
- duration:
42
- entrance: 0.4
43
- hold: 1.8
44
- transition: 0.4
45
- atmosphere:
46
- - terminal-cursor
47
- - scan-lines
48
- - grid-dots
49
- transition: glitch
50
- ---
51
-
52
- ## Overview
53
-
54
- getraw is a fast media downloader CLI built in Bun/TypeScript — a yt-dlp replacement with native JS execution. The visual identity is terminal-native: dark background, green accent (terminal green), monospace type for code, and sharp geometric motion. Feels like watching a hacker tool come alive.
55
-
56
- ## Colors
57
-
58
- - **Primary (#0c0c0f):** Near-black background with slight blue undertone
59
- - **On-primary (#e8e8ec):** Off-white text, not pure white
60
- - **Accent (#00ff88):** Terminal green — the signature color. Used for highlights, commands, active states
61
- - **Surface (#161620):** Slightly elevated panels, code blocks
62
- - **Muted (#6b6b80):** Comments, secondary info
63
-
64
- ## Typography
65
-
66
- - **Headlines:** Space Grotesk 700 — geometric, techy, not generic
67
- - **Code/Body:** JetBrains Mono 400 — the developer's monospace
68
- - **Labels:** JetBrains Mono 500 uppercase — structural metadata
69
-
70
- ## Do's and Don'ts
71
-
72
- ### Do
73
- - Use terminal-style animations (typing effect, cursor blink, line-by-line reveal)
74
- - Show real CLI commands and output
75
- - Use the green accent sparingly but boldly
76
- - Let the dark background breathe
77
-
78
- ### Don't
79
- - Use gradients on text
80
- - Use rounded corners larger than 8px
81
- - Use decorative serif fonts
82
- - Use bright colors other than the accent green