vipcare 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bin/vip.js +430 -2
  2. package/package.json +1 -1
  3. package/web/index.html +35 -10
package/bin/vip.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { Command } from 'commander';
4
4
  import fs from 'fs';
5
5
  import os from 'os';
6
+ import readline from 'readline/promises';
6
7
  import { execFileSync } from 'child_process';
7
8
  import { checkTool, getProfilesDir, loadConfig, saveConfig } from '../lib/config.js';
8
9
  import { deleteProfile, getProfilePath, listProfiles, loadProfile, profileExists, saveProfile, searchProfiles, slugify } from '../lib/profile.js';
@@ -75,7 +76,7 @@ try {
75
76
  } catch {}
76
77
 
77
78
  const program = new Command();
78
- program.name('vip').description('VIP Profile Builder - Auto-build VIP person profiles from public data').version('0.2.0');
79
+ program.name('vip').description('VIP Profile Builder - Auto-build VIP person profiles from public data').version('0.3.0');
79
80
 
80
81
  // --- add ---
81
82
  program.command('add')
@@ -178,8 +179,21 @@ program.command('add')
178
179
  program.command('list')
179
180
  .description('List all VIP profiles')
180
181
  .option('--json', 'Output as JSON')
182
+ .option('--tag <tag>', 'Filter by tag')
181
183
  .action((opts) => {
182
- const profiles = listProfiles();
184
+ let profiles = listProfiles();
185
+
186
+ if (opts.tag) {
187
+ profiles = profiles.filter(p => {
188
+ const content = loadProfile(p.slug);
189
+ if (!content) return false;
190
+ const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
191
+ if (!tagsMatch) return false;
192
+ const tags = tagsMatch[1].split('\n').filter(l => l.match(/^- /)).map(l => l.replace(/^- /, '').trim());
193
+ return tags.includes(opts.tag);
194
+ });
195
+ }
196
+
183
197
  if (opts.json) {
184
198
  const data = profiles.map(p => ({ slug: p.slug, name: p.name, summary: p.summary, updated: p.updated, path: p.path }));
185
199
  console.log(JSON.stringify(data, null, 2));
@@ -500,6 +514,226 @@ mon.command('run').description('Run monitoring now').option('-v, --verbose', 'Ve
500
514
  } else console.log(c.dim('No significant changes detected.'));
501
515
  });
502
516
 
517
+ // --- compare ---
518
+ program.command('compare')
519
+ .description('Compare two VIP profiles side by side')
520
+ .argument('<name1>', 'First profile name')
521
+ .argument('<name2>', 'Second profile name')
522
+ .option('--json', 'Output as JSON')
523
+ .action((name1, name2, opts) => {
524
+ const content1 = loadProfile(name1);
525
+ if (!content1) { console.error(c.red(`Profile not found: ${name1}`)); process.exit(1); }
526
+ const content2 = loadProfile(name2);
527
+ if (!content2) { console.error(c.red(`Profile not found: ${name2}`)); process.exit(1); }
528
+
529
+ function parseProfile(content) {
530
+ const vip = extractVipData(content);
531
+ if (vip) return vip;
532
+
533
+ const nameMatch = content.match(/^# (.+)$/m);
534
+ const titleMatch = content.match(/\*\*Title:\*\*\s*(.+)/);
535
+ const companyMatch = content.match(/\*\*Company:\*\*\s*(.+)/);
536
+ const locationMatch = content.match(/\*\*Location:\*\*\s*(.+)/);
537
+ const discMatch = content.match(/\*\*DISC:\*\*\s*(.+)/);
538
+ const mbtiMatch = content.match(/\*\*MBTI:\*\*\s*(.+)/);
539
+ const industryMatch = content.match(/\*\*Industry:\*\*\s*(.+)/);
540
+
541
+ // Parse tags from ## Tags section
542
+ const tags = [];
543
+ const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
544
+ if (tagsMatch) {
545
+ for (const line of tagsMatch[1].split('\n')) {
546
+ const m = line.match(/^- (.+)/);
547
+ if (m) tags.push(m[1].trim());
548
+ }
549
+ }
550
+ if (industryMatch && !tags.length) tags.push(industryMatch[1].trim());
551
+
552
+ return {
553
+ name: nameMatch ? nameMatch[1] : '',
554
+ title: titleMatch ? titleMatch[1].trim() : '',
555
+ company: companyMatch ? companyMatch[1].trim() : '',
556
+ location: locationMatch ? locationMatch[1].trim() : '',
557
+ disc: discMatch ? discMatch[1].trim() : '',
558
+ mbti: mbtiMatch ? mbtiMatch[1].trim() : '',
559
+ tags,
560
+ };
561
+ }
562
+
563
+ const p1 = parseProfile(content1);
564
+ const p2 = parseProfile(content2);
565
+
566
+ const name1Display = p1.name || name1;
567
+ const name2Display = p2.name || name2;
568
+
569
+ const tags1 = (p1.tags || []).map(t => t.toLowerCase());
570
+ const tags2 = (p2.tags || []).map(t => t.toLowerCase());
571
+ const shared = tags1.filter(t => tags2.includes(t));
572
+ const unique1 = tags1.filter(t => !tags2.includes(t));
573
+ const unique2 = tags2.filter(t => !tags1.includes(t));
574
+
575
+ if (opts.json) {
576
+ console.log(JSON.stringify({
577
+ profile1: { name: name1Display, title: p1.title, company: p1.company, location: p1.location, disc: p1.disc, mbti: p1.mbti, tags: p1.tags },
578
+ profile2: { name: name2Display, title: p2.title, company: p2.company, location: p2.location, disc: p2.disc, mbti: p2.mbti, tags: p2.tags },
579
+ shared,
580
+ uniqueToFirst: unique1,
581
+ uniqueToSecond: unique2,
582
+ }, null, 2));
583
+ return;
584
+ }
585
+
586
+ const col1W = Math.max(20, name1Display.length + 4);
587
+ const col2W = Math.max(20, name2Display.length + 4);
588
+ const labelW = 16;
589
+
590
+ console.log();
591
+ console.log(c.bold(c.cyan(`${name1Display} vs ${name2Display}`)));
592
+ console.log('═'.repeat(labelW + col1W + col2W));
593
+
594
+ console.log(`${''.padEnd(labelW)}${c.bold(name1Display.padEnd(col1W))}${c.bold(name2Display.padEnd(col2W))}`);
595
+
596
+ const fields = [
597
+ ['Title:', p1.title, p2.title],
598
+ ['Company:', p1.company, p2.company],
599
+ ['Location:', p1.location, p2.location],
600
+ ['DISC:', p1.disc, p2.disc],
601
+ ['MBTI:', p1.mbti, p2.mbti],
602
+ ];
603
+
604
+ for (const [label, v1, v2] of fields) {
605
+ if (!v1 && !v2) continue;
606
+ console.log(`${c.dim(label.padEnd(labelW))}${(v1 || c.dim('—')).toString().padEnd(col1W)}${(v2 || c.dim('—')).toString().padEnd(col2W)}`);
607
+ }
608
+
609
+ console.log();
610
+ if (shared.length) console.log(`${c.green('Shared interests:')} ${shared.join(', ')}`);
611
+ if (unique1.length) console.log(`${c.cyan(`Unique to ${name1Display}:`)} ${unique1.join(', ')}`);
612
+ if (unique2.length) console.log(`${c.cyan(`Unique to ${name2Display}:`)} ${unique2.join(', ')}`);
613
+ if (!shared.length && !unique1.length && !unique2.length) console.log(c.dim('No tags to compare.'));
614
+ console.log();
615
+ });
616
+
617
+ // --- tag ---
618
+ program.command('tag')
619
+ .description('Add a tag to a profile')
620
+ .argument('<name>', 'Profile name')
621
+ .argument('<tag>', 'Tag to add')
622
+ .action((name, tag) => {
623
+ let content = loadProfile(name);
624
+ if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
625
+
626
+ const tagLine = `- ${tag}`;
627
+ const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
628
+
629
+ if (tagsMatch) {
630
+ const existingTags = tagsMatch[1].split('\n').filter(l => l.match(/^- /)).map(l => l.replace(/^- /, '').trim());
631
+ if (existingTags.includes(tag)) {
632
+ console.log(c.yellow(`Tag '${tag}' already exists on ${name}.`));
633
+ return;
634
+ }
635
+ content = content.replace(tagsMatch[0], tagsMatch[0].trimEnd() + '\n' + tagLine);
636
+ } else if (content.includes('\n---\n')) {
637
+ content = content.replace('\n---\n', `\n## Tags\n${tagLine}\n\n---\n`);
638
+ } else {
639
+ content = content.trimEnd() + `\n\n## Tags\n${tagLine}\n`;
640
+ }
641
+
642
+ saveProfile(name, content);
643
+ console.log(c.green(`Tagged ${name} with '${tag}'.`));
644
+ });
645
+
646
+ // --- untag ---
647
+ program.command('untag')
648
+ .description('Remove a tag from a profile')
649
+ .argument('<name>', 'Profile name')
650
+ .argument('<tag>', 'Tag to remove')
651
+ .action((name, tag) => {
652
+ let content = loadProfile(name);
653
+ if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
654
+
655
+ const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
656
+ if (!tagsMatch) {
657
+ console.log(c.yellow(`No tags found on ${name}.`));
658
+ return;
659
+ }
660
+
661
+ const lines = tagsMatch[1].split('\n').filter(l => l.match(/^- /));
662
+ const remaining = lines.filter(l => l.replace(/^- /, '').trim() !== tag);
663
+
664
+ if (remaining.length === lines.length) {
665
+ console.log(c.yellow(`Tag '${tag}' not found on ${name}.`));
666
+ return;
667
+ }
668
+
669
+ if (remaining.length === 0) {
670
+ // Remove the entire Tags section
671
+ content = content.replace(/\n?## Tags\n[\s\S]*?(?=\n##|\n---|$)/, '');
672
+ } else {
673
+ content = content.replace(tagsMatch[0], '## Tags\n' + remaining.join('\n'));
674
+ }
675
+
676
+ saveProfile(name, content);
677
+ console.log(c.green(`Removed tag '${tag}' from ${name}.`));
678
+ });
679
+
680
+ // --- tags ---
681
+ program.command('tags')
682
+ .description('List tags across profiles or for a specific profile')
683
+ .argument('[name]', 'Profile name (optional)')
684
+ .option('--json', 'Output as JSON')
685
+ .action((name, opts) => {
686
+ function parseTags(content) {
687
+ const tags = [];
688
+ const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
689
+ if (tagsMatch) {
690
+ for (const line of tagsMatch[1].split('\n')) {
691
+ const m = line.match(/^- (.+)/);
692
+ if (m) tags.push(m[1].trim());
693
+ }
694
+ }
695
+ return tags;
696
+ }
697
+
698
+ if (name) {
699
+ const content = loadProfile(name);
700
+ if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
701
+
702
+ const tags = parseTags(content);
703
+ if (opts.json) {
704
+ console.log(JSON.stringify(tags, null, 2));
705
+ return;
706
+ }
707
+ if (!tags.length) { console.log(c.dim(`No tags on ${name}.`)); return; }
708
+ console.log(c.bold(c.cyan(`Tags for ${name}:\n`)));
709
+ for (const t of tags) console.log(` - ${t}`);
710
+ console.log();
711
+ } else {
712
+ const profiles = listProfiles();
713
+ const counts = {};
714
+ for (const p of profiles) {
715
+ const content = loadProfile(p.slug);
716
+ if (!content) continue;
717
+ for (const t of parseTags(content)) {
718
+ counts[t] = (counts[t] || 0) + 1;
719
+ }
720
+ }
721
+
722
+ if (opts.json) {
723
+ console.log(JSON.stringify(counts, null, 2));
724
+ return;
725
+ }
726
+
727
+ const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
728
+ if (!entries.length) { console.log(c.dim('No tags found across any profiles.')); return; }
729
+ console.log(c.bold(c.cyan('All tags:\n')));
730
+ for (const [tag, count] of entries) {
731
+ console.log(` ${tag.padEnd(30)} ${c.dim(`(${count})`)}`);
732
+ }
733
+ console.log();
734
+ }
735
+ });
736
+
503
737
  // --- config ---
504
738
  program.command('config')
505
739
  .description('View/edit settings')
@@ -512,6 +746,58 @@ program.command('config')
512
746
  console.log(` AI backend: ${(() => { const b = getBackendName(); return b !== 'none' ? c.green(b) : c.red('not found'); })()}`);
513
747
  });
514
748
 
749
+ // --- init ---
750
+ program.command('init')
751
+ .description('Interactive first-time setup for VIPCare')
752
+ .action(async () => {
753
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
754
+
755
+ try {
756
+ console.log(c.bold(c.cyan('\nWelcome to VIPCare!\n')));
757
+
758
+ const defaultDir = path.join(os.homedir(), 'Projects', 'vip-crm', 'profiles');
759
+ const profilesAnswer = await rl.question(`Where should profiles be stored?\n (default: ${defaultDir}) > `);
760
+ const profilesDir = profilesAnswer.trim() || defaultDir;
761
+
762
+ console.log(`\n${c.cyan('AI backend preference:')}`);
763
+ console.log(' 1. Auto-detect (recommended)');
764
+ console.log(' 2. Claude CLI');
765
+ console.log(' 3. Anthropic API');
766
+ console.log(' 4. GitHub Copilot CLI');
767
+ const backendAnswer = await rl.question(' > ');
768
+ const backendChoice = backendAnswer.trim() || '1';
769
+
770
+ const backendMap = { '1': 'auto', '2': 'claude-cli', '3': 'anthropic-api', '4': 'github-copilot' };
771
+ const aiBackend = backendMap[backendChoice] || 'auto';
772
+
773
+ const config = {
774
+ profiles_dir: profilesDir.replace(/^~/, os.homedir()),
775
+ ai_backend: aiBackend,
776
+ };
777
+
778
+ if (aiBackend === 'anthropic-api') {
779
+ const apiKey = await rl.question('\nAnthropic API key: ');
780
+ if (apiKey.trim()) {
781
+ config.anthropic_api_key = apiKey.trim();
782
+ }
783
+ }
784
+
785
+ // Merge with existing config to preserve other settings
786
+ let existing = {};
787
+ try {
788
+ const { loadConfig: lc } = await import('../lib/config.js');
789
+ existing = lc();
790
+ } catch {}
791
+ saveConfig({ ...existing, ...config });
792
+
793
+ const { CONFIG_FILE: cfgPath } = await import('../lib/config.js');
794
+ console.log(c.green(`\nConfig saved to ${cfgPath}`));
795
+ console.log(`You're ready! Try: ${c.cyan('vip add "Sam Altman" --company "OpenAI"')}\n`);
796
+ } finally {
797
+ rl.close();
798
+ }
799
+ });
800
+
515
801
  // --- export ---
516
802
  program.command('export')
517
803
  .description('Export all profiles as JSON')
@@ -587,4 +873,146 @@ program.command('import')
587
873
  console.log(`\nDone: ${imported} imported, ${skipped} skipped.`);
588
874
  });
