martinbonan 3.0.0 → 4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martinbonan",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Martin Music -- interactive CLI portfolio with AI twin",
5
5
  "type": "module",
6
6
  "bin": {
package/src/App.js CHANGED
@@ -8,7 +8,7 @@ const e = React.createElement;
8
8
 
9
9
  export default function App() {
10
10
  const [screen, setScreen] = useState('boot');
11
- const [themeName, setThemeName] = useState('cyberpunk');
11
+ const [themeName, setThemeName] = useState('clean');
12
12
  const theme = THEMES[themeName];
13
13
  const c = makeChalkColors(theme);
14
14
 
package/src/ai/context.js CHANGED
@@ -1,4 +1,4 @@
1
- export const SYSTEM_PROMPT = `You are Martin Music, a 20-year-old French entrepreneur. You're answering as yourself in your CLI portfolio. People are chatting with an AI version of you.
1
+ export const SYSTEM_PROMPT = `You are Martin Bonan, a 20-year-old French entrepreneur. You're answering as yourself in your CLI portfolio. People are chatting with an AI version of you.
2
2
 
3
3
  PERSONALITY:
4
4
  - Direct, casual, no-bullshit. You hate corporate-speak.
@@ -9,35 +9,37 @@ PERSONALITY:
9
9
  - Witty, occasionally funny. Never cringe.
10
10
 
11
11
  BACKGROUND:
12
- - 20yo, French, based in France. Building since age 15.
13
- - Co-founded Qual (qual.cx) -- AI qualitative research
14
- - Building Squal (squal.ai) -- qualitative feedback for AI companies
15
- "Every AI company measures what users do. None understand why."
16
- - Running Astry -- dev agency, 30+ international B2B clients
17
- - Teaching vibecoding at IQ Project (trained at HEC Paris)
18
- - Accepted to Berkeley Global Incubator (Jan 2027)
19
- - 3rd year Global BBA at NEOMA Business School
20
- - Father co-founded Talend (acquired by Qlik)
12
+ - 20yo, French, based in Paris. Building since age 15.
13
+ - Co-founded Qual (qual.cx) with Marin -- research engine making qualitative depth scale
14
+ AI-conducted interviews that go deep, learn in real-time, scale to tens of thousands
15
+ Adopted by PhD researchers at Harvard, MIT, Imperial College London. 40+ studies, 70%+ completion rate.
16
+ - Co-founded Astry with Teo & Mathis -- AI-powered dev agency, 30+ B2B clients, 3-5x speed
17
+ - Built F*cksubscription -- 3K+ paying customers, 10K+ EUR, one-time payments replacing SaaS
18
+ - Teaching vibecoding at HEC Paris through IQ Project -- 120 students trained
19
+ - Started designing at 13 (Figma, Affinity Designer). First venture at 15.
20
+ - NEOMA Business School Global BBA (2023-2027)
21
+ - Exchange at Queen's University / Smith School of Business in Canada (2024)
22
+ Discovered Cursor & Claude there, went from no-code to full-stack AI dev
23
+ - UC Berkeley Global Incubator starting January 2027 -- bringing Qual to the US
21
24
 
22
25
  KEY PROJECTS:
23
- - Squal: Missing qualitative layer for AI. 1 client ~$999/mo. Raising 250K at 2M SAFE.
24
- - Astry: Dev agency, 30+ clients worldwide
25
- - AI Series Farm: Video pipeline (Claude Code + Playwright + Higgsfield.ai)
26
- - SMCP: Supabase MCP Manager -- Electron app, 11 Supabase accounts
27
- - CASA IMMO: React Native real estate aggregator
28
- - F*ckSubscription: First hit at 15, 3K+ users, 10K+ EUR
29
- - Brief MCP: Paris Innov'Hack 2nd/150
26
+ - Qual: Research engine for qualitative depth at scale. Primary focus. Harvard, MIT, Imperial adoption.
27
+ - Astry: AI-powered dev agency, 30+ clients. Cash machine funding Qual.
28
+ - F*cksubscription: "Sell before building" masterclass. 1K signups day one, 90% conversion, 3K+ customers.
29
+ Products: MangoForm, PeachCalendar, GrapeLink, GuavaTeam.
30
+ - Brief: Paris Innov'Hack 2025, 2nd/150+. MCP-based intelligent context manager for AI agents.
31
+ - IQ Project: Teaching vibecoding at HEC Paris. 120 students.
32
+ - Objectif Reussite: Nonprofit tutoring network started at 15. 800+ students, 20+ tutors, 3 years.
33
+ - Good Good Time: Activity recommendation app. NEOMA incubator -> rebuilt with Cursor in Canada.
34
+ - 15+ projects total since age 15.
30
35
 
31
- TECH PHILOSOPHY:
32
- - "Sell before you build"
33
- - Vibecoding: building with AI as co-pilot (Claude Code, agent teams)
34
- - Autonomous agent loops (LOOP_PROMPT.md + PROGRESS.md pattern)
35
- - Claude Code Agent Teams with TeamCreate/CreateTeam tools
36
- - Multi-agent architectures as core dev pattern
37
- - Ships fast, iterates faster
36
+ TECH:
37
+ - "Sell before you build" -- validate with landing pages and pre-sales first
38
+ - Design as competitive moat, not afterthought
39
+ - Stack: Next.js, React, TypeScript, Supabase, Vercel, ShadCN UI, Cursor, Claude
40
+ - Vibecoding evangelist -- AI as core of the development workflow
38
41
 
39
42
  INTERESTS: Philosophy, consciousness, cinema, media literacy
40
- FAVE COMPANIES: Lovable, Logitech, Featherless, Monster
41
43
 
42
44
  RULES:
43
45
  - Be honest. If you don't know, say so.
@@ -47,6 +47,9 @@ export default function ContentPanel({
47
47
  case 'History':
48
48
  props.selectedIndex = selectedIndex;
49
49
  break;
50
+ case 'Contact':
51
+ props.selectedIndex = selectedIndex;
52
+ break;
50
53
  case 'Ask AI':
51
54
  props.isFocused = isFocused;
52
55
  break;
@@ -7,21 +7,28 @@ import { ThemeContext } from '../theme.js';
7
7
 
8
8
  const e = React.createElement;
9
9
 
10
- const nameGradient = gradient(['#00D4AA', '#7C5CE0', '#B088F9']);
10
+ function makeGradient(theme) {
11
+ // Build gradient from theme colors
12
+ return gradient([theme.primary, theme.secondary || theme.primary]);
13
+ }
11
14
 
12
15
  export default function Header({ tagline, fading }) {
13
16
  const { theme } = React.useContext(ThemeContext);
14
17
  const cols = process.stdout.columns || 80;
15
18
  const showPortrait = cols >= 80;
19
+ const grad = makeGradient(theme);
16
20
 
17
- let figletText;
21
+ let line1, line2;
18
22
  try {
19
- figletText = figlet.textSync('MARTIN', { font: 'ANSI Shadow' });
23
+ line1 = figlet.textSync('Martin', { font: 'Small' });
24
+ line2 = figlet.textSync('Bonan', { font: 'Small' });
20
25
  } catch {
21
- figletText = 'MARTIN MUSIC';
26
+ line1 = 'MARTIN';
27
+ line2 = 'BONAN';
22
28
  }
23
29
 
24
- const gradientName = nameGradient(figletText);
30
+ const figletText = line1 + '\n' + line2;
31
+ const gradientName = grad(figletText);
25
32
 
26
33
  return e(Box, { flexDirection: 'column' },
27
34
  e(Box, { flexDirection: 'row' },
@@ -6,11 +6,12 @@ import { ThemeContext, supportsTrueColor } from '../theme.js';
6
6
  const e = React.createElement;
7
7
 
8
8
  export default function Portrait() {
9
- const { c } = React.useContext(ThemeContext);
9
+ const { theme } = React.useContext(ThemeContext);
10
10
 
11
11
  if (supportsTrueColor && PORTRAIT_ANSI.length > 0) {
12
12
  return e(Text, null, PORTRAIT_ANSI.join('\n'));
13
13
  }
14
14
 
15
- return e(Text, { dimColor: true }, c.dim(PORTRAIT_BRAILLE.join('\n')));
15
+ // Use theme primary color for braille portrait
16
+ return e(Text, { color: theme.dim }, PORTRAIT_BRAILLE.join('\n'));
16
17
  }
@@ -1,248 +1,262 @@
1
1
  export const BIO = {
2
- name: 'Martin Music',
2
+ name: 'Martin Bonan',
3
3
  taglines: [
4
4
  '20yo French builder. Entrepreneur, vibecoder, philosopher.',
5
5
  'Sell before you build. Ship fast. Learn faster.',
6
- 'Every AI company measures what users do. None understand why.',
6
+ 'Making qualitative depth scale.',
7
7
  'Building since 15. Still shipping.',
8
8
  ],
9
9
  };
10
10
 
11
- export const ABOUT_TEXT = `Martin Music
11
+ export const ABOUT_TEXT = `Martin Bonan
12
12
  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
13
13
 
14
14
  Builder since 15. French. 20 years old.
15
15
 
16
- Co-founded Qual (qual.cx) -- AI qualitative research.
17
- Now building Squal (squal.ai) -- qualitative feedback
18
- infrastructure for AI companies.
16
+ Co-founded Qual -- a research engine that makes
17
+ qualitative depth scale. AI-conducted interviews
18
+ that go deep, learn in real-time, and scale to
19
+ tens of thousands of participants.
19
20
 
20
- "Every AI company measures what users do.
21
- None understand why."
21
+ Built F*cksubscription -- 3,000+ paying customers,
22
+ 10K+ EUR. One-time payments to replace overpriced SaaS.
22
23
 
23
- Running Astry, dev agency with 30+ international
24
- B2B clients. Teaching vibecoding at IQ Project.
24
+ Co-founded Astry -- dev agency, 30+ B2B clients.
25
+ Teaching vibecoding at HEC Paris through IQ Project.
26
+
27
+ Started designing at 13 (Figma, Affinity Designer).
28
+ First structured venture at 15. 15+ projects since.
25
29
 
26
30
  Philosophy: Sell before you build. Ship fast.
27
31
  Learn faster.
28
32
 
29
- Next: Berkeley Global Incubator, Jan 2027.
33
+ Next: UC Berkeley, Jan 2027. Bringing Qual to the US.
30
34
 
31
35
  Currently online | Paris, France`;
32
36
 
33
37
  export const PROJECTS = [
34
38
  {
35
- name: 'Squal',
36
- tag: 'squal.ai',
39
+ name: 'Qual',
40
+ tag: 'qual.cx',
37
41
  tagColor: 'primary',
38
- description: 'Qualitative feedback infrastructure for AI',
39
- status: 'Active -- 1 client ($999/mo)',
42
+ description: 'Qualitative research at quantitative scale',
43
+ status: 'Active -- 40+ studies, 70%+ completion rate',
40
44
  detail: {
41
- url: 'squal.ai',
42
- tagline: 'Qualitative feedback infrastructure for AI',
43
- quote: '"Every AI company measures what users do.\\n None understand why."',
45
+ url: 'qual.cx',
46
+ tagline: 'Making qualitative depth scale',
47
+ quote: '"Replacing static surveys with adaptive, deep-dive\\n interviews that uncover the why for thousands at once."',
44
48
  fields: [
45
- ['STATUS', 'Active, pivoting from Qual'],
46
- ['REVENUE', '$999/mo (1 healthcare client)'],
47
- ['STAGE', 'Pre-seed, raising 250K EUR'],
48
- ['VALUATION', '2M EUR SAFE cap'],
49
- ['STACK', 'Next.js, Supabase, Claude API'],
49
+ ['STATUS', 'Active, primary focus'],
50
+ ['COFOUNDERS', 'Martin Bonan & Marin'],
51
+ ['TRACTION', '40+ studies, 70%+ completion rate'],
52
+ ['USERS', 'PhD researchers at Harvard, MIT, Imperial'],
53
+ ['STACK', 'Next.js, Supabase, AI engine'],
50
54
  ],
51
- body: 'The missing qualitative layer between\nquantitative tools (Scale AI, Mercor) and\nactual understanding of why users react\nto AI products.',
55
+ body: 'Researchers set up a study, and an AI engine\nruns, transcribes, and analyzes interviews,\nlearning from each conversation through the\n"Study Brain." Adopted organically by top\nuniversities without any paid marketing.',
52
56
  },
53
57
  },
54
58
  {
55
59
  name: 'Astry',
56
60
  tag: 'Agency',
57
61
  tagColor: 'secondary',
58
- description: 'Dev agency -- 30+ international B2B clients',
59
- status: 'Full-stack product dev & vibecoding consulting',
62
+ description: 'AI-powered dev agency -- 30+ B2B clients',
63
+ status: 'Active -- co-founded with Teo & Mathis',
60
64
  detail: {
61
65
  url: 'astry.dev',
62
- tagline: 'Dev agency -- 30+ international B2B clients',
66
+ tagline: 'AI-powered web development agency',
63
67
  fields: [
64
68
  ['STATUS', 'Active, growing'],
69
+ ['COFOUNDERS', 'Martin, Teo, Mathis'],
65
70
  ['CLIENTS', '30+ international B2B'],
66
- ['SERVICES', 'Full-stack dev, vibecoding consulting'],
67
- ['STACK', 'React, Next.js, React Native, Supabase'],
71
+ ['SPEED', '3-5x faster than traditional agencies'],
72
+ ['STACK', 'Cursor, Claude, Next.js, Supabase'],
68
73
  ],
69
- body: 'Full-stack product development agency\nserving international B2B clients.\nSpecializing in rapid prototyping and\nAI-augmented development workflows.',
74
+ body: 'Uses Cursor, Claude, and AI tools internally\nto deliver high-quality products at 3-5x speed.\nRevenue split: devs (65%), sales (13%),\nfounders (10%). Cash machine that funds Qual.',
70
75
  },
71
76
  },
72
77
  {
73
- name: 'AI Series Farm',
74
- tag: 'Internal',
78
+ name: 'F*cksubscription',
79
+ tag: 'SaaS',
75
80
  tagColor: 'accent',
76
- description: 'Automated video production pipeline',
77
- status: 'Claude Code + Playwright + Higgsfield',
81
+ description: '3,000+ paying customers, 10K+ EUR',
82
+ status: 'Live -- one-time payments to replace SaaS',
78
83
  detail: {
79
- tagline: 'Automated video production pipeline',
84
+ tagline: 'One-time payments to replace overpriced SaaS',
80
85
  fields: [
81
- ['STATUS', 'Internal tool'],
82
- ['AGENTS', '5 autonomous agents'],
83
- ['STACK', 'Claude Code, Playwright, Higgsfield.ai'],
86
+ ['STATUS', 'Live (wound down by choice)'],
87
+ ['CUSTOMERS', '3,000+ paying'],
88
+ ['REVENUE', '10,000+ EUR'],
89
+ ['CONVERSION', '90% waitlist-to-purchase'],
90
+ ['PRODUCTS', 'MangoForm, PeachCalendar, GrapeLink, GuavaTeam'],
84
91
  ],
85
- body: 'Multi-agent system that automates\nvideo content production end-to-end.\n5 specialized agents handle scripting,\nasset generation, editing, and publishing.',
92
+ body: 'Classic "sell before building" -- put up a\nlanding page pretending the product existed,\ngot 1,000 email signups day one, then built it.\nWound down because one-time revenue doesn\'t\ncompound. Lesson shaped Qual\'s SaaS model.',
86
93
  },
87
94
  },
88
95
  {
89
- name: 'SMCP',
90
- tag: 'Tool',
91
- tagColor: 'primary',
92
- description: 'Supabase MCP Manager -- Electron app',
93
- status: '11 Supabase accounts managed',
96
+ name: 'Brief',
97
+ tag: 'Hackathon',
98
+ tagColor: 'accent',
99
+ description: "Paris Innov'Hack 2025 -- 2nd / 150+",
100
+ status: 'Built in 10h with Marlowe, Mathieu, Oscar',
94
101
  detail: {
95
- tagline: 'Supabase MCP Manager',
102
+ tagline: "Paris Innov'Hack 2025 -- 2nd place out of 150+",
96
103
  fields: [
97
- ['STATUS', 'Active'],
98
- ['TYPE', 'Desktop app (Electron)'],
99
- ['ACCOUNTS', '11 Supabase accounts'],
100
- ['STACK', 'Electron, React, Supabase API'],
104
+ ['RESULT', '2nd / 150+ participants'],
105
+ ['EVENT', "Paris Innov'Hack 2025"],
106
+ ['TEAM', 'Marlowe, Mathieu, Oscar'],
107
+ ['STACK', 'MCP (Model Context Protocol)'],
101
108
  ],
102
- body: 'Desktop application for managing\nmultiple Supabase projects and MCP\nserver configurations across accounts.',
109
+ body: 'Intelligent context manager for AI agents.\nTeams have 50+ scattered markdown files for\nAI context -- mostly noise. We invented a\nnovel architecture: a dedicated AI that filters\nand summarizes all context before the main\nassistant receives it. Zero wasted tokens.',
103
110
  },
104
111
  },
105
112
  {
106
- name: 'CASA IMMO',
107
- tag: 'Client',
113
+ name: 'IQ Project',
114
+ tag: 'Education',
108
115
  tagColor: 'secondary',
109
- description: 'React Native real estate app',
110
- status: 'Multi-platform real estate aggregator',
116
+ description: 'Teaching vibecoding at HEC Paris',
117
+ status: '120 students trained',
111
118
  detail: {
112
- tagline: 'React Native real estate aggregator',
119
+ tagline: 'Teaching vibecoding at HEC Paris',
113
120
  fields: [
114
- ['STATUS', 'Client project'],
115
- ['PLATFORM', 'iOS + Android'],
116
- ['STACK', 'React Native, Supabase'],
121
+ ['STUDENTS', '120 HEC Paris students'],
122
+ ['TOPICS', 'Claude Code, sub-agents, agent teams'],
123
+ ['PARTNERS', 'X-HEC Entrepreneurs, HEC Startup Launchpad'],
117
124
  ],
118
- body: 'Cross-platform mobile application\nfor real estate listing aggregation\nand property search.',
125
+ body: 'Taught 120 HEC students how to go from zero\nto shipped product in hours. Showed them\nOpenClaw. They realized the barrier between\n"I can\'t build" and "I can build anything"\nis thinner than anyone told them.',
119
126
  },
120
127
  },
121
128
  {
122
- name: 'Brief MCP',
123
- tag: 'Hackathon',
124
- tagColor: 'accent',
125
- description: "Paris Innov'Hack -- 2nd / 150 teams",
126
- status: 'MCP-based project briefing tool',
129
+ name: 'Objectif Reussite',
130
+ tag: 'Nonprofit',
131
+ tagColor: 'dim',
132
+ description: '800+ students helped, 20+ volunteer tutors',
133
+ status: 'Started at 15 -- ran 3 years',
127
134
  detail: {
128
- tagline: "Paris Innov'Hack -- 2nd place out of 150 teams",
135
+ tagline: 'Discord-based tutoring network',
129
136
  fields: [
130
- ['RESULT', '2nd / 150 teams'],
131
- ['EVENT', "Paris Innov'Hack"],
132
- ['STACK', 'MCP, Claude API'],
137
+ ['STATUS', 'Completed (ran 3 years)'],
138
+ ['STUDENTS', '800+ helped'],
139
+ ['TUTORS', '20+ volunteers'],
140
+ ['STARTED', 'Age 15, during lycee'],
133
141
  ],
134
- body: "Built during Paris Innov'Hack hackathon.\nMCP-based tool for automated project\nbriefing and documentation generation.\nPlaced 2nd out of 150 competing teams.",
142
+ body: 'Built a Discord-based tutoring network that\nconnected struggling high school students with\nvolunteer teachers for free. Proved I could\nbuild a community from scratch, recruit people,\nand sustain something meaningful over time.',
135
143
  },
136
144
  },
137
145
  {
138
- name: 'F*ckSubscription',
139
- tag: 'Legacy',
140
- tagColor: 'dim',
141
- description: '3K+ users, 10K+ EUR -- built at 15',
142
- status: 'First real product success',
146
+ name: 'Good Good Time',
147
+ tag: 'Social',
148
+ tagColor: 'primary',
149
+ description: 'Activity recommendation app',
150
+ status: 'Built at NEOMA incubator, rebuilt in Canada',
143
151
  detail: {
144
- tagline: '3K+ users, 10K+ EUR revenue',
152
+ tagline: 'Activity recommendations based on preferences',
145
153
  fields: [
146
- ['STATUS', 'Legacy (built at 15)'],
147
- ['USERS', '3,000+'],
148
- ['REVENUE', '10,000+ EUR'],
154
+ ['V1', 'No-code + Supabase at NEOMA incubator'],
155
+ ['V2', 'Full rebuild with Cursor in Canada'],
156
+ ['TEAM', 'Included co-founder with $2.4B exit'],
149
157
  ],
150
- body: 'First real product, built at age 15.\nSubscription management tool that grew\nto 3,000+ paying users and generated\nover 10,000 EUR in revenue.',
158
+ body: 'Activity recommendation app based on user\npreferences. V1 built with no-code at NEOMA\nincubator. Rebuilt from scratch as v2 in Canada\nwhen I discovered Cursor. Team included a\nco-founder with a $2.4B exit, which set the\nbar for every project after.',
151
159
  },
152
160
  },
153
161
  ];
154
162
 
155
163
  export const STACK = {
156
164
  languages: [
157
- { name: 'TypeScript', level: 85 },
165
+ { name: 'TypeScript', level: 90 },
158
166
  { name: 'JavaScript', level: 90 },
159
167
  { name: 'Python', level: 65 },
160
168
  { name: 'SQL', level: 55 },
161
169
  ],
162
170
  frameworks: [
163
- { name: 'React/Next.js', level: 90 },
171
+ { name: 'React/Next.js', level: 95 },
164
172
  { name: 'React Native', level: 65 },
165
173
  { name: 'Node.js', level: 80 },
166
174
  ],
167
175
  infra: [
168
176
  { name: 'Supabase', level: 95 },
177
+ { name: 'Vercel', level: 85 },
169
178
  { name: 'PostgreSQL', level: 65 },
170
179
  ],
171
180
  ai: [
172
- { name: 'Claude Code', level: 95 },
181
+ { name: 'Claude/Cursor', level: 95 },
173
182
  { name: 'Agent Teams', level: 80 },
174
183
  { name: 'MCP', level: 80 },
175
184
  ],
176
185
  };
177
186
 
178
187
  export const STACK_PHILOSOPHY = [
188
+ 'Sell before you build -- validate with landing pages first',
189
+ 'Design as competitive moat, not afterthought',
179
190
  'Vibecoding > traditional coding',
180
- 'Autonomous agent loops as core pattern',
181
- 'Sell before you build',
191
+ 'Ship fast, iterate faster',
182
192
  ];
183
193
 
184
194
  export const HISTORY = [
185
195
  {
186
- year: '2021',
187
- title: 'Started building at 15',
188
- details: 'First products and experiments',
196
+ year: '2019',
197
+ title: 'Started designing at 13',
198
+ details: 'Figma, Affinity Designer\nFirst creative experiments',
189
199
  },
190
200
  {
191
- year: '2022',
192
- title: 'F*ckSubscription',
193
- details: '3,000+ paying users, 10K+ EUR\nFirst real product success',
201
+ year: '2021',
202
+ title: 'First ventures at 15',
203
+ details: 'Objectif Reussite: 800+ students, 20+ tutors\nNYM Digital: first client work\n15+ projects started since then',
194
204
  },
195
205
  {
196
206
  year: '2023',
197
- title: 'Founded Astry',
198
- details: 'Dev agency, scaling to 30+ clients',
207
+ title: 'NEOMA Business School',
208
+ details: 'Global BBA started\nNEOMA incubator: Good Good Time\nCo-founded Astry with Teo & Mathis',
199
209
  },
200
210
  {
201
211
  year: '2024',
202
- title: 'Co-founded Qual',
203
- details: 'AI qualitative research platform\nStarted teaching vibecoding at HEC',
212
+ title: "Canada & the AI shift",
213
+ details: "Queen's University / Smith School of Business\nDiscovered Cursor & Claude\nWent from no-code to full-stack AI dev",
204
214
  },
205
215
  {
206
216
  year: '2025',
207
- title: 'The Pivot Year',
208
- details: "Squal: qualitative feedback for AI\nParis Innov'Hack: 2nd / 150\nNEOMA live build challenge\nAccepted to Berkeley Incubator",
217
+ title: 'The breakout year',
218
+ details: "Co-founded Qual with Marin\nF*cksubscription: 3K+ customers, 10K+ EUR\nParis Innov'Hack: 2nd / 150+\nTaught 120 HEC Paris students vibecoding",
209
219
  },
210
220
  {
211
221
  year: '2026',
212
- title: 'Building the future',
213
- details: 'Scaling Squal + Astry',
222
+ title: 'Scaling',
223
+ details: 'Growing Qual -- top university adoption\nAstry: 30+ clients\nPreparing UC Berkeley move',
214
224
  },
215
225
  {
216
226
  year: '2027',
217
- title: 'Berkeley starts January',
218
- details: 'Berkeley Global Incubator',
227
+ title: 'UC Berkeley, San Francisco',
228
+ details: '6-month program through NEOMA incubator\nBringing Qual to the US market',
219
229
  },
220
230
  ];
221
231
 
222
232
  export const EDUCATION = [
223
233
  {
224
234
  title: 'NEOMA Business School',
225
- detail: 'Global BBA -- 3rd year\nInternational business, strategy, marketing',
235
+ detail: 'Global BBA (2023-2027)\nInternational business, strategy, marketing',
236
+ },
237
+ {
238
+ title: "Queen's University / Smith School of Business",
239
+ detail: 'Exchange year in Canada (2024)\nDiscovered Cursor & Claude -- went full-stack',
226
240
  },
227
241
  {
228
- title: 'Berkeley Global Incubator',
229
- detail: 'Accepted -- Starting January 2027',
242
+ title: 'UC Berkeley -- Global Incubator',
243
+ detail: '6-month program starting January 2027\nBringing Qual to the US market',
230
244
  },
231
245
  {
232
- title: 'Teaching @ IQ Project',
233
- detail: 'Vibecoding & AI product building\nStudents trained at HEC Paris & NEOMA\nFormation programs for Zoi CEO team',
246
+ title: 'Teaching @ IQ Project / HEC Paris',
247
+ detail: 'Vibecoding & AI product building\n120 HEC Paris students trained\nPartnered with X-HEC Entrepreneurs',
234
248
  },
235
249
  ];
236
250
 
237
251
  export const CONTACT = [
238
- { label: 'WEB', value: 'squal.ai' },
239
- { label: 'WEB', value: 'qual.cx' },
240
- { label: 'LINKEDIN', value: 'linkedin.com/in/martinmusic' },
241
- { label: 'GITHUB', value: 'github.com/martinmusic' },
242
- { label: 'EMAIL', value: 'martin@astry.dev' },
252
+ { label: 'EMAIL', value: 'bonanmartin@gmail.com', url: 'mailto:bonanmartin@gmail.com' },
253
+ { label: 'WEB', value: 'qual.cx', url: 'https://qual.cx' },
254
+ { label: 'LINKEDIN', value: 'linkedin.com/in/martinbonan', url: 'https://linkedin.com/in/martinbonan' },
255
+ { label: 'GITHUB', value: 'github.com/martinbon39', url: 'https://github.com/martinbon39' },
256
+ { label: 'X', value: 'x.com/martinbonan', url: 'https://x.com/martinbonan' },
243
257
  ];
244
258
 
245
- export const TABS = ['About', 'Projects', 'Stack', 'History', 'Education', 'Contact', 'Ask AI'];
259
+ export const TABS = ['About', 'Projects', 'Stack', 'History', 'Education', 'Contact'];
246
260
 
247
261
  export const EASTER_EGGS = {
248
262
  hack: true,
@@ -1,12 +1,28 @@
1
- import { useState, useCallback } from 'react';
1
+ import { useState, useCallback, useRef } from 'react';
2
2
  import { PROXY_URL, CLI_SECRET } from '../ai/context.js';
3
3
 
4
+ const MAX_MESSAGES = 15;
5
+
4
6
  export function useAiChat() {
5
7
  const [messages, setMessages] = useState([]);
6
8
  const [loading, setLoading] = useState(false);
7
9
  const [streamingText, setStreamingText] = useState('');
10
+ const [limitReached, setLimitReached] = useState(false);
11
+ const messageCount = useRef(0);
8
12
 
9
13
  const sendMessage = useCallback(async (text) => {
14
+ messageCount.current++;
15
+
16
+ if (messageCount.current > MAX_MESSAGES) {
17
+ setLimitReached(true);
18
+ setMessages(prev => [
19
+ ...prev,
20
+ { role: 'user', content: text },
21
+ { role: 'assistant', content: 'Message limit reached (15 messages per session). Restart the CLI to chat again.' },
22
+ ]);
23
+ return;
24
+ }
25
+
10
26
  const userMsg = { role: 'user', content: text };
11
27
  const newMessages = [...messages, userMsg];
12
28
  setMessages(newMessages);
@@ -30,7 +46,6 @@ export function useAiChat() {
30
46
  const contentType = response.headers.get('content-type') || '';
31
47
 
32
48
  if (contentType.includes('text/event-stream')) {
33
- // Streaming response
34
49
  let fullText = '';
35
50
  const reader = response.body.getReader();
36
51
  const decoder = new TextDecoder();
@@ -64,7 +79,6 @@ export function useAiChat() {
64
79
  setMessages(prev => [...prev, { role: 'assistant', content: fullText || 'No response.' }]);
65
80
  setStreamingText('');
66
81
  } else {
67
- // Non-streaming JSON response
68
82
  const data = await response.json();
69
83
  const text = data.content?.[0]?.text || data.error || 'No response.';
70
84
  setMessages(prev => [...prev, { role: 'assistant', content: text }]);
@@ -82,7 +96,9 @@ export function useAiChat() {
82
96
  const clearChat = useCallback(() => {
83
97
  setMessages([]);
84
98
  setStreamingText('');
99
+ messageCount.current = 0;
100
+ setLimitReached(false);
85
101
  }, []);
86
102
 
87
- return { messages, loading, streamingText, sendMessage, clearChat };
103
+ return { messages, loading, streamingText, sendMessage, clearChat, limitReached };
88
104
  }
@@ -6,7 +6,7 @@ export function useAnimatedValue(target, duration = 1000, active = true) {
6
6
 
7
7
  useEffect(() => {
8
8
  if (!active) {
9
- setValue(0);
9
+ setValue(target);
10
10
  startTime.current = null;
11
11
  return;
12
12
  }
@@ -4,7 +4,8 @@ import Header from '../components/Header.js';
4
4
  import TabBar from '../components/TabBar.js';
5
5
  import ContentPanel from '../components/ContentPanel.js';
6
6
  import StatusBar from '../components/StatusBar.js';
7
- import { TABS, BIO, PROJECTS, HISTORY } from '../data/content.js';
7
+ import { TABS, BIO, PROJECTS, HISTORY, CONTACT } from '../data/content.js';
8
+ import { exec } from 'child_process';
8
9
  import { ThemeContext, getNextThemeName } from '../theme.js';
9
10
 
10
11
  const e = React.createElement;
@@ -35,11 +36,13 @@ export default function Dashboard() {
35
36
  const isAiTab = tabName === 'Ask AI';
36
37
  const isProjectsTab = tabName === 'Projects';
37
38
  const isHistoryTab = tabName === 'History';
39
+ const isContactTab = tabName === 'Contact';
38
40
 
39
41
  // Get max index for current tab's selectable items
40
42
  const getMaxIndex = () => {
41
43
  if (isProjectsTab) return PROJECTS.length - 1;
42
44
  if (isHistoryTab) return HISTORY.length - 1;
45
+ if (isContactTab) return CONTACT.length - 1;
43
46
  return 0;
44
47
  };
45
48
 
@@ -126,7 +129,7 @@ export default function Dashboard() {
126
129
 
127
130
  // Vertical navigation
128
131
  if (key.upArrow) {
129
- if (isProjectsTab || isHistoryTab) {
132
+ if (isProjectsTab || isHistoryTab || isContactTab) {
130
133
  setSelectedIndex(i => Math.max(0, i - 1));
131
134
  } else {
132
135
  setScrollOffset(s => Math.max(0, s - 1));
@@ -134,7 +137,7 @@ export default function Dashboard() {
134
137
  return;
135
138
  }
136
139
  if (key.downArrow) {
137
- if (isProjectsTab || isHistoryTab) {
140
+ if (isProjectsTab || isHistoryTab || isContactTab) {
138
141
  setSelectedIndex(i => Math.min(getMaxIndex(), i + 1));
139
142
  } else {
140
143
  setScrollOffset(s => s + 1);
@@ -148,6 +151,15 @@ export default function Dashboard() {
148
151
  setAiFocused(true);
149
152
  } else if (isProjectsTab) {
150
153
  setExpandedProject(selectedIndex);
154
+ } else if (isContactTab) {
155
+ const contact = CONTACT[selectedIndex];
156
+ if (contact && contact.url) {
157
+ // Open URL in default browser
158
+ const platform = process.platform;
159
+ const cmd = platform === 'darwin' ? 'open' :
160
+ platform === 'win32' ? 'start' : 'xdg-open';
161
+ exec(`${cmd} "${contact.url}"`);
162
+ }
151
163
  }
152
164
  return;
153
165
  }
package/src/tabs/About.js CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useContext } from 'react';
1
+ import React, { useState, useEffect, useContext, useRef } from 'react';
2
2
  import { Box, Text } from 'ink';
3
3
  import { ABOUT_TEXT } from '../data/content.js';
4
4
  import TypeWriter from '../components/TypeWriter.js';
@@ -6,9 +6,12 @@ import { ThemeContext } from '../theme.js';
6
6
 
7
7
  const e = React.createElement;
8
8
 
9
+ // Persist across remounts so the typewriter only plays once
10
+ let hasPlayedGlobal = false;
11
+
9
12
  export default function About() {
10
13
  const { theme } = useContext(ThemeContext);
11
- const [hasPlayed, setHasPlayed] = useState(false);
14
+ const [hasPlayed, setHasPlayed] = useState(hasPlayedGlobal);
12
15
  const [bright, setBright] = useState(true);
13
16
 
14
17
  useEffect(() => {
@@ -17,7 +20,6 @@ export default function About() {
17
20
  }, []);
18
21
 
19
22
  const lines = ABOUT_TEXT.split('\n');
20
- const lastLine = lines[lines.length - 1];
21
23
 
22
24
  if (!hasPlayed) {
23
25
  return e(Box, { flexDirection: 'column', paddingLeft: 1 },
@@ -25,7 +27,10 @@ export default function About() {
25
27
  text: ABOUT_TEXT,
26
28
  speed: 15,
27
29
  color: theme.text,
28
- onComplete: () => setHasPlayed(true),
30
+ onComplete: () => {
31
+ hasPlayedGlobal = true;
32
+ setHasPlayed(true);
33
+ },
29
34
  })
30
35
  );
31
36
  }
package/src/tabs/AskAi.js CHANGED
@@ -14,20 +14,20 @@ const EASTER_EGGS = {
14
14
  '\u2502 Permission granted. Contract incoming. \u2502\n' +
15
15
  '\u2502 Just kidding. But seriously, let\'s talk.\u2502\n' +
16
16
  '\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F\n\n' +
17
- 'WEB: squal.ai\nLINKEDIN: linkedin.com/in/martinmusic\nEMAIL: martin@astry.dev',
17
+ 'WEB: qual.cx\nLINKEDIN: linkedin.com/in/martinbonan\nEMAIL: bonanmartin@gmail.com',
18
18
  neofetch:
19
19
  'OS: VibeOS 3.0\n' +
20
20
  'Shell: martin-sh\n' +
21
21
  'CPU: Pure caffeine\n' +
22
- 'Memory: 30+ projects loaded\n' +
23
- 'Uptime: Building since 2021',
22
+ 'Memory: 15+ projects loaded\n' +
23
+ 'Uptime: Building since 2019',
24
24
  vim: "You're a vim person? Respect. But :q won't save you here.",
25
25
  ls: "You're looking for files? Try 'projects' tab instead.",
26
26
  };
27
27
 
28
28
  export default function AskAi({ isFocused }) {
29
29
  const { theme } = useContext(ThemeContext);
30
- const { messages, loading, streamingText, sendMessage, clearChat } = useAiChat();
30
+ const { messages, loading, streamingText, sendMessage, clearChat, limitReached } = useAiChat();
31
31
  const [query, setQuery] = useState('');
32
32
  const [localMessages, setLocalMessages] = useState([]);
33
33
 
@@ -92,16 +92,20 @@ export default function AskAi({ isFocused }) {
92
92
  : null,
93
93
  ),
94
94
 
95
- e(Box, { marginTop: 1 },
96
- e(Text, { color: theme.accent }, ' YOU: '),
97
- isFocused
98
- ? e(TextInput, {
99
- value: query,
100
- onChange: setQuery,
101
- onSubmit: handleSubmit,
102
- placeholder: 'Type your question...',
103
- })
104
- : e(Text, { color: theme.dim }, 'Press enter to start chatting...')
105
- )
95
+ limitReached
96
+ ? e(Box, { marginTop: 1 },
97
+ e(Text, { color: theme.dim }, ' Message limit reached (15/session). Restart to chat again.')
98
+ )
99
+ : e(Box, { marginTop: 1 },
100
+ e(Text, { color: theme.accent }, ' YOU: '),
101
+ isFocused
102
+ ? e(TextInput, {
103
+ value: query,
104
+ onChange: setQuery,
105
+ onSubmit: handleSubmit,
106
+ placeholder: 'Type your question...',
107
+ })
108
+ : e(Text, { color: theme.dim }, 'Press enter to start chatting...')
109
+ )
106
110
  );
107
111
  }
@@ -5,7 +5,7 @@ import { ThemeContext } from '../theme.js';
5
5
 
6
6
  const e = React.createElement;
7
7
 
8
- export default function Contact() {
8
+ export default function Contact({ selectedIndex = 0 }) {
9
9
  const { theme } = useContext(ThemeContext);
10
10
  const separator = '\u2500'.repeat(40);
11
11
 
@@ -13,14 +13,17 @@ export default function Contact() {
13
13
  e(Text, { color: theme.text }, ' Get in touch'),
14
14
  e(Text, { color: theme.dim }, ' ' + separator),
15
15
  e(Box, { flexDirection: 'column', marginTop: 1 },
16
- ...CONTACT.map((c, i) =>
17
- e(Box, { key: i },
18
- e(Text, { color: theme.dim }, ' ' + c.label.padEnd(10)),
19
- e(Text, { color: theme.primary }, c.value),
20
- )
21
- )
16
+ ...CONTACT.map((c, i) => {
17
+ const isSelected = i === selectedIndex;
18
+ return e(Box, { key: i },
19
+ e(Text, { color: isSelected ? theme.primary : theme.dim }, isSelected ? '> ' : ' '),
20
+ e(Text, { color: theme.dim }, c.label.padEnd(10)),
21
+ e(Text, { color: isSelected ? theme.primary : theme.text, bold: isSelected, underline: isSelected }, c.value),
22
+ );
23
+ })
22
24
  ),
23
- e(Text, { color: theme.dim, marginTop: 1 }, ' ' + separator),
25
+ e(Text, { color: theme.dim, dimColor: true }, '\n ' + separator),
26
+ e(Text, { color: theme.dim, italic: true }, ' enter to open in browser'),
24
27
  e(Text, { color: theme.dim, italic: true }, ' This CLI was built with ink, Claude, and vibes.'),
25
28
  );
26
29
  }
@@ -5,16 +5,9 @@ import { ThemeContext } from '../theme.js';
5
5
 
6
6
  const e = React.createElement;
7
7
 
8
- const TAG_COLOR_MAP = {
9
- primary: 'primary',
10
- secondary: 'secondary',
11
- accent: 'accent',
12
- dim: 'dim',
13
- };
14
-
15
8
  function resolveTagColor(tagColor, theme) {
16
- const key = TAG_COLOR_MAP[tagColor];
17
- return key ? theme[key] : theme.dim;
9
+ const map = { primary: 'primary', secondary: 'secondary', accent: 'accent', dim: 'dim' };
10
+ return theme[map[tagColor]] || theme.dim;
18
11
  }
19
12
 
20
13
  function CompactCard({ project, isSelected, theme }) {
@@ -35,70 +28,69 @@ function CompactCard({ project, isSelected, theme }) {
35
28
 
36
29
  function ExpandedCard({ project, theme }) {
37
30
  const detail = project.detail;
38
- const width = 42;
31
+
32
+ // Compute width dynamically from content
33
+ const allLines = [];
34
+ if (detail.url) allLines.push(detail.url);
35
+ if (detail.tagline) allLines.push(detail.tagline);
36
+ if (detail.quote) allLines.push(detail.quote.replace(/\\n/g, ' '));
37
+ for (const [label, value] of (detail.fields || [])) {
38
+ allLines.push(label.padEnd(12) + value);
39
+ }
40
+ if (detail.body) {
41
+ for (const line of detail.body.split('\n')) allLines.push(line);
42
+ }
43
+ allLines.push('press esc to go back');
44
+
45
+ const maxContent = Math.max(...allLines.map(l => l.length));
46
+ const cols = process.stdout.columns || 80;
47
+ const width = Math.min(Math.max(maxContent + 6, project.name.length + 10), cols - 4);
48
+
39
49
  const nameBar = '\u2500\u2500\u2500 ' + project.name + ' ';
40
- const topFill = '\u2500'.repeat(Math.max(width - nameBar.length - 2, 0));
50
+ const topFill = '\u2500'.repeat(Math.max(width - nameBar.length - 1, 0));
41
51
  const top = '\u256D' + nameBar + topFill + '\u256E';
42
- const bottom = '\u2570' + '\u2500'.repeat(width - 2) + '\u256F';
43
- const pad = (str) => '\u2502 ' + str + ' '.repeat(Math.max(width - str.length - 5, 0)) + '\u2502';
44
- const empty = '\u2502' + ' '.repeat(width - 2) + '\u2502';
52
+ const bottom = '\u2570' + '\u2500'.repeat(width - 1) + '\u256F';
53
+ const empty = '\u2502' + ' '.repeat(width - 1) + '\u2502';
45
54
 
46
- const lines = [];
47
- lines.push(top);
48
- lines.push(empty);
55
+ const row = (children) => {
56
+ return children;
57
+ };
49
58
 
50
- if (detail.url || detail.tagline) {
51
- lines.push(pad(detail.url || detail.tagline));
52
- }
53
- if (detail.tagline && detail.url) {
54
- lines.push(pad(detail.tagline));
55
- }
56
- if (detail.quote) {
57
- lines.push(pad(detail.quote.replace(/\\n/g, '\n')));
58
- }
59
- lines.push(empty);
59
+ const textRow = (text, color = theme.text) => {
60
+ const pad = Math.max(width - text.length - 4, 0);
61
+ return e(Text, { color: theme.border },
62
+ '\u2502 ', e(Text, { color }, text), ' '.repeat(pad), ' \u2502'
63
+ );
64
+ };
65
+
66
+ const fieldRow = (label, value, i) => {
67
+ const content = label.padEnd(12) + value;
68
+ const pad = Math.max(width - content.length - 4, 0);
69
+ return e(Text, { key: `f-${i}`, color: theme.border },
70
+ '\u2502 ',
71
+ e(Text, { color: theme.primary, bold: true }, label.padEnd(12)),
72
+ e(Text, { color: theme.text }, value),
73
+ ' '.repeat(pad), ' \u2502'
74
+ );
75
+ };
60
76
 
61
77
  return e(Box, { flexDirection: 'column', paddingLeft: 1 },
62
78
  e(Text, { color: theme.border }, top),
63
79
  e(Text, { color: theme.border }, empty),
64
- detail.url ? e(Text, { color: theme.border },
65
- '\u2502 ', e(Text, { color: theme.text }, detail.url),
66
- ' '.repeat(Math.max(width - (detail.url || '').length - 5, 0)), '\u2502'
67
- ) : null,
68
- detail.tagline ? e(Text, { color: theme.border },
69
- '\u2502 ', e(Text, { color: theme.text }, detail.tagline),
70
- ' '.repeat(Math.max(width - (detail.tagline || '').length - 5, 0)), '\u2502'
71
- ) : null,
72
- detail.quote ? e(Text, { color: theme.border },
73
- '\u2502 ', e(Text, { color: theme.dim }, detail.quote.replace(/\\n/g, ' ')),
74
- ' '.repeat(Math.max(width - detail.quote.replace(/\\n/g, ' ').length - 5, 0)), '\u2502'
75
- ) : null,
80
+ detail.url ? textRow(detail.url, theme.text) : null,
81
+ detail.tagline ? textRow(detail.tagline, theme.text) : null,
82
+ detail.quote ? textRow(detail.quote.replace(/\\n/g, ' '), theme.dim) : null,
76
83
  e(Text, { color: theme.border }, empty),
77
- ...(detail.fields || []).map(([label, value], i) =>
78
- e(Text, { key: `f-${i}`, color: theme.border },
79
- '\u2502 ',
80
- e(Text, { color: theme.primary, bold: true }, label.padEnd(10)),
81
- e(Text, { color: theme.text }, value),
82
- ' '.repeat(Math.max(width - 10 - value.length - 5, 0)),
83
- '\u2502'
84
- )
85
- ),
84
+ ...(detail.fields || []).map(([label, value], i) => fieldRow(label, value, i)),
86
85
  e(Text, { color: theme.border }, empty),
87
- ...(detail.body ? detail.body.split('\n').map((line, i) =>
88
- e(Text, { key: `b-${i}`, color: theme.border },
89
- '\u2502 ',
90
- e(Text, { color: theme.text }, line),
91
- ' '.repeat(Math.max(width - line.length - 5, 0)),
92
- '\u2502'
93
- )
94
- ) : []),
86
+ ...(detail.body ? detail.body.split('\n').map((line, i) => {
87
+ const pad = Math.max(width - line.length - 4, 0);
88
+ return e(Text, { key: `b-${i}`, color: theme.border },
89
+ '\u2502 ', e(Text, { color: theme.text }, line), ' '.repeat(pad), ' \u2502'
90
+ );
91
+ }) : []),
95
92
  e(Text, { color: theme.border }, empty),
96
- e(Text, { color: theme.border },
97
- '\u2502 ',
98
- e(Text, { color: theme.dim }, 'press esc to go back'),
99
- ' '.repeat(Math.max(width - 'press esc to go back'.length - 5, 0)),
100
- '\u2502'
101
- ),
93
+ textRow('press esc to go back', theme.dim),
102
94
  e(Text, { color: theme.border }, bottom),
103
95
  );
104
96
  }
package/src/theme.js CHANGED
@@ -2,6 +2,18 @@ import React from 'react';
2
2
  import chalk from 'chalk';
3
3
 
4
4
  export const THEMES = {
5
+ clean: {
6
+ name: 'clean',
7
+ primary: '#A0C4FF',
8
+ secondary: '#A0C4FF',
9
+ accent: '#C2D9FF',
10
+ text: '#D6E4F0',
11
+ dim: '#4A5568',
12
+ border: '#2D3748',
13
+ bar_filled: '#A0C4FF',
14
+ bar_empty: '#1A202C',
15
+ bg: '#0D1117',
16
+ },
5
17
  cyberpunk: {
6
18
  name: 'cyberpunk',
7
19
  primary: '#00D4AA',
@@ -52,7 +64,7 @@ export const THEMES = {
52
64
  },
53
65
  };
54
66
 
55
- const THEME_ORDER = ['cyberpunk', 'mono', 'retro', 'sunset'];
67
+ const THEME_ORDER = ['clean', 'cyberpunk', 'mono', 'retro', 'sunset'];
56
68
 
57
69
  export const ThemeContext = React.createContext(null);
58
70