shivenverma 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shiven Verma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # shivenverma
2
+
3
+ An interactive terminal portfolio for **Shiven Verma** — a small, keyboard-driven
4
+ TUI that runs in your terminal. Built with Node.js, React, and [Ink](https://github.com/vadimdemedes/ink).
5
+
6
+ ```
7
+ _____ __ _______ _________ __ _ ____________ __ ______
8
+ / ___// / / / _/ | / / ____/ | / / | | / / ____/ __ \/ |/ / |
9
+ \__ \/ /_/ // / | | / / __/ / |/ / | | / / __/ / /_/ / /|_/ / /| |
10
+ ___/ / __ // / | |/ / /___/ /| / | |/ / /___/ _, _/ / / / ___ |
11
+ /____/_/ /_/___/ |___/_____/_/ |_/ |___/_____/_/ |_/_/ /_/_/ |_|
12
+ ```
13
+
14
+ ## Run it
15
+
16
+ Once published to npm, it runs with no install step:
17
+
18
+ ```bash
19
+ npx shivenverma
20
+ ```
21
+
22
+ Until then (or to run from a clone), see [Development](#development) below.
23
+
24
+ > Requires **Node.js 18+**.
25
+
26
+ ## Using it
27
+
28
+ The interface is keyboard-first.
29
+
30
+ | Key | Action |
31
+ | --- | --- |
32
+ | `↑` `↓` / `k` `j` | Move the selection |
33
+ | `⏎` Enter | Open the selected item |
34
+ | `Esc` | Go back |
35
+ | `:` | Open the command bar |
36
+ | `q` | Quit |
37
+
38
+ ### Command bar
39
+
40
+ Press `:` from anywhere and type a command:
41
+
42
+ ```
43
+ about skills projects github contact resume home clear help exit
44
+ ```
45
+
46
+ `help` lists everything. Unknown commands report back instead of failing.
47
+
48
+ ### Sections
49
+
50
+ - **About** — a short intro and current areas of focus
51
+ - **Skills** — languages, frameworks, and tools (no made-up proficiency bars)
52
+ - **Projects** — a selectable list; open one for details and links
53
+ - **GitHub** — profile link, opens in your browser
54
+ - **Contact** — configured contact channels
55
+ - **Resume** — opens your resume link when configured
56
+
57
+ Links open in your default browser via the OS handler (`open` / `start` /
58
+ `xdg-open`). If no browser can be launched, the URL is shown so you can copy it —
59
+ the app never crashes on a failed open.
60
+
61
+ ## Make it yours
62
+
63
+ All content lives in one file: [`src/data/portfolio.js`](src/data/portfolio.js).
64
+ Edit values there and rebuild — no component code needs to change.
65
+
66
+ Values written as `REPLACE_WITH_*` (or left empty) are treated as **not
67
+ configured**: the UI shows a tasteful fallback instead of a broken link. Search
68
+ the file for `REPLACE_WITH` to find everything still to fill in. At the time of
69
+ writing that includes:
70
+
71
+ - `links.linkedin`, `links.email`, `links.resume`
72
+ - `location` (optional)
73
+ - Each project's `links` (repo / docs / live URLs) and, for the placeholder
74
+ projects, their `description` and `tech`
75
+
76
+ The GitHub username is already set to `shivenverma`.
77
+
78
+ ## Development
79
+
80
+ From a clone of the repository:
81
+
82
+ ```bash
83
+ npm install # install dependencies
84
+ npm run dev # build, then launch the portfolio
85
+ ```
86
+
87
+ Other scripts:
88
+
89
+ ```bash
90
+ npm run build # bundle src/ -> dist/cli.js with esbuild
91
+ npm start # run the built CLI (bin/cli.js)
92
+ npm test # build, then run the smoke test
93
+ npm run banner # regenerate the ASCII wordmark (src/data/banner.js)
94
+ npm run package # build and create a local .tgz tarball
95
+ ```
96
+
97
+ ### How it's built
98
+
99
+ - **[Ink](https://github.com/vadimdemedes/ink) + React** render the UI to the terminal.
100
+ - **esbuild** bundles `src/` into a single `dist/cli.js`. React and Ink stay as
101
+ runtime dependencies (installed by npm); only the app's own code is bundled.
102
+ - The ASCII wordmark is generated once at build time with
103
+ [figlet](https://github.com/patorjk/figlet.js) and committed as plain data
104
+ ([`src/data/banner.js`](src/data/banner.js)), so there's no font loading at runtime.
105
+ - The layout is responsive: the wordmark steps down through smaller fonts (and a
106
+ plain-text fallback) as the terminal narrows, and dense sections compact on
107
+ short terminals.
108
+
109
+ ### Flags
110
+
111
+ ```
112
+ shivenverma --help show help
113
+ shivenverma --version print the version
114
+ shivenverma --no-startup skip the boot animation
115
+ ```
116
+
117
+ ## License
118
+
119
+ [MIT](LICENSE) © Shiven Verma
package/bin/cli.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Executable entry point. Loads the prebuilt bundle so end users never need a
4
+ * JSX toolchain — `npm run build` produces dist/cli.js.
5
+ */
6
+ import './../dist/cli.js';
package/dist/cli.js ADDED
@@ -0,0 +1,1103 @@
1
+ // src/index.jsx
2
+ import React21 from "react";
3
+ import { render } from "ink";
4
+
5
+ // src/App.jsx
6
+ import React20, { useState as useState5 } from "react";
7
+ import { Box as Box18, Text as Text18, useApp } from "ink";
8
+
9
+ // src/theme/theme.js
10
+ var theme = {
11
+ colors: {
12
+ // Text
13
+ primary: "#e6edf3",
14
+ // main foreground
15
+ secondary: "#8b949e",
16
+ // muted / supporting text
17
+ faint: "#6e7681",
18
+ // hints, separators, disabled
19
+ // Accents
20
+ accent: "#3fb950",
21
+ // green — selection, success, brand
22
+ accentDim: "#2ea043",
23
+ // green, slightly deeper
24
+ link: "#58a6ff",
25
+ // blue — links only
26
+ warning: "#d29922",
27
+ // muted amber — warnings / not-configured
28
+ // Surfaces
29
+ border: "#30363d"
30
+ // subtle rules and panel edges
31
+ },
32
+ // Glyphs kept to a small, widely-supported set.
33
+ glyphs: {
34
+ pointer: "\u203A",
35
+ bullet: "\u2022",
36
+ arrow: "\u2192",
37
+ check: "\u2713",
38
+ dot: "\xB7",
39
+ corner: "\u2514",
40
+ vbar: "\u2502"
41
+ },
42
+ // Layout constants (in columns).
43
+ layout: {
44
+ indent: 3,
45
+ // left gutter for content
46
+ maxWidth: 76,
47
+ // content never grows wider than this
48
+ minWidth: 44
49
+ // below this we drop into a compact layout
50
+ }
51
+ };
52
+ var theme_default = theme;
53
+
54
+ // src/data/portfolio.js
55
+ var GITHUB_USERNAME = "shivenverma";
56
+ var portfolio = {
57
+ name: "Shiven Verma",
58
+ role: "Developer \u2022 Builder \u2022 Problem Solver",
59
+ tagline: "I build software, experiment with technology, and solve interesting problems.",
60
+ location: "Noida, India",
61
+ // e.g. "Delhi, India" — optional
62
+ // About screen. Each string is rendered as its own paragraph.
63
+ about: [
64
+ "I'm Shiven \u2014 a developer who likes building things, taking them apart, and understanding how they work.",
65
+ "I care about software that feels considered: tools that are pleasant to use and code that is a pleasure to read.",
66
+ "This portfolio is itself a small project. Everything you see lives in one data file, so it grows as I do."
67
+ ],
68
+ // Short list of what you're currently into. Keep these honest and editable.
69
+ focus: [
70
+ "Developer tooling and productivity",
71
+ "Full-stack web development",
72
+ "Automation and small, sharp utilities",
73
+ "AI-assisted software"
74
+ ],
75
+ // Skills grouped by category. Add or remove categories freely — the Skills
76
+ // screen renders whatever is here. No fake proficiency percentages.
77
+ skills: {
78
+ Languages: ["Java", "JavaScript", "Python", "C++", "SQL"],
79
+ Frontend: ["React", "HTML", "CSS"],
80
+ Backend: ["Node.js"],
81
+ Tools: ["Git", "GitHub", "VS Code"]
82
+ },
83
+ // Projects. Each entry becomes a selectable card in the Projects screen.
84
+ // links: any value that is empty or REPLACE_WITH_* is shown as "not linked".
85
+ projects: [
86
+ {
87
+ id: "gfghub",
88
+ name: "GFGHub",
89
+ summary: "Organize coding problems solved across GeeksforGeeks.",
90
+ description: "A developer productivity extension designed to organize coding problems solved across GeeksforGeeks, keeping your practice history in one place.",
91
+ tech: ["JavaScript", "Chrome Extensions", "GitHub API"],
92
+ status: "In Development",
93
+ links: {
94
+ github: "https://github.com/harshitcodes1308/gfg_hub_extension.git",
95
+ docs: "REPLACE_WITH_GFGHUB_DOCS_URL",
96
+ live: ""
97
+ }
98
+ },
99
+ {
100
+ id: "batcave-experience",
101
+ name: "Batcave Experience",
102
+ summary: "An immersive personal music room where a curated YouTube playlist plays inside a painted 2D version of my flat.",
103
+ description: "Batcave Music is an immersive music experience inspired by themed web radio sites. It recreates my flat as a painted 2D environment, plays a curated YouTube playlist, and lets visitors manually change the room lighting between purple, crimson, and amber moods. I built it to explore immersive web experiences, custom music-player interactions, responsive UI, and realtime presence.",
104
+ tech: [
105
+ "React",
106
+ "TypeScript",
107
+ "Vite",
108
+ "YouTube IFrame Player API",
109
+ "Supabase Realtime"
110
+ ],
111
+ status: "Deployed",
112
+ links: {
113
+ github: "https://github.com/shivenverma/Batcave-Experience.git",
114
+ live: "https://www.batcaveexperience.studio/"
115
+ }
116
+ }
117
+ ],
118
+ // GitHub section. The username is known; stats can be wired to the live API
119
+ // later (see src/utils/github.js) without changing this file.
120
+ github: {
121
+ username: GITHUB_USERNAME,
122
+ url: `https://github.com/shivenverma`,
123
+ blurb: "Open-source experiments, side projects, and the occasional useful tool."
124
+ },
125
+ // Contact + external links. mailto: is added automatically for email.
126
+ links: {
127
+ github: `https://github.com/shivenverma`,
128
+ linkedin: "https://www.linkedin.com/in/shiven-verma-24b5862a2/",
129
+ email: "shivenverma022@gmail.com",
130
+ resume: ""
131
+ }
132
+ };
133
+ var portfolio_default = portfolio;
134
+
135
+ // src/utils/commands.js
136
+ var COMMANDS = [
137
+ { name: "about", screen: "about", description: "About me" },
138
+ { name: "skills", screen: "skills", description: "Technical skills" },
139
+ { name: "projects", screen: "projects", description: "View projects" },
140
+ { name: "github", screen: "github", description: "GitHub profile" },
141
+ { name: "contact", screen: "contact", description: "Contact information" },
142
+ { name: "resume", screen: "resume", description: "Resume" },
143
+ { name: "home", screen: "home", description: "Main menu" },
144
+ { name: "clear", action: "clear", description: "Clear the screen" },
145
+ { name: "help", screen: "help", description: "Show commands" },
146
+ { name: "exit", action: "exit", description: "Exit portfolio" }
147
+ ];
148
+ var ALIASES = {
149
+ quit: "exit",
150
+ q: "exit",
151
+ back: "home",
152
+ ls: "projects",
153
+ "?": "help",
154
+ man: "help"
155
+ };
156
+ var EASTER_EGGS = /* @__PURE__ */ new Set(["sudo shiven", "sudo make me a sandwich", "sudo", "whoami"]);
157
+ function matchCommand(rawInput) {
158
+ const input = rawInput.trim().toLowerCase().replace(/\s+/g, " ");
159
+ if (input.length === 0) return { type: "noop" };
160
+ if (EASTER_EGGS.has(input)) return { type: "action", action: "easter-egg" };
161
+ const name = ALIASES[input] ?? input;
162
+ const command = COMMANDS.find((c) => c.name === name);
163
+ if (!command) return { type: "unknown", input: rawInput.trim() };
164
+ if (command.screen) return { type: "screen", screen: command.screen };
165
+ return { type: "action", action: command.action };
166
+ }
167
+
168
+ // src/utils/browser.js
169
+ import { spawn } from "node:child_process";
170
+ function openUrl(url) {
171
+ return new Promise((resolve) => {
172
+ let settled = false;
173
+ const finish = (result) => {
174
+ if (!settled) {
175
+ settled = true;
176
+ resolve(result);
177
+ }
178
+ };
179
+ if (typeof url !== "string" || url.trim().length === 0) {
180
+ finish({ ok: false, error: "No URL to open." });
181
+ return;
182
+ }
183
+ try {
184
+ const child = spawnHandler(url.trim());
185
+ child.once("error", (err) => finish({ ok: false, error: err.message }));
186
+ child.unref();
187
+ setTimeout(() => finish({ ok: true }), 150);
188
+ } catch (err) {
189
+ finish({ ok: false, error: err.message });
190
+ }
191
+ });
192
+ }
193
+ function spawnHandler(url) {
194
+ const options = { stdio: "ignore", detached: true };
195
+ if (process.platform === "darwin") {
196
+ return spawn("open", [url], options);
197
+ }
198
+ if (process.platform === "win32") {
199
+ const escaped = url.replace(/[&|<>^]/g, "^$&");
200
+ return spawn("cmd", ["/c", "start", '""', escaped], {
201
+ ...options,
202
+ windowsVerbatimArguments: true,
203
+ windowsHide: true
204
+ });
205
+ }
206
+ return spawn("xdg-open", [url], options);
207
+ }
208
+
209
+ // src/hooks/useSafeInput.js
210
+ import { useInput, useStdin } from "ink";
211
+ function useSafeInput(handler, options = {}) {
212
+ const { isRawModeSupported } = useStdin();
213
+ const isActive = Boolean((options.isActive ?? true) && isRawModeSupported);
214
+ useInput(handler, { ...options, isActive });
215
+ }
216
+ var useSafeInput_default = useSafeInput;
217
+
218
+ // src/utils/terminal.js
219
+ var PLACEHOLDER_PREFIX = "REPLACE_WITH";
220
+ function isConfigured(value) {
221
+ if (typeof value !== "string") return Boolean(value);
222
+ const trimmed = value.trim();
223
+ if (trimmed.length === 0) return false;
224
+ return !trimmed.toUpperCase().startsWith(PLACEHOLDER_PREFIX);
225
+ }
226
+ function configuredList(list) {
227
+ if (!Array.isArray(list)) return [];
228
+ return list.filter(isConfigured);
229
+ }
230
+ function toMailto(email) {
231
+ if (!email) return "";
232
+ return email.startsWith("mailto:") ? email : `mailto:${email}`;
233
+ }
234
+ function clamp(value, min, max) {
235
+ return Math.max(min, Math.min(max, value));
236
+ }
237
+ function wrapWords(text, width) {
238
+ const safeWidth = Math.max(1, Math.floor(width) || 1);
239
+ const lines = [];
240
+ let line = "";
241
+ const flush = () => {
242
+ lines.push(line);
243
+ line = "";
244
+ };
245
+ for (const raw of String(text).split(/\s+/).filter(Boolean)) {
246
+ let word = raw;
247
+ while (word.length > safeWidth) {
248
+ if (line) flush();
249
+ lines.push(word.slice(0, safeWidth));
250
+ word = word.slice(safeWidth);
251
+ }
252
+ if (!line) line = word;
253
+ else if (line.length + 1 + word.length <= safeWidth) line += ` ${word}`;
254
+ else {
255
+ flush();
256
+ line = word;
257
+ }
258
+ }
259
+ if (line) flush();
260
+ return lines.length > 0 ? lines : [""];
261
+ }
262
+
263
+ // src/hooks/useTerminalSize.js
264
+ import { useState, useEffect } from "react";
265
+ function useTerminalSize() {
266
+ const [size, setSize] = useState(() => read());
267
+ useEffect(() => {
268
+ const onResize = () => setSize(read());
269
+ process.stdout.on("resize", onResize);
270
+ return () => process.stdout.off("resize", onResize);
271
+ }, []);
272
+ return size;
273
+ }
274
+ function read() {
275
+ return {
276
+ columns: process.stdout.columns || 80,
277
+ rows: process.stdout.rows || 24
278
+ };
279
+ }
280
+
281
+ // src/hooks/useLayout.js
282
+ function useLayout() {
283
+ const { columns, rows } = useTerminalSize();
284
+ const indent = columns < 40 ? 1 : theme_default.layout.indent;
285
+ const width = clamp(columns - indent * 2, 16, theme_default.layout.maxWidth);
286
+ const compact = columns < theme_default.layout.minWidth || rows < 20;
287
+ return { columns, rows, indent, width, compact };
288
+ }
289
+ var useLayout_default = useLayout;
290
+
291
+ // src/components/Startup.jsx
292
+ import React, { useEffect as useEffect2, useRef, useState as useState2 } from "react";
293
+ import { Box, Text } from "ink";
294
+ import { jsx, jsxs } from "react/jsx-runtime";
295
+ var STEPS = ["Loading profile", "Loading projects", "Loading skills", "Connecting to portfolio"];
296
+ var SPINNER = "\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F".split("");
297
+ var STEP_MS = 120;
298
+ var READY_MS = 320;
299
+ function Startup({ onComplete }) {
300
+ const { indent } = useLayout_default();
301
+ const [step, setStep] = useState2(0);
302
+ const [frame, setFrame] = useState2(0);
303
+ const done = useRef(false);
304
+ const finish = () => {
305
+ if (!done.current) {
306
+ done.current = true;
307
+ onComplete();
308
+ }
309
+ };
310
+ useEffect2(() => {
311
+ if (step >= STEPS.length) {
312
+ const t2 = setTimeout(finish, READY_MS);
313
+ return () => clearTimeout(t2);
314
+ }
315
+ const t = setTimeout(() => setStep((s) => s + 1), step === 0 ? 240 : STEP_MS);
316
+ return () => clearTimeout(t);
317
+ }, [step]);
318
+ useEffect2(() => {
319
+ const t = setInterval(() => setFrame((f) => (f + 1) % SPINNER.length), 80);
320
+ return () => clearInterval(t);
321
+ }, []);
322
+ useSafeInput_default(() => finish());
323
+ const complete = step >= STEPS.length;
324
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: indent, paddingY: 1, children: [
325
+ /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
326
+ /* @__PURE__ */ jsx(Text, { color: theme_default.colors.primary, bold: true, children: "Initializing ShivenOS" }),
327
+ /* @__PURE__ */ jsx(Text, { color: theme_default.colors.faint, children: "\u2026" })
328
+ ] }),
329
+ STEPS.map((label, i) => {
330
+ const isDone = i < step;
331
+ const isCurrent = i === step && !complete;
332
+ return /* @__PURE__ */ jsxs(Box, { children: [
333
+ /* @__PURE__ */ jsx(Text, { color: isDone ? theme_default.colors.accent : theme_default.colors.faint, children: isDone ? `${theme_default.glyphs.check} ` : isCurrent ? `${SPINNER[frame]} ` : " " }),
334
+ /* @__PURE__ */ jsx(
335
+ Text,
336
+ {
337
+ color: isDone ? theme_default.colors.secondary : isCurrent ? theme_default.colors.primary : theme_default.colors.faint,
338
+ children: label
339
+ }
340
+ )
341
+ ] }, label);
342
+ }),
343
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: complete ? /* @__PURE__ */ jsx(Text, { color: theme_default.colors.accent, children: "System ready." }) : /* @__PURE__ */ jsx(Text, { color: theme_default.colors.faint, children: "press any key to skip" }) })
344
+ ] });
345
+ }
346
+ var Startup_default = Startup;
347
+
348
+ // src/components/Home.jsx
349
+ import React7 from "react";
350
+ import { Box as Box6 } from "ink";
351
+
352
+ // src/components/Header.jsx
353
+ import React2 from "react";
354
+ import { Box as Box2, Text as Text2 } from "ink";
355
+
356
+ // src/data/banner.js
357
+ var BANNER_TIERS = [
358
+ { minColumns: 74, text: " _____ __ _______ _________ __ _ ____________ __ ______\n / ___// / / / _/ | / / ____/ | / / | | / / ____/ __ \\/ |/ / |\n \\__ \\/ /_/ // / | | / / __/ / |/ / | | / / __/ / /_/ / /|_/ / /| |\n ___/ / __ // / | |/ / /___/ /| / | |/ / /___/ _, _/ / / / ___ |\n/____/_/ /_/___/ |___/_____/_/ |_/ |___/_____/_/ |_/_/ /_/_/ |_|" },
359
+ { minColumns: 62, text: " ______ _______ _______ __ _ _________ __ ______\n / __/ // / _/ | / / __/ |/ / | | / / __/ _ \\/ |/ / _ |\n _\\ \\/ _ // / | |/ / _// / | |/ / _// , _/ /|_/ / __ |\n/___/_//_/___/ |___/___/_/|_/ |___/___/_/|_/_/ /_/_/ |_|" },
360
+ { minColumns: 34, text: " ___ _ _ _____ _____ _ _\n / __| || |_ _\\ \\ / / __| \\| |\n \\__ \\ __ || | \\ V /| _|| .` |\n |___/_||_|___| \\_/ |___|_|\\_|\n \\ \\ / / __| _ \\ \\/ | /_\\\n \\ V /| _|| / |\\/| |/ _ \\\n \\_/ |___|_|_\\_| |_/_/ \\_\\" }
361
+ ];
362
+ function selectBanner(columns) {
363
+ const tier = BANNER_TIERS.find((t) => columns >= t.minColumns);
364
+ return tier ? tier.text : null;
365
+ }
366
+
367
+ // src/components/Header.jsx
368
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
369
+ function Header({ columns, width, compact }) {
370
+ const banner = selectBanner(columns);
371
+ const roleParts = portfolio_default.role.split(" \u2022 ");
372
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
373
+ banner ? /* @__PURE__ */ jsx2(Text2, { color: theme_default.colors.primary, children: banner }) : /* @__PURE__ */ jsx2(Text2, { color: theme_default.colors.primary, bold: true, children: portfolio_default.name.toUpperCase() }),
374
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { children: roleParts.map((part, i) => /* @__PURE__ */ jsxs2(Text2, { children: [
375
+ i > 0 ? /* @__PURE__ */ jsxs2(Text2, { color: theme_default.colors.accent, children: [
376
+ " ",
377
+ theme_default.glyphs.bullet,
378
+ " "
379
+ ] }) : null,
380
+ /* @__PURE__ */ jsx2(Text2, { color: theme_default.colors.secondary, children: part })
381
+ ] }, part)) }) }),
382
+ !compact ? /* @__PURE__ */ jsx2(Box2, { marginTop: 1, width, children: /* @__PURE__ */ jsx2(Text2, { color: theme_default.colors.faint, wrap: "wrap", children: portfolio_default.tagline }) }) : null
383
+ ] });
384
+ }
385
+ var Header_default = Header;
386
+
387
+ // src/components/Navigation.jsx
388
+ import React4 from "react";
389
+ import { Box as Box4 } from "ink";
390
+
391
+ // src/hooks/useSelection.js
392
+ import { useState as useState3, useEffect as useEffect3, useCallback } from "react";
393
+ function useSelection(length, { initial = 0, wrap = true } = {}) {
394
+ const [index, setIndex] = useState3(initial);
395
+ useEffect3(() => {
396
+ setIndex((current) => current >= length ? Math.max(0, length - 1) : current);
397
+ }, [length]);
398
+ const move = useCallback(
399
+ (delta) => {
400
+ setIndex((current) => {
401
+ if (length <= 0) return 0;
402
+ const next = current + delta;
403
+ if (wrap) return (next + length) % length;
404
+ return Math.max(0, Math.min(length - 1, next));
405
+ });
406
+ },
407
+ [length, wrap]
408
+ );
409
+ return { index, setIndex, move };
410
+ }
411
+ var useSelection_default = useSelection;
412
+
413
+ // src/components/SelectableList.jsx
414
+ import React3 from "react";
415
+ import { Box as Box3, Text as Text3 } from "ink";
416
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
417
+ function SelectableList({ items, index, inline = false }) {
418
+ const labelWidth = inline ? 0 : items.reduce((max, item) => Math.max(max, item.label.length), 0);
419
+ return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: items.map((item, i) => {
420
+ const selected = i === index;
421
+ const pointer = selected ? `${theme_default.glyphs.pointer} ` : " ";
422
+ const labelColor = item.muted ? theme_default.colors.faint : selected ? theme_default.colors.primary : theme_default.colors.secondary;
423
+ const label = inline || !item.hint ? item.label : item.label.padEnd(labelWidth);
424
+ const hintText = item.hint ? inline ? ` ${theme_default.glyphs.dot} ${item.hint}` : ` ${item.hint}` : "";
425
+ return /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate-end", children: [
426
+ /* @__PURE__ */ jsx3(Text3, { color: selected ? theme_default.colors.accent : theme_default.colors.faint, children: pointer }),
427
+ /* @__PURE__ */ jsx3(Text3, { bold: selected, color: labelColor, children: label }),
428
+ hintText ? /* @__PURE__ */ jsx3(Text3, { color: theme_default.colors.faint, children: hintText }) : null
429
+ ] }, item.key ?? item.label);
430
+ }) });
431
+ }
432
+ var SelectableList_default = SelectableList;
433
+
434
+ // src/components/Navigation.jsx
435
+ import { jsx as jsx4 } from "react/jsx-runtime";
436
+ var MENU = [
437
+ { label: "About", hint: "Who I am", screen: "about" },
438
+ { label: "Skills", hint: "Languages, frameworks & tools", screen: "skills" },
439
+ { label: "Projects", hint: "Things I've built", screen: "projects" },
440
+ { label: "GitHub", hint: `github.com/${portfolio_default.github.username}`, screen: "github" },
441
+ { label: "Contact", hint: "Get in touch", screen: "contact" },
442
+ { label: "Resume", hint: "View my resume", screen: "resume" },
443
+ { label: "Exit", hint: "Close the portfolio", action: "exit" }
444
+ ];
445
+ function Navigation({ isActive, onNavigate, onQuit }) {
446
+ const { index, move } = useSelection_default(MENU.length);
447
+ useSafeInput_default(
448
+ (input, key) => {
449
+ if (key.upArrow || input === "k") move(-1);
450
+ else if (key.downArrow || input === "j") move(1);
451
+ else if (key.return) {
452
+ const item = MENU[index];
453
+ if (item.action === "exit") onQuit();
454
+ else onNavigate(item.screen);
455
+ }
456
+ },
457
+ { isActive }
458
+ );
459
+ return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", children: /* @__PURE__ */ jsx4(SelectableList_default, { items: MENU, index }) });
460
+ }
461
+ var Navigation_default = Navigation;
462
+
463
+ // src/components/Rule.jsx
464
+ import React5 from "react";
465
+ import { Text as Text4 } from "ink";
466
+ import { jsx as jsx5 } from "react/jsx-runtime";
467
+ function Rule({ width, color = theme_default.colors.border, char = "\u2500" }) {
468
+ return /* @__PURE__ */ jsx5(Text4, { color, children: char.repeat(Math.max(0, width)) });
469
+ }
470
+ var Rule_default = Rule;
471
+
472
+ // src/components/Footer.jsx
473
+ import React6 from "react";
474
+ import { Box as Box5, Text as Text5 } from "ink";
475
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
476
+ function Footer({ hints }) {
477
+ return /* @__PURE__ */ jsx6(Box5, { flexWrap: "wrap", children: hints.map((hint, i) => /* @__PURE__ */ jsxs4(Box5, { children: [
478
+ i > 0 ? /* @__PURE__ */ jsxs4(Text5, { color: theme_default.colors.border, children: [
479
+ " ",
480
+ theme_default.glyphs.dot,
481
+ " "
482
+ ] }) : null,
483
+ /* @__PURE__ */ jsxs4(Text5, { color: theme_default.colors.secondary, children: [
484
+ hint.key,
485
+ " "
486
+ ] }),
487
+ /* @__PURE__ */ jsx6(Text5, { color: theme_default.colors.faint, children: hint.label })
488
+ ] }, hint.key)) });
489
+ }
490
+ var Footer_default = Footer;
491
+
492
+ // src/components/Home.jsx
493
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
494
+ var HOME_HINTS = [
495
+ { key: "\u2191\u2193", label: "navigate" },
496
+ { key: "\u23CE", label: "select" },
497
+ { key: ":", label: "command" },
498
+ { key: "q", label: "quit" }
499
+ ];
500
+ function Home({ isActive, onNavigate, onQuit }) {
501
+ const { indent, width, columns, compact } = useLayout_default();
502
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", paddingX: indent, paddingY: 1, width: width + indent * 2, children: [
503
+ /* @__PURE__ */ jsx7(Header_default, { columns, width, compact }),
504
+ /* @__PURE__ */ jsx7(Box6, { marginY: 1, width, children: /* @__PURE__ */ jsx7(Rule_default, { width }) }),
505
+ /* @__PURE__ */ jsx7(Navigation_default, { isActive, onNavigate, onQuit }),
506
+ /* @__PURE__ */ jsx7(Box6, { marginTop: 1, width, children: /* @__PURE__ */ jsx7(Rule_default, { width }) }),
507
+ /* @__PURE__ */ jsx7(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx7(Footer_default, { hints: HOME_HINTS }) })
508
+ ] });
509
+ }
510
+ var Home_default = Home;
511
+
512
+ // src/components/About.jsx
513
+ import React10 from "react";
514
+ import { Box as Box8, Text as Text8 } from "ink";
515
+
516
+ // src/components/Screen.jsx
517
+ import React8 from "react";
518
+ import { Box as Box7, Text as Text6 } from "ink";
519
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
520
+ function Screen({ title, breadcrumb, hints, children }) {
521
+ const { indent, width } = useLayout_default();
522
+ return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", paddingX: indent, paddingY: 1, width: width + indent * 2, children: [
523
+ /* @__PURE__ */ jsxs6(Box7, { marginBottom: 1, children: [
524
+ /* @__PURE__ */ jsx8(Text6, { color: theme_default.colors.accent, children: portfolio_default.name }),
525
+ breadcrumb ? /* @__PURE__ */ jsxs6(Text6, { color: theme_default.colors.faint, children: [
526
+ " ",
527
+ theme_default.glyphs.pointer,
528
+ " ",
529
+ breadcrumb
530
+ ] }) : null
531
+ ] }),
532
+ title ? /* @__PURE__ */ jsx8(Box7, { marginBottom: 1, children: /* @__PURE__ */ jsx8(Text6, { color: theme_default.colors.accent, bold: true, children: title }) }) : null,
533
+ /* @__PURE__ */ jsx8(Box7, { flexDirection: "column", width, children }),
534
+ /* @__PURE__ */ jsx8(Box7, { marginTop: 1, flexDirection: "column", width, children: /* @__PURE__ */ jsx8(Rule_default, { width }) }),
535
+ /* @__PURE__ */ jsx8(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx8(Footer_default, { hints }) })
536
+ ] });
537
+ }
538
+ var Screen_default = Screen;
539
+
540
+ // src/components/Paragraph.jsx
541
+ import React9 from "react";
542
+ import { Text as Text7 } from "ink";
543
+ import { jsx as jsx9 } from "react/jsx-runtime";
544
+ function Paragraph({ children, color = theme_default.colors.secondary, width }) {
545
+ const layout = useLayout_default();
546
+ const text = Array.isArray(children) ? children.join("") : String(children ?? "");
547
+ const lines = wrapWords(text, width ?? layout.width);
548
+ return /* @__PURE__ */ jsx9(Text7, { color, children: lines.join("\n") });
549
+ }
550
+ var Paragraph_default = Paragraph;
551
+
552
+ // src/components/hints.js
553
+ var BACK_HINTS = [
554
+ { key: "esc", label: "back" },
555
+ { key: ":", label: "command" },
556
+ { key: "q", label: "quit" }
557
+ ];
558
+ var LIST_HINTS = [
559
+ { key: "\u2191\u2193", label: "navigate" },
560
+ { key: "\u23CE", label: "open" },
561
+ { key: "esc", label: "back" },
562
+ { key: "q", label: "quit" }
563
+ ];
564
+ var OPEN_HINTS = [
565
+ { key: "\u23CE", label: "open" },
566
+ { key: "esc", label: "back" },
567
+ { key: "q", label: "quit" }
568
+ ];
569
+
570
+ // src/components/About.jsx
571
+ import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
572
+ function About() {
573
+ return /* @__PURE__ */ jsxs7(Screen_default, { title: "ABOUT", breadcrumb: "about", hints: BACK_HINTS, children: [
574
+ portfolio_default.about.map((paragraph, i) => /* @__PURE__ */ jsx10(Box8, { marginBottom: 1, children: /* @__PURE__ */ jsx10(Paragraph_default, { children: paragraph }) }, i)),
575
+ /* @__PURE__ */ jsx10(Box8, { marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx10(Text8, { color: theme_default.colors.accent, children: "CURRENTLY EXPLORING" }) }),
576
+ /* @__PURE__ */ jsx10(Box8, { flexDirection: "column", children: portfolio_default.focus.map((item) => /* @__PURE__ */ jsxs7(Box8, { children: [
577
+ /* @__PURE__ */ jsxs7(Text8, { color: theme_default.colors.faint, children: [
578
+ theme_default.glyphs.bullet,
579
+ " "
580
+ ] }),
581
+ /* @__PURE__ */ jsx10(Text8, { color: theme_default.colors.secondary, children: item })
582
+ ] }, item)) })
583
+ ] });
584
+ }
585
+ var About_default = About;
586
+
587
+ // src/components/Skills.jsx
588
+ import React11 from "react";
589
+ import { Box as Box9, Text as Text9 } from "ink";
590
+ import { jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
591
+ function Skills() {
592
+ const { compact } = useLayout_default();
593
+ const categories = Object.entries(portfolio_default.skills);
594
+ const labelWidth = categories.reduce((max, [name]) => Math.max(max, name.length), 0);
595
+ return /* @__PURE__ */ jsx11(Screen_default, { title: "SKILLS", breadcrumb: "skills", hints: BACK_HINTS, children: /* @__PURE__ */ jsx11(Box9, { flexDirection: "column", children: categories.map(([name, items]) => /* @__PURE__ */ jsxs8(Box9, { flexDirection: compact ? "column" : "row", marginBottom: 1, children: [
596
+ /* @__PURE__ */ jsx11(Box9, { width: compact ? void 0 : labelWidth + 4, children: /* @__PURE__ */ jsx11(Text9, { color: theme_default.colors.accent, children: name.toUpperCase() }) }),
597
+ /* @__PURE__ */ jsx11(Text9, { color: theme_default.colors.secondary, children: items.map((item, i) => /* @__PURE__ */ jsxs8(Text9, { children: [
598
+ i > 0 ? /* @__PURE__ */ jsxs8(Text9, { color: theme_default.colors.border, children: [
599
+ " ",
600
+ theme_default.glyphs.dot,
601
+ " "
602
+ ] }) : null,
603
+ item
604
+ ] }, item)) })
605
+ ] }, name)) }) });
606
+ }
607
+ var Skills_default = Skills;
608
+
609
+ // src/components/Projects.jsx
610
+ import React12 from "react";
611
+ import { Box as Box10, Text as Text10 } from "ink";
612
+ import { jsx as jsx12 } from "react/jsx-runtime";
613
+ function Projects({ isActive, onOpen }) {
614
+ const projects = portfolio_default.projects;
615
+ const { index, move } = useSelection_default(projects.length);
616
+ useSafeInput_default(
617
+ (input, key) => {
618
+ if (key.upArrow || input === "k") move(-1);
619
+ else if (key.downArrow || input === "j") move(1);
620
+ else if (key.return && projects[index]) onOpen(projects[index].id);
621
+ },
622
+ { isActive }
623
+ );
624
+ const items = projects.map((project) => ({
625
+ key: project.id,
626
+ label: project.name,
627
+ hint: project.summary
628
+ }));
629
+ return /* @__PURE__ */ jsx12(Screen_default, { title: "PROJECTS", breadcrumb: "projects", hints: LIST_HINTS, children: projects.length === 0 ? /* @__PURE__ */ jsx12(Text10, { color: theme_default.colors.faint, children: "No projects yet." }) : /* @__PURE__ */ jsx12(SelectableList_default, { items, index, inline: true }) });
630
+ }
631
+ var Projects_default = Projects;
632
+
633
+ // src/components/ProjectDetail.jsx
634
+ import React13 from "react";
635
+ import { Box as Box11, Text as Text11 } from "ink";
636
+ import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
637
+ var LINK_LABELS = { github: "GitHub", docs: "Documentation", live: "Live demo" };
638
+ function ProjectDetail({ isActive, projectId, onOpenLink }) {
639
+ const project = portfolio_default.projects.find((p) => p.id === projectId);
640
+ const links = project ? Object.entries(project.links).filter(([, url]) => isConfigured(url)).map(([kind, url]) => ({ key: kind, label: LINK_LABELS[kind] ?? kind, url })) : [];
641
+ const { index, move } = useSelection_default(links.length);
642
+ useSafeInput_default(
643
+ (input, key) => {
644
+ if (key.upArrow || input === "k") move(-1);
645
+ else if (key.downArrow || input === "j") move(1);
646
+ else if (key.return && links[index]) onOpenLink(links[index].url, links[index].label);
647
+ },
648
+ { isActive }
649
+ );
650
+ if (!project) {
651
+ return /* @__PURE__ */ jsx13(Screen_default, { title: "PROJECT", breadcrumb: "projects", hints: BACK_HINTS, children: /* @__PURE__ */ jsx13(Text11, { color: theme_default.colors.warning, children: "That project could not be found." }) });
652
+ }
653
+ const tech = configuredList(project.tech);
654
+ return /* @__PURE__ */ jsxs9(
655
+ Screen_default,
656
+ {
657
+ title: project.name,
658
+ breadcrumb: `projects ${theme_default.glyphs.pointer} ${project.name.toLowerCase()}`,
659
+ hints: links.length > 0 ? LIST_HINTS : BACK_HINTS,
660
+ children: [
661
+ /* @__PURE__ */ jsx13(Box11, { marginBottom: 1, children: /* @__PURE__ */ jsx13(Paragraph_default, { children: project.description }) }),
662
+ /* @__PURE__ */ jsx13(Section, { label: "TECHNOLOGY", children: tech.length > 0 ? /* @__PURE__ */ jsx13(Text11, { color: theme_default.colors.secondary, children: tech.map((item, i) => /* @__PURE__ */ jsxs9(Text11, { children: [
663
+ i > 0 ? /* @__PURE__ */ jsxs9(Text11, { color: theme_default.colors.border, children: [
664
+ " ",
665
+ theme_default.glyphs.dot,
666
+ " "
667
+ ] }) : null,
668
+ item
669
+ ] }, item)) }) : /* @__PURE__ */ jsx13(Text11, { color: theme_default.colors.faint, children: "Not listed yet." }) }),
670
+ /* @__PURE__ */ jsx13(Section, { label: "STATUS", children: /* @__PURE__ */ jsx13(Text11, { color: theme_default.colors.accent, children: project.status }) }),
671
+ /* @__PURE__ */ jsx13(Section, { label: "LINKS", children: links.length > 0 ? /* @__PURE__ */ jsx13(SelectableList_default, { items: links.map((l) => ({ key: l.key, label: l.label, hint: l.url })), index }) : /* @__PURE__ */ jsx13(Text11, { color: theme_default.colors.faint, children: "No links yet \u2014 add them in portfolio.js." }) })
672
+ ]
673
+ }
674
+ );
675
+ }
676
+ function Section({ label, children }) {
677
+ return /* @__PURE__ */ jsxs9(Box11, { flexDirection: "column", marginBottom: 1, children: [
678
+ /* @__PURE__ */ jsx13(Text11, { color: theme_default.colors.faint, children: label }),
679
+ /* @__PURE__ */ jsx13(Box11, { children })
680
+ ] });
681
+ }
682
+ var ProjectDetail_default = ProjectDetail;
683
+
684
+ // src/components/Github.jsx
685
+ import React14, { useEffect as useEffect4, useState as useState4 } from "react";
686
+ import { Box as Box12, Text as Text12 } from "ink";
687
+
688
+ // src/utils/github.js
689
+ async function getGithubProfile(github) {
690
+ return {
691
+ data: {
692
+ username: github.username,
693
+ url: github.url,
694
+ blurb: github.blurb
695
+ // The following are intentionally omitted until wired to the live API,
696
+ // so the UI never shows fabricated numbers:
697
+ // repos, followers, stars, contributions.
698
+ },
699
+ error: null
700
+ };
701
+ }
702
+
703
+ // src/components/Github.jsx
704
+ import { jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
705
+ function Github({ isActive, onOpenLink }) {
706
+ const [state, setState] = useState4({ loading: true, data: null, error: null });
707
+ useEffect4(() => {
708
+ let alive = true;
709
+ getGithubProfile(portfolio_default.github).then((result) => {
710
+ if (alive) setState({ loading: false, data: result.data, error: result.error });
711
+ });
712
+ return () => {
713
+ alive = false;
714
+ };
715
+ }, []);
716
+ useSafeInput_default(
717
+ (input, key) => {
718
+ if (key.return) onOpenLink(portfolio_default.github.url, "GitHub profile");
719
+ },
720
+ { isActive }
721
+ );
722
+ const profile = state.data ?? portfolio_default.github;
723
+ return /* @__PURE__ */ jsxs10(Screen_default, { title: "GITHUB", breadcrumb: "github", hints: OPEN_HINTS, children: [
724
+ /* @__PURE__ */ jsxs10(Box12, { children: [
725
+ /* @__PURE__ */ jsx14(Text12, { color: theme_default.colors.secondary, children: "@" }),
726
+ /* @__PURE__ */ jsx14(Text12, { color: theme_default.colors.primary, bold: true, children: profile.username })
727
+ ] }),
728
+ /* @__PURE__ */ jsx14(Box12, { marginTop: 0, children: /* @__PURE__ */ jsx14(Text12, { color: theme_default.colors.link, underline: true, children: profile.url }) }),
729
+ /* @__PURE__ */ jsx14(Box12, { marginTop: 1, children: /* @__PURE__ */ jsx14(Text12, { color: theme_default.colors.secondary, wrap: "wrap", children: profile.blurb }) }),
730
+ state.error ? /* @__PURE__ */ jsx14(Box12, { marginTop: 1, children: /* @__PURE__ */ jsxs10(Text12, { color: theme_default.colors.warning, children: [
731
+ state.error,
732
+ " \u2014 showing offline info."
733
+ ] }) }) : null,
734
+ /* @__PURE__ */ jsx14(Box12, { marginTop: 1, children: /* @__PURE__ */ jsxs10(Text12, { color: theme_default.colors.faint, children: [
735
+ "Press ",
736
+ /* @__PURE__ */ jsx14(Text12, { color: theme_default.colors.accent, children: "\u23CE" }),
737
+ " to open the profile in your browser."
738
+ ] }) })
739
+ ] });
740
+ }
741
+ var Github_default = Github;
742
+
743
+ // src/components/Contact.jsx
744
+ import React15 from "react";
745
+ import { Box as Box13, Text as Text13 } from "ink";
746
+ import { jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
747
+ function Contact({ isActive, onOpenLink }) {
748
+ const { links } = portfolio_default;
749
+ const channels = [
750
+ { key: "github", label: "GitHub", value: links.github, url: links.github },
751
+ { key: "linkedin", label: "LinkedIn", value: links.linkedin, url: links.linkedin },
752
+ { key: "email", label: "Email", value: links.email, url: toMailto(links.email) }
753
+ ];
754
+ const configured = channels.filter((c) => isConfigured(c.value));
755
+ const missing = channels.filter((c) => !isConfigured(c.value)).map((c) => c.label);
756
+ const { index, move } = useSelection_default(configured.length);
757
+ useSafeInput_default(
758
+ (input, key) => {
759
+ if (key.upArrow || input === "k") move(-1);
760
+ else if (key.downArrow || input === "j") move(1);
761
+ else if (key.return && configured[index]) {
762
+ onOpenLink(configured[index].url, configured[index].label);
763
+ }
764
+ },
765
+ { isActive }
766
+ );
767
+ return /* @__PURE__ */ jsxs11(
768
+ Screen_default,
769
+ {
770
+ title: "CONTACT",
771
+ breadcrumb: "contact",
772
+ hints: configured.length > 0 ? LIST_HINTS : BACK_HINTS,
773
+ children: [
774
+ configured.length > 0 ? /* @__PURE__ */ jsx15(
775
+ SelectableList_default,
776
+ {
777
+ items: configured.map((c) => ({ key: c.key, label: c.label, hint: c.value })),
778
+ index
779
+ }
780
+ ) : /* @__PURE__ */ jsx15(Text13, { color: theme_default.colors.faint, children: "No contact channels configured yet." }),
781
+ missing.length > 0 ? /* @__PURE__ */ jsx15(Box13, { marginTop: 1, children: /* @__PURE__ */ jsxs11(Text13, { color: theme_default.colors.faint, children: [
782
+ "Not set up yet: ",
783
+ missing.join(", "),
784
+ " \u2014 add them in portfolio.js."
785
+ ] }) }) : null
786
+ ]
787
+ }
788
+ );
789
+ }
790
+ var Contact_default = Contact;
791
+
792
+ // src/components/Resume.jsx
793
+ import React16 from "react";
794
+ import { Box as Box14, Text as Text14 } from "ink";
795
+ import { jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
796
+ function Resume({ isActive, onOpenLink }) {
797
+ const url = portfolio_default.links.resume;
798
+ const configured = isConfigured(url);
799
+ useSafeInput_default(
800
+ (input, key) => {
801
+ if (key.return && configured) onOpenLink(url, "Resume");
802
+ },
803
+ { isActive }
804
+ );
805
+ return /* @__PURE__ */ jsx16(Screen_default, { title: "RESUME", breadcrumb: "resume", hints: configured ? OPEN_HINTS : BACK_HINTS, children: configured ? /* @__PURE__ */ jsxs12(Box14, { flexDirection: "column", children: [
806
+ /* @__PURE__ */ jsx16(Text14, { color: theme_default.colors.link, underline: true, children: url }),
807
+ /* @__PURE__ */ jsx16(Box14, { marginTop: 1, children: /* @__PURE__ */ jsxs12(Text14, { color: theme_default.colors.faint, children: [
808
+ "Press ",
809
+ /* @__PURE__ */ jsx16(Text14, { color: theme_default.colors.accent, children: "\u23CE" }),
810
+ " to open the resume."
811
+ ] }) })
812
+ ] }) : /* @__PURE__ */ jsxs12(Box14, { flexDirection: "column", children: [
813
+ /* @__PURE__ */ jsx16(Text14, { color: theme_default.colors.warning, children: "Resume link has not been configured yet." }),
814
+ /* @__PURE__ */ jsx16(Box14, { marginTop: 1, children: /* @__PURE__ */ jsx16(Text14, { color: theme_default.colors.faint, children: "Set links.resume in portfolio.js to enable it." }) })
815
+ ] }) });
816
+ }
817
+ var Resume_default = Resume;
818
+
819
+ // src/components/Help.jsx
820
+ import React17 from "react";
821
+ import { Box as Box15, Text as Text15 } from "ink";
822
+ import { jsx as jsx17, jsxs as jsxs13 } from "react/jsx-runtime";
823
+ function Help() {
824
+ const nameWidth = COMMANDS.reduce((max, c) => Math.max(max, c.name.length), 0);
825
+ return /* @__PURE__ */ jsxs13(Screen_default, { title: "HELP", breadcrumb: "help", hints: BACK_HINTS, children: [
826
+ /* @__PURE__ */ jsx17(Box15, { marginBottom: 1, children: /* @__PURE__ */ jsxs13(Text15, { color: theme_default.colors.secondary, children: [
827
+ "Press ",
828
+ /* @__PURE__ */ jsx17(Text15, { color: theme_default.colors.accent, children: ":" }),
829
+ " anywhere to open the command bar, then type a command."
830
+ ] }) }),
831
+ /* @__PURE__ */ jsx17(Box15, { flexDirection: "column", children: COMMANDS.map((command) => /* @__PURE__ */ jsxs13(Box15, { children: [
832
+ /* @__PURE__ */ jsx17(Text15, { color: theme_default.colors.accent, children: command.name.padEnd(nameWidth + 4) }),
833
+ /* @__PURE__ */ jsx17(Text15, { color: theme_default.colors.secondary, children: command.description })
834
+ ] }, command.name)) })
835
+ ] });
836
+ }
837
+ var Help_default = Help;
838
+
839
+ // src/components/EasterEgg.jsx
840
+ import React18 from "react";
841
+ import { Box as Box16, Text as Text16 } from "ink";
842
+ import { jsx as jsx18, jsxs as jsxs14 } from "react/jsx-runtime";
843
+ var ART = ["\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510", "\u2502 > _ \u2502", "\u2502 \u2502", "\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"];
844
+ var SWATCHES = [
845
+ theme_default.colors.accent,
846
+ theme_default.colors.link,
847
+ theme_default.colors.warning,
848
+ theme_default.colors.primary,
849
+ theme_default.colors.secondary,
850
+ theme_default.colors.faint
851
+ ];
852
+ function EasterEgg() {
853
+ const { compact } = useLayout_default();
854
+ const title = `${portfolio_default.github.username}@portfolio`;
855
+ const languages = (portfolio_default.skills.Languages ?? []).join(" \xB7 ");
856
+ const info = [
857
+ ["OS", "ShivenOS \xB7 terminal edition"],
858
+ ["Host", "npx shivenverma"],
859
+ ["Shell", "your terminal, thankfully"],
860
+ ["Uptime", "since you ran the command"],
861
+ ["Languages", languages || "\u2014"],
862
+ ["Motto", "build \xB7 break \xB7 learn"]
863
+ ];
864
+ return /* @__PURE__ */ jsxs14(Screen_default, { title: "WHOAMI", breadcrumb: "easter egg", hints: [{ key: "esc", label: "back" }], children: [
865
+ /* @__PURE__ */ jsxs14(Box16, { flexDirection: compact ? "column" : "row", children: [
866
+ /* @__PURE__ */ jsx18(Box16, { flexDirection: "column", marginRight: compact ? 0 : 3, children: ART.map((line, i) => /* @__PURE__ */ jsx18(Text16, { color: theme_default.colors.accent, children: line }, i)) }),
867
+ /* @__PURE__ */ jsxs14(Box16, { flexDirection: "column", children: [
868
+ /* @__PURE__ */ jsx18(Text16, { color: theme_default.colors.accent, bold: true, children: title }),
869
+ /* @__PURE__ */ jsx18(Text16, { color: theme_default.colors.border, children: "\u2500".repeat(title.length) }),
870
+ info.map(([label, value]) => /* @__PURE__ */ jsxs14(Box16, { children: [
871
+ /* @__PURE__ */ jsx18(Text16, { color: theme_default.colors.accent, children: label }),
872
+ /* @__PURE__ */ jsxs14(Text16, { color: theme_default.colors.secondary, children: [
873
+ ": ",
874
+ value
875
+ ] })
876
+ ] }, label)),
877
+ /* @__PURE__ */ jsx18(Box16, { marginTop: 1, children: SWATCHES.map((color, i) => /* @__PURE__ */ jsx18(Text16, { color, children: "\u2588\u2588" }, i)) })
878
+ ] })
879
+ ] }),
880
+ /* @__PURE__ */ jsx18(Box16, { marginTop: 1, children: /* @__PURE__ */ jsx18(Text16, { color: theme_default.colors.faint, children: "we trust you have received the usual lecture from the local system administrator." }) })
881
+ ] });
882
+ }
883
+ var EasterEgg_default = EasterEgg;
884
+
885
+ // src/components/CommandBar.jsx
886
+ import React19 from "react";
887
+ import { Box as Box17, Text as Text17 } from "ink";
888
+ import { jsx as jsx19, jsxs as jsxs15 } from "react/jsx-runtime";
889
+ var STATUS_COLORS = {
890
+ success: theme_default.colors.accent,
891
+ warn: theme_default.colors.warning,
892
+ error: theme_default.colors.warning,
893
+ info: theme_default.colors.secondary
894
+ };
895
+ function CommandBar({ active, buffer }) {
896
+ const { indent } = useLayout_default();
897
+ if (!active) return null;
898
+ return /* @__PURE__ */ jsxs15(Box17, { paddingX: indent, children: [
899
+ /* @__PURE__ */ jsx19(Text17, { color: theme_default.colors.accent, children: ": " }),
900
+ /* @__PURE__ */ jsx19(Text17, { color: theme_default.colors.primary, children: buffer }),
901
+ /* @__PURE__ */ jsx19(Text17, { color: theme_default.colors.accent, children: "\u258B" })
902
+ ] });
903
+ }
904
+ function StatusLine({ status }) {
905
+ const { indent } = useLayout_default();
906
+ if (!status) return null;
907
+ return /* @__PURE__ */ jsxs15(Box17, { paddingX: indent, children: [
908
+ /* @__PURE__ */ jsxs15(Text17, { color: theme_default.colors.faint, children: [
909
+ theme_default.glyphs.corner,
910
+ " "
911
+ ] }),
912
+ /* @__PURE__ */ jsx19(Text17, { color: STATUS_COLORS[status.type] ?? theme_default.colors.secondary, wrap: "truncate-end", children: status.text })
913
+ ] });
914
+ }
915
+
916
+ // src/App.jsx
917
+ import { jsx as jsx20, jsxs as jsxs16 } from "react/jsx-runtime";
918
+ function App({ interactive: interactive2 = true, skipStartup: skipStartup2 = false }) {
919
+ const { exit } = useApp();
920
+ const [booting, setBooting] = useState5(interactive2 && !skipStartup2);
921
+ const [stack, setStack] = useState5(["home"]);
922
+ const [projectId, setProjectId] = useState5(null);
923
+ const [command, setCommand] = useState5({ active: false, buffer: "" });
924
+ const [status, setStatus] = useState5(null);
925
+ const screen = stack[stack.length - 1];
926
+ const push = (next) => {
927
+ setStatus(null);
928
+ setStack((st) => [...st, next]);
929
+ };
930
+ const back = () => {
931
+ setStatus(null);
932
+ setStack((st) => st.length > 1 ? st.slice(0, -1) : st);
933
+ };
934
+ const goHome = () => {
935
+ setStatus(null);
936
+ setStack(["home"]);
937
+ };
938
+ const jumpTo = (next) => {
939
+ setStatus(null);
940
+ setStack(next === "home" ? ["home"] : ["home", next]);
941
+ };
942
+ const openProject = (id) => {
943
+ setProjectId(id);
944
+ push("project");
945
+ };
946
+ const quit = () => {
947
+ setStatus(null);
948
+ setStack(["bye"]);
949
+ setTimeout(() => exit(), 60);
950
+ };
951
+ const openLink = async (url, label) => {
952
+ setStatus({ type: "info", text: `Opening ${label}\u2026` });
953
+ const result = await openUrl(url);
954
+ if (result.ok) {
955
+ setStatus({ type: "success", text: `Opened ${label} in your browser.` });
956
+ } else {
957
+ setStatus({ type: "warn", text: `Couldn't open a browser. Copy this: ${url}` });
958
+ }
959
+ };
960
+ const runCommand = (raw) => {
961
+ const result = matchCommand(raw);
962
+ setCommand({ active: false, buffer: "" });
963
+ if (result.type === "screen") return jumpTo(result.screen);
964
+ if (result.type === "action") {
965
+ if (result.action === "exit") return quit();
966
+ if (result.action === "clear") return goHome();
967
+ if (result.action === "easter-egg") return jumpTo("egg");
968
+ }
969
+ if (result.type === "unknown") {
970
+ setStatus({
971
+ type: "warn",
972
+ text: `command not found: ${result.input} \u2014 type "help" to list commands.`
973
+ });
974
+ }
975
+ };
976
+ useSafeInput_default(
977
+ (input, key) => {
978
+ if (key.escape) return back();
979
+ if (input === "q") return quit();
980
+ if (input === ":" || input === "/") setCommand({ active: true, buffer: "" });
981
+ else if (input === "?") jumpTo("help");
982
+ },
983
+ { isActive: !command.active && !booting && screen !== "bye" }
984
+ );
985
+ useSafeInput_default(
986
+ (input, key) => {
987
+ if (key.escape) return setCommand({ active: false, buffer: "" });
988
+ if (key.return) return runCommand(command.buffer);
989
+ if (key.backspace || key.delete) {
990
+ return setCommand((c) => ({ ...c, buffer: c.buffer.slice(0, -1) }));
991
+ }
992
+ if (key.ctrl && input === "u") return setCommand((c) => ({ ...c, buffer: "" }));
993
+ if (input && !key.ctrl && !key.meta && !key.tab) {
994
+ setCommand((c) => ({ ...c, buffer: c.buffer + input }));
995
+ }
996
+ },
997
+ { isActive: command.active && !booting }
998
+ );
999
+ if (booting) {
1000
+ return /* @__PURE__ */ jsx20(Startup_default, { onComplete: () => setBooting(false) });
1001
+ }
1002
+ const inputActive = !command.active;
1003
+ return /* @__PURE__ */ jsxs16(Box18, { flexDirection: "column", children: [
1004
+ renderScreen({ screen, inputActive, projectId, push, openProject, openLink, quit }),
1005
+ /* @__PURE__ */ jsx20(StatusLine, { status }),
1006
+ /* @__PURE__ */ jsx20(CommandBar, { active: command.active, buffer: command.buffer })
1007
+ ] });
1008
+ }
1009
+ function renderScreen({ screen, inputActive, projectId, push, openProject, openLink, quit }) {
1010
+ switch (screen) {
1011
+ case "about":
1012
+ return /* @__PURE__ */ jsx20(About_default, {});
1013
+ case "skills":
1014
+ return /* @__PURE__ */ jsx20(Skills_default, {});
1015
+ case "projects":
1016
+ return /* @__PURE__ */ jsx20(Projects_default, { isActive: inputActive, onOpen: openProject });
1017
+ case "project":
1018
+ return /* @__PURE__ */ jsx20(ProjectDetail_default, { isActive: inputActive, projectId, onOpenLink: openLink });
1019
+ case "github":
1020
+ return /* @__PURE__ */ jsx20(Github_default, { isActive: inputActive, onOpenLink: openLink });
1021
+ case "contact":
1022
+ return /* @__PURE__ */ jsx20(Contact_default, { isActive: inputActive, onOpenLink: openLink });
1023
+ case "resume":
1024
+ return /* @__PURE__ */ jsx20(Resume_default, { isActive: inputActive, onOpenLink: openLink });
1025
+ case "help":
1026
+ return /* @__PURE__ */ jsx20(Help_default, {});
1027
+ case "egg":
1028
+ return /* @__PURE__ */ jsx20(EasterEgg_default, {});
1029
+ case "bye":
1030
+ return /* @__PURE__ */ jsx20(Farewell, {});
1031
+ case "home":
1032
+ default:
1033
+ return /* @__PURE__ */ jsx20(Home_default, { isActive: inputActive, onNavigate: push, onQuit: quit });
1034
+ }
1035
+ }
1036
+ function Farewell() {
1037
+ const { indent } = useLayout_default();
1038
+ return /* @__PURE__ */ jsxs16(Box18, { flexDirection: "column", paddingX: indent, paddingY: 1, children: [
1039
+ /* @__PURE__ */ jsx20(Text18, { color: theme_default.colors.accent, children: "Thanks for stopping by." }),
1040
+ /* @__PURE__ */ jsxs16(Text18, { color: theme_default.colors.faint, children: [
1041
+ "github.com/",
1042
+ portfolio_default.github.username,
1043
+ " ",
1044
+ theme_default.glyphs.dot,
1045
+ " npx shivenverma"
1046
+ ] })
1047
+ ] });
1048
+ }
1049
+ var App_default = App;
1050
+
1051
+ // src/index.jsx
1052
+ import { jsx as jsx21 } from "react/jsx-runtime";
1053
+ var VERSION = true ? "1.0.0" : "0.0.0";
1054
+ var args = process.argv.slice(2);
1055
+ var has = (...names) => names.some((name) => args.includes(name));
1056
+ if (has("-v", "--version")) {
1057
+ console.log(VERSION);
1058
+ process.exit(0);
1059
+ }
1060
+ if (has("-h", "--help")) {
1061
+ printHelp();
1062
+ process.exit(0);
1063
+ }
1064
+ var interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY) && !has("--ci");
1065
+ var skipStartup = has("--no-startup", "--no-anim", "--skip") || !interactive;
1066
+ try {
1067
+ const instance = render(/* @__PURE__ */ jsx21(App_default, { interactive, skipStartup }), {
1068
+ exitOnCtrlC: true,
1069
+ // In one-shot / piped mode we don't want Ink buffering console output; this
1070
+ // also surfaces render errors on real stderr instead of swallowing them.
1071
+ patchConsole: interactive
1072
+ });
1073
+ if (!interactive) {
1074
+ setTimeout(() => instance.unmount(), 200);
1075
+ setTimeout(() => process.exit(0), 900).unref();
1076
+ }
1077
+ instance.waitUntilExit().catch(() => {
1078
+ });
1079
+ } catch (error) {
1080
+ console.error("shivenverma failed to start:", error?.message ?? error);
1081
+ process.exit(1);
1082
+ }
1083
+ function printHelp() {
1084
+ const lines = [
1085
+ "",
1086
+ " shivenverma \u2014 an interactive terminal portfolio",
1087
+ "",
1088
+ " Usage",
1089
+ " npx shivenverma launch the portfolio",
1090
+ " shivenverma [options]",
1091
+ "",
1092
+ " Options",
1093
+ " -h, --help show this help",
1094
+ " -v, --version print the version",
1095
+ " --no-startup skip the boot animation",
1096
+ "",
1097
+ " Once inside",
1098
+ " \u2191 \u2193 / j k navigate \u23CE select esc back",
1099
+ " : command mode q quit",
1100
+ ""
1101
+ ];
1102
+ console.log(lines.join("\n"));
1103
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "shivenverma",
3
+ "version": "1.0.0",
4
+ "description": "An interactive terminal portfolio for Shiven Verma. Run it with: npx shivenverma",
5
+ "type": "module",
6
+ "bin": {
7
+ "shivenverma": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "node scripts/build.mjs",
17
+ "banner": "node scripts/gen-banner.mjs",
18
+ "dev": "node scripts/build.mjs && node bin/cli.js",
19
+ "start": "node bin/cli.js",
20
+ "test": "node scripts/build.mjs && node scripts/smoke-test.mjs",
21
+ "package": "node scripts/build.mjs && npm pack",
22
+ "prepublishOnly": "node scripts/build.mjs"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "keywords": [
28
+ "portfolio",
29
+ "cli",
30
+ "terminal",
31
+ "resume",
32
+ "developer",
33
+ "ink",
34
+ "react",
35
+ "npx",
36
+ "interactive",
37
+ "tui",
38
+ "shiven-verma"
39
+ ],
40
+ "author": "Shiven Verma",
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/shivenverma/shivenverma.git"
45
+ },
46
+ "homepage": "https://github.com/shivenverma/shivenverma#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/shivenverma/shivenverma/issues"
49
+ },
50
+ "dependencies": {
51
+ "ink": "^5.0.1",
52
+ "react": "^18.3.1"
53
+ },
54
+ "devDependencies": {
55
+ "esbuild": "^0.23.1",
56
+ "figlet": "^1.7.0"
57
+ }
58
+ }