589
875
 
876
+ // --- stats ---
877
+ program.command('stats')
878
+ .description('Show dashboard overview')
879
+ .option('--json', 'Output as JSON')
880
+ .action((opts) => {
881
+ const profiles = listProfiles();
882
+ const activity = readChangelog(7);
883
+ const backend = getBackendName();
884
+ const birdAvailable = checkTool('bird');
885
+
886
+ // Find most recently updated profile
887
+ let lastUpdated = null;
888
+ let lastUpdatedName = null;
889
+ for (const p of profiles) {
890
+ if (!lastUpdated || p.updated > lastUpdated) {
891
+ lastUpdated = p.updated;
892
+ lastUpdatedName = p.name;
893
+ }
894
+ }
895
+
896
+ if (opts.json) {
897
+ const data = {
898
+ profileCount: profiles.length,
899
+ lastUpdated: lastUpdated ? { date: lastUpdated, name: lastUpdatedName } : null,
900
+ aiBackend: backend,
901
+ birdCli: birdAvailable ? 'available' : 'not found',
902
+ recentActivity: activity.map(e => ({
903
+ date: (e.timestamp || '').slice(0, 10),
904
+ name: e.name,
905
+ type: e.type,
906
+ summary: e.summary,
907
+ })),
908
+ };
909
+ console.log(JSON.stringify(data, null, 2));
910
+ return;
911
+ }
912
+
913
+ console.log(c.bold(c.cyan('\nVIPCare Stats')));
914
+ console.log('─'.repeat(15));
915
+ console.log(` Profiles: ${c.green(String(profiles.length))}`);
916
+ if (lastUpdated) {
917
+ console.log(` Last updated: ${c.green(lastUpdated)} (${lastUpdatedName})`);
918
+ } else {
919
+ console.log(` Last updated: ${c.dim('n/a')}`);
920
+ }
921
+ console.log(` AI backend: ${backend !== 'none' ? c.green(backend) : c.dim('not found')}`);
922
+ console.log(` Bird CLI: ${birdAvailable ? c.green('available') : c.dim('not found')}`);
923
+
924
+ if (activity.length) {
925
+ console.log(`\n Recent activity (7 days):`);
926
+ for (const e of activity) {
927
+ const date = (e.timestamp || '').slice(0, 10);
928
+ const label = e.summary || (e.type === 'created' ? 'Profile created' : 'Profile updated');
929
+ console.log(` [${date}] ${e.name} — ${label}`);
930
+ }
931
+ } else {
932
+ console.log(`\n ${c.dim('No recent activity (7 days)')}`);
933
+ }
934
+ console.log();
935
+ });
936
+
937
+ // --- regenerate ---
938
+ program.command('regenerate')
939
+ .description('Re-synthesize all profiles with current AI template')
940
+ .option('--dry-run', 'Show what would be regenerated without doing it')
941
+ .option('--no-ai', 'Skip AI synthesis (raw data only)')
942
+ .action(async (opts) => {
943
+ const profiles = listProfiles();
944
+ if (!profiles.length) {
945
+ console.log(c.dim('No profiles to regenerate. Use "vip add" to create one.'));
946
+ return;
947
+ }
948
+
949
+ if (opts.dryRun) {
950
+ console.log(c.cyan('Dry run — would regenerate:\n'));
951
+ profiles.forEach((p, i) => {
952
+ console.log(` [${i + 1}/${profiles.length}] ${p.name}`);
953
+ });
954
+ console.log(`\n${profiles.length} profile(s) would be regenerated.`);
955
+ return;
956
+ }
957
+
958
+ console.log(c.cyan('Regenerating all profiles with current template...'));
959
+ let count = 0;
960
+
961
+ for (let i = 0; i < profiles.length; i++) {
962
+ const p = profiles[i];
963
+ const prefix = ` [${i + 1}/${profiles.length}] ${p.name}`;
964
+ const stop = spinner(`${prefix}...`);
965
+
966
+ try {
967
+ const content = loadProfile(p.slug);
968
+ if (!content) {
969
+ stop();
970
+ console.log(`${prefix}... ${c.yellow('skipped (not found)')}`);
971
+ continue;
972
+ }
973
+
974
+ const { extractMetadata } = await import('../lib/monitor.js');
975
+ const meta = extractMetadata(content);
976
+ const personName = meta.name || p.name;
977
+
978
+ const person = resolveFromName(personName);
979
+ if (meta.twitterHandle) person.twitterHandle = person.twitterHandle || meta.twitterHandle;
980
+ if (meta.linkedinUrl) person.linkedinUrl = person.linkedinUrl || meta.linkedinUrl;
981
+
982
+ const [rawData, sources] = gatherData(person);
983
+ if (!rawData.trim()) {
984
+ stop();
985
+ console.log(`${prefix}... ${c.yellow('skipped (no data)')}`);
986
+ continue;
987
+ }
988
+
989
+ let profile;
990
+ if (opts.ai === false) {
991
+ profile = `# ${personName}\n\n## Raw Data\n\n${rawData}`;
992
+ } else {
993
+ profile = await synthesizeProfile(rawData, sources);
994
+ }
995
+
996
+ saveProfile(personName, profile);
997
+ stop();
998
+ console.log(`${prefix}... ${c.green('done')}`);
999
+
1000
+ appendChangelog({
1001
+ timestamp: new Date().toISOString(),
1002
+ name: personName,
1003
+ slug: p.slug,
1004
+ type: 'updated',
1005
+ summary: `Profile regenerated with current template`,
1006
+ });
1007
+
1008
+ count++;
1009
+ } catch (e) {
1010
+ stop();
1011
+ console.log(`${prefix}... ${c.red(`error: ${e.message}`)}`);
1012
+ }
1013
+ }
1014
+
1015
+ console.log(`\n${count} profile(s) regenerated.`);
1016
+ });
1017
+
590
1018
  program.parseAsync();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vipcare",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Auto-build VIP person profiles from Twitter/LinkedIn public data",
5
5
  "type": "module",
6
6
  "bin": {
package/web/index.html CHANGED
@@ -6,9 +6,10 @@
6
6
  <title>VIPCare - Baseball Cards</title>
7
7
  <style>
8
8
  * { margin: 0; padding: 0; box-sizing: border-box; }
9
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; padding: 20px; }
9
+ html { scroll-behavior: smooth; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; padding: 20px; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); }
10
11
  h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8; }
11
- .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; max-width: 1200px; margin: 0 auto; }
12
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; max-width: 1200px; margin: 0 auto; }
12
13
 
13
14
  .card {
14
15
  background: linear-gradient(145deg, #1e293b, #334155);
@@ -19,8 +20,11 @@ h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8;
19
20
  transition: transform 0.2s, box-shadow 0.2s;
20
21
  position: relative;
21
22
  overflow: hidden;
23
+ min-height: 44px;
24
+ -webkit-tap-highlight-color: transparent;
22
25
  }
23
26
  .card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px rgba(56,189,248,0.15); }
27
+ .card:active { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(56,189,248,0.1); }
24
28
  .card::before {
25
29
  content: '';
26
30
  position: absolute;
@@ -33,17 +37,17 @@ h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8;
33
37
  .card-name { font-size: 1.4em; font-weight: 700; color: #f1f5f9; }
34
38
  .card-role { font-size: 0.85em; color: #94a3b8; margin-top: 2px; }
35
39
  .card-badges { display: flex; gap: 6px; }
36
- .badge { padding: 3px 8px; border-radius: 6px; font-size: 0.7em; font-weight: 700; }
40
+ .badge { padding: 4px 10px; border-radius: 6px; font-size: 0.75em; font-weight: 700; min-height: 28px; display: inline-flex; align-items: center; }
37
41
  .badge-disc { background: #38bdf8; color: #0f172a; }
38
42
  .badge-mbti { background: #818cf8; color: #0f172a; }
39
43
 
40
44
  .card-quote { font-style: italic; color: #94a3b8; font-size: 0.8em; margin: 10px 0; padding: 8px 12px; border-left: 3px solid #475569; }
41
45
 
42
46
  .radar-container { display: flex; justify-content: center; margin: 16px 0; }
43
- .radar { width: 200px; height: 200px; }
47
+ .radar { width: 200px; height: 200px; max-width: 100%; }
44
48
 
45
49
  .tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
46
- .tag { background: #1e3a5f; color: #38bdf8; padding: 3px 10px; border-radius: 12px; font-size: 0.75em; }
50
+ .tag { background: #1e3a5f; color: #38bdf8; padding: 4px 12px; border-radius: 12px; font-size: 0.75em; min-height: 28px; display: inline-flex; align-items: center; }
47
51
 
48
52
  .expertise { margin: 10px 0; }
49
53
  .expertise-title { font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
@@ -51,7 +55,7 @@ h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8;
51
55
  .superpower { color: #fbbf24; font-weight: 600; font-size: 0.85em; margin: 6px 0; }
52
56
 
53
57
  .tips { margin-top: 12px; border-top: 1px solid #475569; padding-top: 12px; }
54
- .tip-row { display: flex; gap: 4px; font-size: 0.8em; margin: 4px 0; color: #cbd5e1; }
58
+ .tip-row { display: flex; gap: 4px; font-size: 0.8em; margin: 4px 0; color: #cbd5e1; min-height: 44px; align-items: center; }
55
59
  .tip-icon { width: 20px; text-align: center; }
56
60
  .tip-label { color: #64748b; min-width: 55px; }
57
61
 
@@ -61,11 +65,32 @@ h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8;
61
65
  .modal {
62
66
  background: #1e293b; border-radius: 16px; max-width: 600px; width: 100%; max-height: 90vh; overflow-y: auto; padding: 32px;
63
67
  border: 1px solid #475569;
68
+ -webkit-overflow-scrolling: touch;
64
69
  }
65
- .modal-close { float: right; background: none; border: none; color: #94a3b8; font-size: 1.5em; cursor: pointer; }
70
+ .modal-close { float: right; background: none; border: none; color: #94a3b8; font-size: 1.5em; cursor: pointer; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
66
71
  .modal h2 { color: #38bdf8; margin: 16px 0 8px; font-size: 1.1em; }
67
72
  .modal p, .modal li { color: #cbd5e1; font-size: 0.9em; line-height: 1.6; }
68
73
  .modal ul { padding-left: 20px; }
74
+
75
+ /* Mobile: screens < 480px */
76
+ @media (max-width: 480px) {
77
+ body { padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); }
78
+ h1 { font-size: 1.5em; margin: 12px 0 20px; }
79
+ .grid { grid-template-columns: 1fr; gap: 16px; }
80
+ .card { padding: 18px; }
81
+ .card-name { font-size: 1.25em; }
82
+ .card-role { font-size: 0.9em; }
83
+ .card-quote { font-size: 0.85em; }
84
+ .tip-row { font-size: 0.85em; }
85
+ .radar { width: 180px; height: 180px; }
86
+ .badge { font-size: 0.8em; padding: 5px 12px; }
87
+ .tag { font-size: 0.8em; padding: 5px 14px; }
88
+
89
+ .modal-overlay { padding: 0; align-items: stretch; }
90
+ .modal { max-width: 100%; max-height: 100vh; height: 100%; border-radius: 0; padding: 20px; padding-top: max(20px, env(safe-area-inset-top)); padding-bottom: max(20px, env(safe-area-inset-bottom)); }
91
+ .modal h2 { font-size: 1.15em; }
92
+ .modal p, .modal li { font-size: 0.95em; line-height: 1.7; }
93
+ }
69
94
  </style>
70
95
  </head>
71
96
  <body>
@@ -116,10 +141,10 @@ function radarSvg(scores, size = 200) {
116
141
  const y = cy + r * Math.sin(angle);
117
142
  axes += `<line x1="${cx}" y1="${cy}" x2="${x}" y2="${y}" stroke="#334155" stroke-width="0.5"/>`;
118
143
 
119
- const lx = cx + (r + 18) * Math.cos(angle);
120
- const ly = cy + (r + 18) * Math.sin(angle);
144
+ const lx = cx + (r + 22) * Math.cos(angle);
145
+ const ly = cy + (r + 22) * Math.sin(angle);
121
146
  const label = SCORE_LABELS[keys[i]] || keys[i];
122
- labels += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#64748b" font-size="7">${label}</text>`;
147
+ labels += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#64748b" font-size="9">${label}</text>`;
123
148
 
124
149
  const val = (scores[keys[i]] || 0) / 5;
125
150
  const dx = cx + r * val * Math.cos(angle);